import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import type { NitterPluginConfig } from "./config.js";
import type { FeedPollState, FeedSubscription } from "./feed-state.js";
import type { PollerApi, PollerServiceDeps } from "./poller-service.js";
import { createNitterPollerService, getActiveFeedState } from "./poller-service.js";
import type { NitterRssFetchResult, NitterRssItem } from "./rss.js";

function makeItem(id: string, username = "testuser"): NitterRssItem {
  return {
    id,
    username,
    title: `Tweet ${id}`,
    content: `Content of tweet ${id}`,
    link: `https://nitter.example.com/${username}/status/${id}`,
    pubDate: new Date().toISOString(),
    pubDateMs: Date.now(),
    isRetweet: false,
    isReply: false,
    mediaUrls: [],
  };
}

function makeSubscription(overrides: Partial<FeedSubscription> = {}): FeedSubscription {
  return {
    username: "testuser",
    addedAtMs: Date.now(),
    enabled: true,
    source: "config",
    ...overrides,
  };
}

function makeConfig(overrides: Partial<NitterPluginConfig> = {}): NitterPluginConfig {
  return {
    enabled: true,
    instanceUrl: "http://localhost:8080",
    pollIntervalMs: 300_000,
    maxItemsPerPoll: 20,
    requestTimeoutMs: 15_000,
    deliveryMode: "both",
    feeds: [{ username: "testuser", enabled: true }],
    ...overrides,
  };
}

function makeMockApi(): PollerApi {
  return {
    enqueueSystemEvent: vi.fn(),
    logger: {
      info: vi.fn(),
      warn: vi.fn(),
      error: vi.fn(),
    },
  };
}

function makeMockFeedState() {
  const subs: FeedSubscription[] = [makeSubscription()];
  const pollStates: Record<string, FeedPollState> = {};

  return {
    instance: {
      load: vi.fn<() => Promise<void>>().mockResolvedValue(undefined),
      save: vi.fn<() => Promise<void>>().mockResolvedValue(undefined),
      syncFromConfig: vi.fn(),
      listSubscriptions: vi.fn(() => subs),
      getSubscription: vi.fn((username: string) => subs.find((s) => s.username === username)),
      getPollState: vi.fn(
        (username: string): FeedPollState =>
          pollStates[username] ?? {
            lastPollAtMs: 0,
            seenIds: [],
            consecutiveErrors: 0,
          },
      ),
      recordPoll: vi.fn(
        (_username: string, result: { items?: Array<{ id: string }>; error?: string }) => {
          if (result.error) return [];
          return result.items ?? [];
        },
      ),
      addSubscription: vi.fn(),
      removeSubscription: vi.fn(),
    },
    subs,
    pollStates,
  };
}

function makeDeps(
  overrides: {
    api?: PollerApi;
    config?: NitterPluginConfig;
    feedState?: ReturnType<typeof makeMockFeedState>;
    fetchRss?: PollerServiceDeps["fetchRss"];
    deliverItems?: PollerServiceDeps["deliverItems"];
  } = {},
) {
  const api = overrides.api ?? makeMockApi();
  const config = overrides.config ?? makeConfig();
  const mockFeedState = overrides.feedState ?? makeMockFeedState();

  // Must be a regular function (not arrow) so it can be called with `new`
  const FeedStateManagerMock = vi.fn(function (this: unknown) {
    return mockFeedState.instance;
  }) as unknown as PollerServiceDeps["FeedStateManager"];

  const fetchRss =
    overrides.fetchRss ??
    vi.fn<PollerServiceDeps["fetchRss"]>().mockResolvedValue({
      ok: true as const,
      items: [makeItem("1")],
    });

  const deliverItems =
    overrides.deliverItems ??
    vi.fn<PollerServiceDeps["deliverItems"]>().mockReturnValue({
      sessionDelivered: false,
      channelDelivered: false,
      itemCount: 0,
    });

  const deps: PollerServiceDeps = {
    api,
    config,
    fetchRss: fetchRss as PollerServiceDeps["fetchRss"],
    FeedStateManager: FeedStateManagerMock,
    deliverItems: deliverItems as PollerServiceDeps["deliverItems"],
  };

  return { deps, api, config, mockFeedState, fetchRss, deliverItems, FeedStateManagerMock };
}

