import { describe, test, expect, mock, spyOn, afterEach } from "bun:test";
import type { RepoRegistry } from "@/repo/registry.js";
import { GitHubClientImpl } from "@/github/client.js";
import * as prMemoryStore from "@/pr-memory/store.js";
import { createFakeQueue } from "../_helpers/fake-queue.js";
import { mockLogger } from "../_helpers/mock-logger.js";
import { FakeMetrics } from "../_helpers/fake-metrics.js";
import { PrCommentPoller } from "@/poller/pr-comment-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 ?? "hu-agent[bot]",
    headRef: overrides.headRef ?? "fix/SQDP-1-foo",
    url: overrides.url ?? "https://github.com/org/repo/pull/1",
  };
}

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

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

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

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

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

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

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

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

function stubPrList(prs: PrShape[] = []) {
  return spyOn(GitHubClientImpl.prototype, "listOpenPrs").mockResolvedValue(prs as never);
}

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

function stubClearClosedPrMemories() {
  return spyOn(prMemoryStore, "clearClosedPrMemories").mockReturnValue(0);
}

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

describe("PrCommentPoller — skip conditions", () => {
  test("returns 0 when queue is busy", async () => {
    stubPrList();
    stubComments();
    stubClearClosedPrMemories();
    const { poller, queue } = makePoller({ queueBusy: true });
    const count = await poller.poll();
    expect(count).toBe(0);
    expect(queue.enqueued).toHaveLength(0);
  });

  test("returns 0 when jira job is running", async () => {
    stubPrList();
    stubComments();
    stubClearClosedPrMemories();
    const { poller, queue } = makePoller({ currentJobKind: "jira" });
    const count = await poller.poll();
    expect(count).toBe(0);
    expect(queue.enqueued).toHaveLength(0);
  });
});

describe("PrCommentPoller — bot PR filtering", () => {
  test("only processes bot-authored PRs", async () => {
    stubPrList([
      makePr({ author: "human-dev", number: 1 }),
      makePr({ author: "hu-agent[bot]", number: 2 }),
    ]);
    const issueCommentsSpy = spyOn(
      GitHubClientImpl.prototype,
      "listIssueComments",
    ).mockResolvedValue([makeComment({ body: "please fix this" })] as never);
    spyOn(GitHubClientImpl.prototype, "listPullReviewComments").mockResolvedValue([] as never);
    spyOn(GitHubClientImpl.prototype, "listPullReviews").mockResolvedValue([] as never);
    stubClearClosedPrMemories();

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

    // Only the bot-authored PR (number 2) should have comments fetched
    expect(issueCommentsSpy.mock.calls.length).toBe(1);
    expect(queue.enqueued).toHaveLength(1);
  });

  test("enqueues a human comment on a bot PR", async () => {
    stubPrList([makePr({ author: "hu-agent[bot]", number: 5 })]);
    stubComments([
      makeComment({
        id: 99,
        body: "please fix this",
        user: { login: "human-dev" },
        created_at: "2026-01-01T12:00:00Z",
      }),
    ]);
    stubClearClosedPrMemories();

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

    expect(count).toBe(1);
    expect(queue.enqueued[0]).toMatchObject({
      jobKind: "pr_comment",
      commentId: 99,
      prNumber: 5,
    });
  });
});

