import { describe, test, expect, mock, spyOn, afterEach } from "bun:test";
import type { RepoRegistry } from "@/repo/registry.js";
import type { JobKind } from "@/types/index.js";
import { GitHubClientImpl } from "@/github/client.js";
import { createFakeQueue } from "../_helpers/fake-queue.js";
import { mockLogger } from "../_helpers/mock-logger.js";
import { FakeMetrics } from "../_helpers/fake-metrics.js";
import { PrMentionPoller } from "@/poller/pr-mention-poller.js";

// Restore all spies after each test so they don't leak into other test files.
afterEach(() => mock.restore());

// ── shape helpers ────────────────────────────────────────────────────────────

function makePr(
  overrides: {
    number?: number;
    author?: string;
    headRef?: string;
    url?: string;
  } = {},
) {
  return {
    number: overrides.number ?? 1,
    author: overrides.author ?? "human-user",
    headRef: overrides.headRef ?? "feature/SQDP-123-foo",
    url: overrides.url ?? "https://github.com/org/repo/pull/1",
  };
}

function makeComment(
  overrides: {
    id?: number;
    body?: string;
    userLogin?: string;
    created_at?: string;
    in_reply_to_id?: number | null;
  } = {},
) {
  return {
    id: overrides.id ?? 10,
    body: overrides.body ?? "hello",
    user: { login: overrides.userLogin ?? "human-user" },
    created_at: overrides.created_at ?? "2026-05-01T10:00:00Z",
    in_reply_to_id: overrides.in_reply_to_id ?? undefined,
  };
}

function makeRepoConfig(name = "my-api") {
  return {
    name,
    url: "https://github.com/org/my-api",
    defaultBranch: "main",
    description: "",
    keywords: [],
  };
}

// ── poller factory ────────────────────────────────────────────────────────────

function makePoller(
  opts: {
    repos?: ReturnType<typeof makeRepoConfig>[];
    queueBusy?: boolean;
    currentJobKind?: JobKind | null;
    ignoredLogins?: string[];
  } = {},
) {
  const queue = createFakeQueue({
    busy: opts.queueBusy,
    currentJobKind: opts.currentJobKind ?? undefined,
  });
  const logger = mockLogger();
  const repos = opts.repos ?? [makeRepoConfig()];
  const metrics = new FakeMetrics();

  const poller = new PrMentionPoller({
    repoRegistry: { getAll: () => repos } as unknown as RepoRegistry,
    getGitHubToken: async () => "tok",
    queue,
    githubBotUser: "hu-agent[bot]",
    ignoredCommentLogins: opts.ignoredLogins ?? [],
    metrics,
    logger,
  });

  return { poller, queue, logger, metrics };
}

// ── stub helpers ──────────────────────────────────────────────────────────────

type PrShape = ReturnType<typeof makePr>;
type CommentShape = ReturnType<typeof makeComment>;

function stubPolling(
  prs: PrShape[] = [],
  issueComments: CommentShape[] = [],
  reviewComments: CommentShape[] = [],
) {
  spyOn(GitHubClientImpl.prototype, "listOpenPrs").mockResolvedValue(prs as never);
  spyOn(GitHubClientImpl.prototype, "listIssueComments").mockResolvedValue(issueComments as never);
  spyOn(GitHubClientImpl.prototype, "listPullReviewComments").mockResolvedValue(
    reviewComments as never,
  );
  spyOn(GitHubClientImpl.prototype, "listPullReviews").mockResolvedValue([] as never);
}

// ── pure-logic tests (no GitHub I/O) ─────────────────────────────────────────

describe("PrMentionPoller — pure logic", () => {
  describe("poll() skips", () => {
    test("returns 0 when queue is busy", async () => {
      stubPolling();
      const { poller, queue } = makePoller({ queueBusy: true });
      const result = await poller.poll();
      expect(result).toBe(0);
      expect(queue.enqueued).toHaveLength(0);
    });

    test("returns 0 when current job is 'jira'", async () => {
      stubPolling();
      const { poller } = makePoller({ currentJobKind: "jira" });
      const result = await poller.poll();
      expect(result).toBe(0);
    });
  });

  describe("start() / stop()", () => {
    test("start() is idempotent — double start does not throw", () => {
      const { poller } = makePoller();
      expect(() => {
        poller.start();
        poller.start();
      }).not.toThrow();
      poller.stop();
    });

    test("stop() clears the timer", () => {
      const { poller } = makePoller();
      poller.start();
      expect(() => poller.stop()).not.toThrow();
    });

    test("stop() on un-started poller does not throw", () => {
      const { poller } = makePoller();
      expect(() => poller.stop()).not.toThrow();
    });
  });
});