const serviceCtx = {
  config: {} as never,
  stateDir: "/tmp/nitter-test",
  logger: {
    info: vi.fn(),
    warn: vi.fn(),
    error: vi.fn(),
  },
};

describe("nitter poller service", () => {
  beforeEach(() => {
    vi.useFakeTimers();
  });

  afterEach(() => {
    vi.useRealTimers();
  });

  test("starts and initializes feed state", async () => {
    const { deps, mockFeedState } = makeDeps();

    const service = createNitterPollerService(deps);

    await service.start(serviceCtx);

    expect(mockFeedState.instance.load).toHaveBeenCalledOnce();
    expect(mockFeedState.instance.syncFromConfig).toHaveBeenCalledWith(deps.config.feeds);
    expect(getActiveFeedState()).toBe(mockFeedState.instance);

    service.stop?.(serviceCtx);
  });

  test("startup delay of 10s before first poll", async () => {
    const { deps, fetchRss } = makeDeps();

    const service = createNitterPollerService(deps);

    await service.start(serviceCtx);

    // No poll yet — still within startup delay
    expect(fetchRss).not.toHaveBeenCalled();

    // Advance 9 seconds — still within startup delay
    await vi.advanceTimersByTimeAsync(9_000);
    expect(fetchRss).not.toHaveBeenCalled();

    // Advance past the 10s startup delay
    await vi.advanceTimersByTimeAsync(1_000);
    expect(fetchRss).toHaveBeenCalled();

    service.stop?.(serviceCtx);
  });

  test("polls feeds at configured interval", async () => {
    const { deps, fetchRss } = makeDeps();

    const service = createNitterPollerService(deps);

    await service.start(serviceCtx);

    // Pass startup delay
    await vi.advanceTimersByTimeAsync(10_000);
    expect(fetchRss).toHaveBeenCalledTimes(1);

    // First interval tick at 30s
    await vi.advanceTimersByTimeAsync(30_000);
    expect(fetchRss).toHaveBeenCalledTimes(2);

    // Second interval tick at 60s
    await vi.advanceTimersByTimeAsync(30_000);
    expect(fetchRss).toHaveBeenCalledTimes(3);

    service.stop?.(serviceCtx);
  });

  test("skips feeds not yet due", async () => {
    const mockFeedState = makeMockFeedState();
    // Feed was polled recently — not due yet
    mockFeedState.pollStates["testuser"] = {
      lastPollAtMs: Date.now(),
      seenIds: [],
      consecutiveErrors: 0,
    };
    // Return this poll state when queried
    mockFeedState.instance.getPollState.mockImplementation(
      (username: string): FeedPollState =>
        mockFeedState.pollStates[username] ?? {
          lastPollAtMs: 0,
          seenIds: [],
          consecutiveErrors: 0,
        },
    );

    const { deps, fetchRss } = makeDeps({ feedState: mockFeedState });

    const service = createNitterPollerService(deps);

    await service.start(serviceCtx);

    // Pass startup delay to trigger first poll cycle
    await vi.advanceTimersByTimeAsync(10_000);

    // fetchRss should NOT be called because the feed was polled "just now"
    expect(fetchRss).not.toHaveBeenCalled();

    service.stop?.(serviceCtx);
  });

  test("new items trigger delivery", async () => {
    const newItems = [makeItem("new-1"), makeItem("new-2")];
    const mockFeedState = makeMockFeedState();
    mockFeedState.instance.recordPoll.mockReturnValue(newItems);

    const { deps, fetchRss, deliverItems } = makeDeps({ feedState: mockFeedState });
    (fetchRss as ReturnType<typeof vi.fn>).mockResolvedValue({
      ok: true,
      items: newItems,
    } satisfies NitterRssFetchResult);

    const service = createNitterPollerService(deps);

    await service.start(serviceCtx);
    await vi.advanceTimersByTimeAsync(10_000);

    expect(deliverItems).toHaveBeenCalledWith(
      expect.objectContaining({
        items: newItems,
        subscription: expect.objectContaining({ username: "testuser" }),
        config: deps.config,
        api: deps.api,
      }),
    );

    service.stop?.(serviceCtx);
  });

  test("fetch error does not crash loop (error isolation)", async () => {
    const mockFeedState = makeMockFeedState();
    // Two enabled subscriptions
    const subs = [
      makeSubscription({ username: "user-fail" }),
      makeSubscription({ username: "user-ok" }),
    ];
    mockFeedState.subs.length = 0;
    mockFeedState.subs.push(...subs);

    const fetchRss = vi.fn<PollerServiceDeps["fetchRss"]>().mockImplementation(async (opts) => {
      if (opts.username === "user-fail") {
        return { ok: false as const, error: "Network error" };
      }
      return { ok: true as const, items: [makeItem("ok-1", "user-ok")] };
    });

    const { deps, deliverItems, api } = makeDeps({
      feedState: mockFeedState,
      fetchRss: fetchRss as PollerServiceDeps["fetchRss"],
    });

    const service = createNitterPollerService(deps);

    await service.start(serviceCtx);
    await vi.advanceTimersByTimeAsync(10_000);

    // Both feeds should have been attempted
    expect(fetchRss).toHaveBeenCalledTimes(2);

    // Error was recorded for the failing feed
    expect(mockFeedState.instance.recordPoll).toHaveBeenCalledWith("user-fail", {
      error: "Network error",
    });

    // Successful feed should still have been polled
    expect(mockFeedState.instance.recordPoll).toHaveBeenCalledWith("user-ok", {
      items: [expect.objectContaining({ id: "ok-1" })],
    });

    // Warning logged for the error
    expect(api.logger.warn).toHaveBeenCalledWith(expect.stringContaining("user-fail"));

    service.stop?.(serviceCtx);
  });

  test("stop clears interval and timeout", async () => {
    const { deps, fetchRss } = makeDeps();

    const service = createNitterPollerService(deps);

    await service.start(serviceCtx);
    service.stop?.(serviceCtx);

    expect(getActiveFeedState()).toBeNull();

    // Advancing time should not trigger any polls
    await vi.advanceTimersByTimeAsync(60_000);
    expect(fetchRss).not.toHaveBeenCalled();
  });

  test("stop clears interval after polling has started", async () => {
    const { deps, fetchRss } = makeDeps();

    const service = createNitterPollerService(deps);

    await service.start(serviceCtx);

    // Let the startup delay pass and first poll happen
    await vi.advanceTimersByTimeAsync(10_000);
    expect(fetchRss).toHaveBeenCalledTimes(1);

    // Stop the service
    service.stop?.(serviceCtx);

    // Further time advancement should not trigger more polls
    await vi.advanceTimersByTimeAsync(60_000);
    expect(fetchRss).toHaveBeenCalledTimes(1);
  });

  test("exception in fetchRss does not crash the loop", async () => {
    const fetchRss = vi
      .fn<PollerServiceDeps["fetchRss"]>()
      .mockRejectedValueOnce(new Error("Connection refused"))
      .mockResolvedValue({ ok: true as const, items: [] });

    const mockFeedState = makeMockFeedState();
    const { deps, api } = makeDeps({
      feedState: mockFeedState,
      fetchRss: fetchRss as PollerServiceDeps["fetchRss"],
    });

    const service = createNitterPollerService(deps);

    await service.start(serviceCtx);

    // First poll — throws
    await vi.advanceTimersByTimeAsync(10_000);
    expect(fetchRss).toHaveBeenCalledTimes(1);
    expect(mockFeedState.instance.recordPoll).toHaveBeenCalledWith("testuser", {
      error: "Connection refused",
    });
    expect(api.logger.warn).toHaveBeenCalledWith(expect.stringContaining("Connection refused"));

    // Second poll — succeeds (loop survived)
    await vi.advanceTimersByTimeAsync(30_000);
    expect(fetchRss).toHaveBeenCalledTimes(2);

    service.stop?.(serviceCtx);
  });
});
