import { describe, test, expect, mock } from "bun:test";
import { JiraPoller } from "@/poller/jira-poller.js";
import { createFakeQueue } from "../_helpers/fake-queue.js";
import { mockLogger } from "../_helpers/mock-logger.js";
import { FakeMetrics } from "../_helpers/fake-metrics.js";
import type { JiraIssue } from "@/types/index.js";
import { METRIC } from "@/metrics/names.js";

// ── minimal stubs ────────────────────────────────────────────────────────────

function makeIssue(overrides: Partial<JiraIssue> = {}): JiraIssue {
  return {
    id: "10001",
    key: "SQDP-1",
    summary: "Fix bug",
    description: "desc",
    issueType: "Bug",
    status: "To Do",
    created: "2026-01-01T00:00:00Z",
    updated: "2026-01-01T00:00:00Z",
    acceptanceCriteria: null,
    stacktrace: null,
    flow: null,
    assignee: null,
    parent: null,
    subtasks: [],
    children: [],
    attachments: [],
    comments: [],
    components: [],
    ...overrides,
  };
}

function makeFakeRegistry() {
  return {
    getAll: () => [{ name: "repo-a", url: "https://github.com/acme/repo-a" }],
  } as never;
}

function buildDeps(
  opts: {
    issues?: JiraIssue[];
    accountId?: string | null;
    searchError?: Error;
    squadScope?: string[];
    currentJobKind?: "pr_comment" | "jira_ticket_review" | null;
    queueBusy?: boolean;
  } = {},
) {
  const {
    issues = [],
    accountId = "acc123",
    squadScope = [],
    currentJobKind = null,
    queueBusy = false,
  } = opts;

  const jiraClient = {
    resolveAccountIdForBotEmail: mock(async (_email: string) => accountId),
    searchIssues: mock(async (_jql: string) => {
      if (opts.searchError) throw opts.searchError;
      return { issues, total: issues.length };
    }),
  };

  const queue = createFakeQueue({
    busy: queueBusy,
    currentJobKind: currentJobKind ?? undefined,
  });

  const logger = mockLogger();
  const metrics = new FakeMetrics();

  const getGitHubToken = mock(async () => "tok");
  const repoRegistry = makeFakeRegistry();
  const buildGitHubClient = mock(() => ({ listOpenPrs: mock(async () => []) }));

  return {
    jiraClient,
    queue,
    logger,
    squadScope,
    metrics,
    getGitHubToken,
    repoRegistry,
    buildGitHubClient,
  };
}

function makePoller(parts: {
  jiraClient: unknown;
  queue: ReturnType<typeof createFakeQueue>;
  logger: ReturnType<typeof mockLogger>;
  metrics: FakeMetrics;
  squadScope?: string[];
  botEmail?: string;
  getGitHubToken?: ReturnType<typeof mock>;
  repoRegistry?: unknown;
  buildGitHubClient?: ReturnType<typeof mock>;
}) {
  return new JiraPoller({
    jiraClient: parts.jiraClient as never,
    queue: parts.queue,
    botEmail: parts.botEmail ?? "bot@example.com",
    squadScope: parts.squadScope ?? [],
    logger: parts.logger,
    metrics: parts.metrics,
    getGitHubToken: parts.getGitHubToken ?? mock(async () => "tok"),
    repoRegistry: (parts.repoRegistry ?? makeFakeRegistry()) as never,
    buildGitHubClient: (parts.buildGitHubClient ??
      mock(() => ({ listOpenPrs: mock(async () => []) }))) as never,
  });
}

// ── tests ────────────────────────────────────────────────────────────────────