describe("PrCommentPoller — bot-self / ignored login skip", () => {
  test("does not enqueue comments from the bot itself", async () => {
    stubPrList([makePr({ author: "hu-agent[bot]", number: 1 })]);
    stubComments([
      makeComment({ id: 1, user: { login: "hu-agent[bot]" }, created_at: "2026-01-01T10:00:00Z" }),
    ]);
    stubClearClosedPrMemories();

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

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

  test("does not enqueue comments from ignored logins (hu-reviewer[bot])", async () => {
    stubPrList([makePr({ author: "hu-agent[bot]", number: 1 })]);
    stubComments([
      makeComment({
        id: 1,
        user: { login: "hu-reviewer[bot]" },
        created_at: "2026-01-01T10:00:00Z",
      }),
    ]);
    stubClearClosedPrMemories();

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

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

  test("skips PR when bot already replied and there are no newer human comments", async () => {
    stubPrList([makePr({ author: "hu-agent[bot]", number: 1 })]);
    stubComments([
      makeComment({ id: 1, user: { login: "human-dev" }, created_at: "2026-01-01T09:00:00Z" }),
      makeComment({ id: 2, user: { login: "hu-agent[bot]" }, created_at: "2026-01-01T10:00:00Z" }),
    ]);
    stubClearClosedPrMemories();

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

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

  test("enqueues when a human comments AFTER the bot reply", async () => {
    stubPrList([makePr({ author: "hu-agent[bot]", number: 1 })]);
    stubComments([
      makeComment({ id: 1, user: { login: "hu-agent[bot]" }, created_at: "2026-01-01T09:00:00Z" }),
      makeComment({ id: 2, user: { login: "human-dev" }, created_at: "2026-01-01T10:00:00Z" }),
    ]);
    stubClearClosedPrMemories();

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

    expect(count).toBe(1);
    expect(queue.enqueued[0]).toMatchObject({ commentId: 2 });
  });
});

describe("PrCommentPoller — review comment threads", () => {
  test("enqueues thread root when last message in thread is human", async () => {
    stubPrList([makePr({ author: "hu-agent[bot]", number: 1 })]);
    spyOn(GitHubClientImpl.prototype, "listIssueComments").mockResolvedValue([] as never);
    spyOn(GitHubClientImpl.prototype, "listPullReviewComments").mockResolvedValue([
      makeComment({ id: 10, user: { login: "hu-agent[bot]" }, created_at: "2026-01-01T09:00:00Z" }),
      {
        id: 11,
        body: "follow up",
        user: { login: "human-dev" },
        created_at: "2026-01-01T10:00:00Z",
        in_reply_to_id: 10,
      },
    ] as never);
    spyOn(GitHubClientImpl.prototype, "listPullReviews").mockResolvedValue([] as never);
    stubClearClosedPrMemories();

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

    expect(count).toBe(1);
    expect(queue.enqueued[0]).toMatchObject({ commentId: 11, isReviewComment: true });
  });

  test("skips thread when last message in thread is bot", async () => {
    stubPrList([makePr({ author: "hu-agent[bot]", number: 1 })]);
    spyOn(GitHubClientImpl.prototype, "listIssueComments").mockResolvedValue([] as never);
    spyOn(GitHubClientImpl.prototype, "listPullReviewComments").mockResolvedValue([
      makeComment({ id: 10, user: { login: "human-dev" }, created_at: "2026-01-01T09:00:00Z" }),
      {
        id: 11,
        body: "done",
        user: { login: "hu-agent[bot]" },
        created_at: "2026-01-01T10:00:00Z",
        in_reply_to_id: 10,
      },
    ] as never);
    spyOn(GitHubClientImpl.prototype, "listPullReviews").mockResolvedValue([] as never);
    stubClearClosedPrMemories();

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

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

describe("PrCommentPoller — error handling", () => {
  test("continues to next repo after listOpenPrs fails", async () => {
    const repos = [makeRepoConfig("repo-a"), makeRepoConfig("repo-b")];
    let callCount = 0;
    spyOn(GitHubClientImpl.prototype, "listOpenPrs").mockImplementation(async () => {
      callCount++;
      if (callCount === 1) throw new Error("network error");
      return [] as never;
    });
    stubComments();
    stubClearClosedPrMemories();

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

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

  test("aborts cycle after 3 consecutive listOpenPrs failures", async () => {
    const repos = [
      makeRepoConfig("repo-a"),
      makeRepoConfig("repo-b"),
      makeRepoConfig("repo-c"),
      makeRepoConfig("repo-d"),
    ];
    let callCount = 0;
    spyOn(GitHubClientImpl.prototype, "listOpenPrs").mockImplementation(async () => {
      callCount++;
      throw new Error("network");
    });
    stubClearClosedPrMemories();

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

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

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

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

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

describe("PrCommentPoller — 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");
    });
    stubClearClosedPrMemories();

    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_comment_poll_backoff_active",
      ),
    ).toBe(true);
  });
});