// ── full poll cycle tests ─────────────────────────────────────────────────────

describe("PrMentionPoller — full poll cycle", () => {
  test("skips bot-authored PRs", async () => {
    stubPolling([makePr({ author: "hu-agent[bot]" })]);
    const listIssueCommentsSpy = spyOn(
      GitHubClientImpl.prototype,
      "listIssueComments",
    ).mockResolvedValue([] as never);

    const { poller, queue } = makePoller();
    await poller.poll();

    expect(queue.enqueued).toHaveLength(0);
    expect(listIssueCommentsSpy).not.toHaveBeenCalled();
  });

  test("enqueues a pending @hu-agent mention", async () => {
    const mentionComment = makeComment({
      id: 42,
      body: "@hu-agent please help",
      userLogin: "human-dev",
      created_at: "2026-05-01T10:00:00Z",
    });
    stubPolling([makePr({ author: "human-dev" })], [mentionComment]);

    const { poller, queue } = makePoller();
    const count = await poller.poll();

    expect(count).toBe(1);
    expect(queue.enqueued).toHaveLength(1);
    const job = queue.enqueued[0] as { jobKind: string; commentId: number };
    expect(job.jobKind).toBe("pr_mention");
    expect(job.commentId).toBe(42);
  });

  test("does not enqueue when bot already replied after the mention", async () => {
    const mention = makeComment({
      id: 1,
      body: "@hu-agent do X",
      userLogin: "human-dev",
      created_at: "2026-05-01T09:00:00Z",
    });
    const botReply = makeComment({
      id: 2,
      body: "Done!",
      userLogin: "hu-agent[bot]",
      created_at: "2026-05-01T10:00:00Z",
    });
    stubPolling([makePr({ author: "human-dev" })], [mention, botReply]);

    const { poller, queue } = makePoller();
    const count = await poller.poll();

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

  test("skips ignored login comments (e.g. hu-reviewer[bot])", async () => {
    const mentionComment = makeComment({
      id: 99,
      body: "@hu-agent check this",
      userLogin: "hu-reviewer[bot]",
      created_at: "2026-05-01T10:00:00Z",
    });
    stubPolling([makePr({ author: "human-dev" })], [mentionComment]);

    const { poller, queue } = makePoller({ ignoredLogins: ["hu-reviewer[bot]"] });
    const count = await poller.poll();

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

  test("marks mention exhausted after MAX_ENQUEUE_ATTEMPTS (3) and stops re-enqueueing", async () => {
    const mention = makeComment({
      id: 77,
      body: "@hu-agent do it",
      userLogin: "human-dev",
      created_at: "2026-05-01T10:00:00Z",
    });
    stubPolling([makePr({ author: "human-dev" })], [mention]);

    const { poller, queue } = makePoller();
    await poller.poll(); // attempt 1
    await poller.poll(); // attempt 2
    await poller.poll(); // attempt 3
    await poller.poll(); // attempt 4 → exhausted
    expect(queue.enqueued).toHaveLength(3);
  });

  test("review-comment mentions on different threads stay pending after the bot replies to one", async () => {
    // Reproduces PR humand-mobile#7625: agudeluca posted four @hu-agent
    // review comments on different files; the bot replied to the first one
    // and the previous global "any later bot reply" check then dropped the
    // remaining three even though each lived in its own thread.
    const reviewComments = [
      makeComment({
        id: 1,
        body: "@hu-agent fix file A",
        userLogin: "human-dev",
        created_at: "2026-05-01T10:00:00Z",
      }),
      makeComment({
        id: 2,
        body: "@hu-agent fix file B",
        userLogin: "human-dev",
        created_at: "2026-05-01T10:01:00Z",
      }),
      makeComment({
        id: 3,
        body: "@hu-agent fix file C",
        userLogin: "human-dev",
        created_at: "2026-05-01T10:02:00Z",
      }),
      makeComment({
        id: 10,
        body: "Done!",
        userLogin: "hu-agent[bot]",
        created_at: "2026-05-01T10:05:00Z",
        in_reply_to_id: 1,
      }),
    ];
    stubPolling([makePr({ author: "human-dev" })], [], reviewComments);

    const { poller, queue } = makePoller();
    const count = await poller.poll();

    expect(count).toBe(2);
    const enqueuedIds = (queue.enqueued as Array<{ commentId: number }>)
      .map((j) => j.commentId)
      .sort((a, b) => a - b);
    expect(enqueuedIds).toEqual([2, 3]);
  });

  test("issue-comment mentions still use stream-wide bot timestamp", async () => {
    // Flat issue comments: bot reply at 10:30 must mark earlier human
    // mentions as resolved, but a later mention stays pending.
    const issueComments = [
      makeComment({
        id: 1,
        body: "@hu-agent first",
        userLogin: "human-dev",
        created_at: "2026-05-01T10:00:00Z",
      }),
      makeComment({
        id: 2,
        body: "Done!",
        userLogin: "hu-agent[bot]",
        created_at: "2026-05-01T10:30:00Z",
      }),
      makeComment({
        id: 3,
        body: "@hu-agent second",
        userLogin: "human-dev",
        created_at: "2026-05-01T11:00:00Z",
      }),
    ];
    stubPolling([makePr({ author: "human-dev" })], issueComments);

    const { poller, queue } = makePoller();
    const count = await poller.poll();

    expect(count).toBe(1);
    const enqueued = queue.enqueued as Array<{ commentId: number }>;
    expect(enqueued[0]?.commentId).toBe(3);
  });

  test("continues to next repo when listOpenPrs fails, aborts after 3 failures", async () => {
    const repos = [makeRepoConfig("repo-a"), makeRepoConfig("repo-b"), makeRepoConfig("repo-c")];
    let callCount = 0;
    spyOn(GitHubClientImpl.prototype, "listOpenPrs").mockImplementation(async () => {
      callCount++;
      throw new Error("network error");
    });
    spyOn(GitHubClientImpl.prototype, "listIssueComments").mockResolvedValue([] as never);
    spyOn(GitHubClientImpl.prototype, "listPullReviewComments").mockResolvedValue([] as never);
    spyOn(GitHubClientImpl.prototype, "listPullReviews").mockResolvedValue([] as never);

    const { poller } = makePoller({ repos });
    await poller.poll();

    expect(callCount).toBe(3);
  });
});

describe("PrMentionPoller — failure escalation", () => {
  test("escalates to logger.error after FAILURE_ESCALATION_THRESHOLD consecutive failures", async () => {
    const repos = [makeRepoConfig("repo-a"), makeRepoConfig("repo-b"), makeRepoConfig("repo-c")];
    // All three repos fail listOpenPrs → listPrFailures reaches 3 → consecutiveFailures++
    spyOn(GitHubClientImpl.prototype, "listOpenPrs").mockImplementation(async () => {
      throw new Error("network error");
    });
    spyOn(GitHubClientImpl.prototype, "listIssueComments").mockResolvedValue([] as never);
    spyOn(GitHubClientImpl.prototype, "listPullReviewComments").mockResolvedValue([] as never);
    spyOn(GitHubClientImpl.prototype, "listPullReviews").mockResolvedValue([] as never);

    const { poller, logger } = makePoller({ repos });

    // Three consecutive failing polls push consecutiveFailures to 3 (=== FAILURE_ESCALATION_THRESHOLD)
    await poller.poll(); // consecutiveFailures = 1 → warn
    await poller.poll(); // consecutiveFailures = 2 → warn
    await poller.poll(); // consecutiveFailures = 3 → error

    expect(
      (logger.error as ReturnType<typeof mock>).mock.calls.some(
        (args) =>
          typeof args[0] === "object" &&
          (args[0] as Record<string, unknown>).consecutiveFailures === 3 &&
          args[1] === "pr_mention_poll_backoff_active",
      ),
    ).toBe(true);
  });
});