describe("JiraPoller", () => {
  describe("poll()", () => {
    test("resolves account id from bot email on first poll", async () => {
      const { jiraClient, queue, logger, metrics } = buildDeps({ issues: [] });
      const poller = makePoller({ jiraClient, queue, logger, metrics });

      await poller.poll();

      expect(jiraClient.resolveAccountIdForBotEmail).toHaveBeenCalledWith("bot@example.com");
    });

    test("skips account resolution on subsequent polls (caches accountId)", async () => {
      const { jiraClient, queue, logger, metrics } = buildDeps({ issues: [] });
      const poller = makePoller({ jiraClient, queue, logger, metrics });

      await poller.poll();
      await poller.poll();

      // Only one resolution even after two polls
      expect(
        (jiraClient.resolveAccountIdForBotEmail as ReturnType<typeof mock>).mock.calls.length,
      ).toBe(1);
    });

    test("enqueues each matching issue with jobKind='jira'", async () => {
      const issue1 = makeIssue({ id: "100", key: "SQDP-1" });
      const issue2 = makeIssue({ id: "101", key: "SQDP-2" });
      const { jiraClient, queue, logger, metrics } = buildDeps({ issues: [issue1, issue2] });

      const poller = makePoller({ jiraClient, queue, logger, metrics });

      const count = await poller.poll();

      expect(count).toBe(2);
      expect(queue.enqueued).toHaveLength(2);
      expect(queue.enqueued[0]).toMatchObject({ jobKind: "jira", issueKey: "SQDP-1" });
      expect(queue.enqueued[1]).toMatchObject({ jobKind: "jira", issueKey: "SQDP-2" });
    });

    test("skips issues that have subtasks", async () => {
      const issue = makeIssue({
        subtasks: [
          { id: "s1", key: "SQDP-2", summary: "Sub", status: "To Do", issueType: "Subtask" },
        ],
      });
      const { jiraClient, queue, logger, metrics } = buildDeps({ issues: [issue] });

      const poller = makePoller({ jiraClient, queue, logger, metrics });

      const count = await poller.poll();

      expect(count).toBe(0);
      expect(queue.enqueued).toHaveLength(0);
    });

    test("skips issues that have children", async () => {
      const issue = makeIssue({
        children: [
          { id: "c1", key: "SQDP-3", summary: "Child", status: "To Do", issueType: "Sub-task" },
        ],
      });
      const { jiraClient, queue, logger, metrics } = buildDeps({ issues: [issue] });

      const poller = makePoller({ jiraClient, queue, logger, metrics });

      const count = await poller.poll();

      expect(count).toBe(0);
    });

    test("builds JQL without project filter when squadScope is empty", async () => {
      const { jiraClient, queue, logger, metrics } = buildDeps({ issues: [], squadScope: [] });
      const poller = makePoller({ jiraClient, queue, logger, metrics });

      await poller.poll();

      const calls = (jiraClient.searchIssues as ReturnType<typeof mock>).mock.calls;
      const jql = calls[0]?.[0] as string;
      expect(jql).not.toContain("project in");
      expect(jql).toContain('assignee = "acc123"');
    });

    test("builds JQL with project filter when squadScope has values", async () => {
      const { jiraClient, queue, logger, metrics } = buildDeps({
        issues: [],
        squadScope: ["SQDP", "SQZB"],
      });
      const poller = makePoller({
        jiraClient,
        queue,
        logger,
        metrics,
        squadScope: ["SQDP", "SQZB"],
      });

      await poller.poll();

      const calls = (jiraClient.searchIssues as ReturnType<typeof mock>).mock.calls;
      const jql = calls[0]?.[0] as string;
      expect(jql).toContain("project in (SQDP, SQZB)");
    });

    test("returns 0 when queue is busy", async () => {
      const { jiraClient, queue, logger, metrics } = buildDeps({ queueBusy: true });
      const poller = makePoller({ jiraClient, queue, logger, metrics });

      const count = await poller.poll();

      expect(count).toBe(0);
      expect(jiraClient.resolveAccountIdForBotEmail).not.toHaveBeenCalled();
    });

    test("returns 0 when pr_comment job is running", async () => {
      const { jiraClient, queue, logger, metrics } = buildDeps({ currentJobKind: "pr_comment" });
      const poller = makePoller({ jiraClient, queue, logger, metrics });

      const count = await poller.poll();

      expect(count).toBe(0);
      expect(jiraClient.resolveAccountIdForBotEmail).not.toHaveBeenCalled();
    });

    test("returns 0 when jira_ticket_review job is running", async () => {
      const { jiraClient, queue, logger, metrics } = buildDeps({
        currentJobKind: "jira_ticket_review",
      });
      const poller = makePoller({ jiraClient, queue, logger, metrics });

      const count = await poller.poll();

      expect(count).toBe(0);
    });

    test("logs error and returns 0 when Jira API throws", async () => {
      const { jiraClient, queue, logger, metrics } = buildDeps({
        searchError: new Error("network failure"),
      });
      const poller = makePoller({ jiraClient, queue, logger, metrics });

      const count = await poller.poll();

      expect(count).toBe(0);
      expect(logger.error).toHaveBeenCalled();
    });

    test("returns 0 when account id could not be resolved", async () => {
      const { jiraClient, queue, logger, metrics } = buildDeps({ accountId: null });
      const poller = makePoller({ jiraClient, queue, logger, metrics });

      const count = await poller.poll();

      expect(count).toBe(0);
      expect(logger.error).toHaveBeenCalled();
    });
  });

  describe("In Progress orphan recovery", () => {
    const oldTs = () => new Date(Date.now() - 5 * 60_000).toISOString();

    test("widens JQL to include In Progress", async () => {
      const { jiraClient, queue, logger, metrics } = buildDeps({ issues: [] });
      const poller = makePoller({ jiraClient, queue, logger, metrics });

      await poller.poll();

      const calls = (jiraClient.searchIssues as ReturnType<typeof mock>).mock.calls;
      const jql = calls[0]?.[0] as string;
      expect(jql).toContain('status IN ("To Do", "In Progress")');
    });

    test("re-enqueues an orphaned In Progress issue with no open PR", async () => {
      const issue = makeIssue({ key: "SQDP-1", status: "In Progress", updated: oldTs() });
      const { jiraClient, queue, logger, metrics } = buildDeps({ issues: [issue] });
      const buildGitHubClient = mock(() => ({ listOpenPrs: mock(async () => []) }));
      const poller = makePoller({ jiraClient, queue, logger, metrics, buildGitHubClient });

      await poller.poll();

      expect(queue.enqueued.some((j) => j.jobKind === "jira" && j.issueKey === "SQDP-1")).toBe(
        true,
      );
    });

    test("skips an In Progress issue that already has an open PR", async () => {
      const issue = makeIssue({ key: "SQDP-1", status: "In Progress", updated: oldTs() });
      const { jiraClient, queue, logger, metrics } = buildDeps({ issues: [issue] });
      const buildGitHubClient = mock(() => ({
        listOpenPrs: mock(async () => [
          { number: 7, url: "u", title: "t", author: null, headRef: "fix/SQDP-1-fix-login-bug" },
        ]),
      }));
      const poller = makePoller({ jiraClient, queue, logger, metrics, buildGitHubClient });

      await poller.poll();

      expect(queue.enqueued.length).toBe(0);
    });

    test("skips an orphaned subtask whose PR is on the parent-summary branch (C1 parity)", async () => {
      // The fix pipeline pushes a subtask PR on buildBranchName(subtaskKey, PARENT summary),
      // while the subtask's own Jira summary carries a repo prefix. The poller must still
      // find the PR via the issue-key branch prefix, not a summary-reconstructed branch.
      const issue = makeIssue({
        key: "SQDP-1",
        summary: "BACKEND | fix login bug",
        status: "In Progress",
        updated: oldTs(),
      });
      const { jiraClient, queue, logger, metrics } = buildDeps({ issues: [issue] });
      const buildGitHubClient = mock(() => ({
        listOpenPrs: mock(async () => [
          // PR head is the BARE-summary branch (no "backend-" segment).
          { number: 9, url: "u", title: "t", author: null, headRef: "fix/SQDP-1-fix-login-bug" },
        ]),
      }));
      const poller = makePoller({ jiraClient, queue, logger, metrics, buildGitHubClient });

      await poller.poll();

      expect(queue.enqueued.length).toBe(0);
    });

    test("skips an In Progress issue updated too recently (within ORPHAN_MIN_AGE_MS)", async () => {
      const issue = makeIssue({
        key: "SQDP-1",
        status: "In Progress",
        updated: new Date().toISOString(),
      });
      const { jiraClient, queue, logger, metrics } = buildDeps({ issues: [issue] });
      const buildGitHubClient = mock(() => ({ listOpenPrs: mock(async () => []) }));
      const poller = makePoller({ jiraClient, queue, logger, metrics, buildGitHubClient });

      await poller.poll();

      expect(queue.enqueued.length).toBe(0);
      // PR lookup must not run for a too-recent issue.
      expect(buildGitHubClient).not.toHaveBeenCalled();
    });

    test("treats In Progress as having a PR (fail-safe) when PR lookup throws", async () => {
      const issue = makeIssue({ key: "SQDP-1", status: "In Progress", updated: oldTs() });
      const { jiraClient, queue, logger, metrics } = buildDeps({ issues: [issue] });
      const buildGitHubClient = mock(() => ({
        listOpenPrs: mock(async () => {
          throw new Error("github down");
        }),
      }));
      const poller = makePoller({ jiraClient, queue, logger, metrics, buildGitHubClient });

      await poller.poll();

      expect(queue.enqueued.length).toBe(0);
    });

    test("treats In Progress as having a PR (fail-safe) when the GitHub token fetch fails", async () => {
      const issue = makeIssue({ key: "SQDP-1", status: "In Progress", updated: oldTs() });
      const { jiraClient, queue, logger, metrics } = buildDeps({ issues: [issue] });
      const getGitHubToken = mock(async () => {
        throw new Error("token expired");
      });
      const poller = makePoller({ jiraClient, queue, logger, metrics, getGitHubToken });

      await poller.poll();

      expect(queue.enqueued.length).toBe(0);
    });

    test("still enqueues a To Do issue (regression guard)", async () => {
      const issue = makeIssue({ key: "SQDP-9", status: "To Do" });
      const { jiraClient, queue, logger, metrics } = buildDeps({ issues: [issue] });
      const buildGitHubClient = mock(() => ({ listOpenPrs: mock(async () => []) }));
      const poller = makePoller({ jiraClient, queue, logger, metrics, buildGitHubClient });

      const count = await poller.poll();

      expect(count).toBe(1);
      expect(queue.enqueued[0]).toMatchObject({ jobKind: "jira", issueKey: "SQDP-9" });
      // To Do path never consults GitHub.
      expect(buildGitHubClient).not.toHaveBeenCalled();
    });
  });

  describe("start() / stop()", () => {
    test("start() is idempotent — double start does not create two timers", () => {
      const { jiraClient, queue, logger, metrics } = buildDeps();
      const poller = makePoller({ jiraClient, queue, logger, metrics });

      poller.start();
      poller.start(); // second start should be a no-op

      // stop() cleans up the single timer
      poller.stop();
    });

    test("stop() clears the interval", () => {
      const { jiraClient, queue, logger, metrics } = buildDeps();
      const poller = makePoller({ jiraClient, queue, logger, metrics });

      poller.start();
      expect(() => poller.stop()).not.toThrow();
    });

    test("stop() on un-started poller does not throw", () => {
      const { jiraClient, queue, logger, metrics } = buildDeps();
      const poller = makePoller({ jiraClient, queue, logger, metrics });

      expect(() => poller.stop()).not.toThrow();
    });
  });

  describe("auth failure handling", () => {
    test("a matching ticket is enqueued and poll.executed is emitted", async () => {
      const issue = makeIssue({ id: "200", key: "SQDP-10" });
      const { jiraClient, queue, logger, metrics } = buildDeps({ issues: [issue] });

      const poller = makePoller({ jiraClient, queue, logger, metrics });

      const count = await poller.poll();

      expect(count).toBe(1);
      expect(queue.enqueued[0]).toMatchObject({ jobKind: "jira", issueKey: "SQDP-10" });
      expect(metrics.countOf(METRIC.pollExecuted)).toBe(1);
      expect(metrics.last(METRIC.pollExecuted)?.tags).toMatchObject({ poller: "jira" });
    });

    test("on 401 from searchIssues: re-resolves accountId on next poll, emits poll.auth_failure, and logs error", async () => {
      const authError = new Error("Request failed with status 401 Unauthorized");

      // First call resolves to acc123; second call (after re-resolve) also resolves
      const resolveAccountIdForBotEmail = mock(async (_email: string) => "acc123");

      const jiraClient = {
        resolveAccountIdForBotEmail,
        searchIssues: mock(async (_jql: string) => {
          throw authError;
        }),
      };

      const queue = createFakeQueue({});
      const logger = mockLogger();
      const metrics = new FakeMetrics();

      const poller = makePoller({ jiraClient, queue, logger, metrics });

      // First poll — 401 thrown, botAccountId cleared
      await poller.poll();

      expect(metrics.countOf(METRIC.pollAuthFailure)).toBe(1);
      expect(metrics.last(METRIC.pollAuthFailure)?.tags).toMatchObject({
        poller: "jira",
        integration: "jira",
      });
      expect(logger.error).toHaveBeenCalled();

      // Second poll — must re-resolve accountId (resolveAccountIdForBotEmail called twice total)
      await poller.poll();

      expect((resolveAccountIdForBotEmail as ReturnType<typeof mock>).mock.calls.length).toBe(2);
    });
  });
});
