import { describe, test, expect, mock } from "bun:test";
import { GitHubClientImpl } from "@/github/client.js";
import { GitHubApiError } from "@/core/errors.js";
import { mockLogger } from "../_helpers/mock-logger.js";
import type { IssueFlow } from "@/types/index.js";

const logger = mockLogger();

// ── Factory helpers ──────────────────────────────────────────────────────────

const VALID_REPO_URL = "https://github.com/acme/my-repo";
const TOKEN = "ghp_test_token";
const JIRA_DOMAIN = "acme";

function makeClient(repoUrl = VALID_REPO_URL): GitHubClientImpl {
  return new GitHubClientImpl(TOKEN, repoUrl, JIRA_DOMAIN, logger);
}

/** Access internal Octokit namespaces as plain record for mutation. */
function octNamespace(client: GitHubClientImpl, ns: string): Record<string, unknown> {
  const oct = (client as unknown as { octokit: Record<string, Record<string, unknown>> }).octokit;
  return oct[ns]!;
}

/**
 * Replace a nested Octokit method with a `mock()` function.
 * Returns the mock so callers can assert call count / args.
 */
function stubOctokit(
  client: GitHubClientImpl,
  ns: string,
  method: string,
  impl: (...args: unknown[]) => unknown,
): ReturnType<typeof mock> {
  const fn = mock(impl);
  octNamespace(client, ns)[method] = fn;
  return fn;
}

function stubResolve(
  client: GitHubClientImpl,
  ns: string,
  method: string,
  value: unknown,
): ReturnType<typeof mock> {
  return stubOctokit(client, ns, method, async () => value);
}

function stubReject(
  client: GitHubClientImpl,
  ns: string,
  method: string,
  err: Error,
): ReturnType<typeof mock> {
  return stubOctokit(client, ns, method, async () => {
    throw err;
  });
}

// ── Constructor / parseRepoUrl ───────────────────────────────────────────────

describe("GitHubClientImpl constructor – parseRepoUrl", () => {
  test("parses HTTPS URL correctly", () => {
    expect(() => makeClient("https://github.com/acme/my-repo")).not.toThrow();
  });

  test("parses SSH-style URL correctly", () => {
    expect(() => makeClient("git@github.com:acme/my-repo.git")).not.toThrow();
  });

  test("throws GitHubApiError for a non-github.com URL", () => {
    expect(() => makeClient("https://example.com/not-github")).toThrow(GitHubApiError);
  });

  test("throws GitHubApiError when URL has no repo segment", () => {
    expect(() => makeClient("https://github.com/acme")).toThrow(GitHubApiError);
  });
});

// ── listOpenPrs ──────────────────────────────────────────────────────────────

describe("listOpenPrs", () => {
  test("returns mapped PR objects on success", async () => {
    const client = makeClient();
    stubResolve(client, "pulls", "list", {
      data: [
        {
          number: 42,
          html_url: "https://github.com/acme/my-repo/pull/42",
          title: "Fix bug",
          user: { login: "hu-agent" },
          head: { ref: "fix/bug-123" },
        },
      ],
    });

    const prs = await client.listOpenPrs();
    expect(prs).toHaveLength(1);
    expect(prs[0]).toEqual({
      number: 42,
      url: "https://github.com/acme/my-repo/pull/42",
      title: "Fix bug",
      author: "hu-agent",
      headRef: "fix/bug-123",
    });
  });

  test("passes head filter when provided", async () => {
    const client = makeClient();
    const capturedParams: Record<string, unknown>[] = [];
    octNamespace(client, "pulls")["list"] = mock(async (p: Record<string, unknown>) => {
      capturedParams.push(p);
      return { data: [] };
    });

    await client.listOpenPrs("acme:my-branch");
    expect(capturedParams[0]).toMatchObject({ head: "acme:my-branch" });
  });

  test("does not include head when called without argument", async () => {
    const client = makeClient();
    const capturedParams: Record<string, unknown>[] = [];
    octNamespace(client, "pulls")["list"] = mock(async (p: Record<string, unknown>) => {
      capturedParams.push(p);
      return { data: [] };
    });

    await client.listOpenPrs();
    expect(capturedParams[0]!["head"]).toBeUndefined();
  });

  test("maps null user / html_url / title to null / empty strings", async () => {
    const client = makeClient();
    stubResolve(client, "pulls", "list", {
      data: [{ number: 1, html_url: null, title: null, user: null, head: { ref: "branch" } }],
    });

    const prs = await client.listOpenPrs();
    expect(prs[0]!.author).toBeNull();
    expect(prs[0]!.url).toBe("");
    expect(prs[0]!.title).toBe("");
  });

  test("throws GitHubApiError on Octokit error", async () => {
    const client = makeClient();
    stubReject(client, "pulls", "list", new Error("network error"));

    await expect(client.listOpenPrs()).rejects.toThrow(GitHubApiError);
  });

  test("includes 401 hint in error message on 401 status", async () => {
    const client = makeClient();
    const err = Object.assign(new Error("Unauthorized"), { status: 401 });
    stubReject(client, "pulls", "list", err);

    await expect(client.listOpenPrs()).rejects.toThrow(/401 Unauthorized/);
  });

  test("includes 404 hint in error message on 404 response status", async () => {
    const client = makeClient();
    const err = Object.assign(new Error("Not Found"), { response: { status: 404 } });
    stubReject(client, "pulls", "list", err);

    await expect(client.listOpenPrs()).rejects.toThrow(/404/);
  });
});

// ── listMergedPrs ────────────────────────────────────────────────────────────

describe("listMergedPrs", () => {
  const SINCE = "2026-01-01T00:00:00Z";
  const UNTIL = "2026-02-01T00:00:00Z";

  function makePr(overrides: Partial<Record<string, unknown>> & { number: number }) {
    const base = {
      title: "PR title",
      user: { login: "alice" },
      merged_at: "2026-01-15T00:00:00Z",
      updated_at: "2026-01-15T00:00:00Z",
      html_url: `https://github.com/acme/my-repo/pull/${overrides.number}`,
      head: { ref: `feat/${overrides.number}` },
    };
    return { ...base, ...overrides };
  }

  test("throws GitHubApiError when sinceIso is not a valid date", async () => {
    const client = makeClient();
    await expect(client.listMergedPrs({ sinceIso: "not-a-date", untilIso: UNTIL })).rejects.toThrow(
      GitHubApiError,
    );
  });

  test("throws GitHubApiError when untilIso is not a valid date", async () => {
    const client = makeClient();
    await expect(
      client.listMergedPrs({ sinceIso: SINCE, untilIso: "also-not-a-date" }),
    ).rejects.toThrow(GitHubApiError);
  });

  test("returns mapped PRs merged within the window on a single page", async () => {
    const client = makeClient();
    stubResolve(client, "pulls", "list", { data: [makePr({ number: 1 }), makePr({ number: 2 })] });

    const result = await client.listMergedPrs({
      sinceIso: SINCE,
      untilIso: UNTIL,
      perPage: 100,
    });

    expect(result).toHaveLength(2);
    expect(result[0]).toEqual({
      number: 1,
      title: "PR title",
      authorLogin: "alice",
      mergedAt: "2026-01-15T00:00:00Z",
      htmlUrl: "https://github.com/acme/my-repo/pull/1",
      headRef: "feat/1",
    });
  });

  test("skips PRs with null merged_at (closed without merge)", async () => {
    const client = makeClient();
    stubResolve(client, "pulls", "list", {
      data: [makePr({ number: 1, merged_at: null }), makePr({ number: 2 })],
    });

    const result = await client.listMergedPrs({ sinceIso: SINCE, untilIso: UNTIL, perPage: 100 });
    expect(result).toHaveLength(1);
    expect(result[0]!.number).toBe(2);
  });

  test("skips PRs with merged_at before sinceIso or at/after untilIso", async () => {
    const client = makeClient();
    stubResolve(client, "pulls", "list", {
      data: [
        makePr({ number: 1, merged_at: "2025-12-01T00:00:00Z" }), // before window
        makePr({ number: 2, merged_at: "2026-01-15T00:00:00Z" }), // inside window
        makePr({ number: 3, merged_at: "2026-02-01T00:00:00Z" }), // exactly at untilIso → excluded
      ],
    });

    const result = await client.listMergedPrs({ sinceIso: SINCE, untilIso: UNTIL, perPage: 100 });
    expect(result.map((p) => p.number)).toEqual([2]);
  });

  test("stops paginating once a page's oldest updated_at falls before sinceIso", async () => {
    const client = makeClient();
    const listFn = mock(async (params: Record<string, unknown>) => {
      if (params.page === 1) {
        // Mix: one inside window, one whose updated_at is before sinceIso.
        return {
          data: [
            makePr({ number: 10, updated_at: "2026-01-20T00:00:00Z" }),
            makePr({
              number: 11,
              merged_at: "2025-11-01T00:00:00Z",
              updated_at: "2025-11-01T00:00:00Z",
            }),
          ],
        };
      }
      return { data: [makePr({ number: 99 })] };
    });
    octNamespace(client, "pulls")["list"] = listFn;

    const result = await client.listMergedPrs({ sinceIso: SINCE, untilIso: UNTIL, perPage: 100 });
    expect(listFn.mock.calls).toHaveLength(1);
    expect(result.map((p) => p.number)).toEqual([10]);
  });

  test("stops paginating when a page returns fewer items than perPage", async () => {
    const client = makeClient();
    const listFn = mock(async () => ({ data: [makePr({ number: 1 })] }));
    octNamespace(client, "pulls")["list"] = listFn;

    await client.listMergedPrs({ sinceIso: SINCE, untilIso: UNTIL, perPage: 100 });
    expect(listFn.mock.calls).toHaveLength(1);
  });

  test("breaks out early when a page returns zero items", async () => {
    const client = makeClient();
    const listFn = mock(async () => ({ data: [] }));
    octNamespace(client, "pulls")["list"] = listFn;

    const result = await client.listMergedPrs({ sinceIso: SINCE, untilIso: UNTIL, perPage: 100 });
    expect(result).toEqual([]);
    expect(listFn.mock.calls).toHaveLength(1);
  });

  test("fills in empty strings when merged PR is missing optional fields", async () => {
    const client = makeClient();
    stubResolve(client, "pulls", "list", {
      data: [
        {
          number: 42,
          title: null,
          user: null,
          merged_at: "2026-01-15T00:00:00Z",
          updated_at: "2026-01-15T00:00:00Z",
          html_url: null,
          head: null,
        },
      ],
    });

    const result = await client.listMergedPrs({ sinceIso: SINCE, untilIso: UNTIL, perPage: 100 });
    expect(result[0]).toEqual({
      number: 42,
      title: "",
      authorLogin: null,
      mergedAt: "2026-01-15T00:00:00Z",
      htmlUrl: "",
      headRef: "",
    });
  });

  test("includes 401 hint when Octokit throws 401", async () => {
    const client = makeClient();
    const err = Object.assign(new Error("Unauthorized"), { status: 401 });
    stubReject(client, "pulls", "list", err);

    await expect(
      client.listMergedPrs({ sinceIso: SINCE, untilIso: UNTIL, perPage: 100 }),
    ).rejects.toThrow(/401 Unauthorized/);
  });

  test("includes 404 hint when Octokit throws 404", async () => {
    const client = makeClient();
    const err = Object.assign(new Error("Not Found"), { response: { status: 404 } });
    stubReject(client, "pulls", "list", err);

    await expect(
      client.listMergedPrs({ sinceIso: SINCE, untilIso: UNTIL, perPage: 100 }),
    ).rejects.toThrow(/404/);
  });

  test("wraps non-retryable Octokit errors in GitHubApiError", async () => {
    const client = makeClient();
    stubReject(client, "pulls", "list", new Error("boom"));

    await expect(
      client.listMergedPrs({ sinceIso: SINCE, untilIso: UNTIL, perPage: 100 }),
    ).rejects.toThrow(GitHubApiError);
  });

  test("retries on 403 secondary rate limit then succeeds", async () => {
    const client = makeClient();
    const originalSetTimeout = globalThis.setTimeout;
    // Fast-forward waits so the retry loop resolves instantly.
    globalThis.setTimeout = ((cb: () => void) => {
      cb();
      return 0 as unknown as ReturnType<typeof setTimeout>;
    }) as typeof setTimeout;

    try {
      let calls = 0;
      octNamespace(client, "pulls")["list"] = mock(async () => {
        calls++;
        if (calls === 1) {
          throw Object.assign(new Error("rate limited"), {
            response: { status: 403, headers: { "retry-after": "0" } },
          });
        }
        return { data: [makePr({ number: 7 })] };
      });

      const result = await client.listMergedPrs({
        sinceIso: SINCE,
        untilIso: UNTIL,
        perPage: 100,
      });
      expect(calls).toBe(2);
      expect(result[0]!.number).toBe(7);
    } finally {
      globalThis.setTimeout = originalSetTimeout;
    }
  });

  test("gives up with GitHubApiError after exhausting retries on persistent 403", async () => {
    const client = makeClient();
    const originalSetTimeout = globalThis.setTimeout;
    globalThis.setTimeout = ((cb: () => void) => {
      cb();
      return 0 as unknown as ReturnType<typeof setTimeout>;
    }) as typeof setTimeout;

    try {
      octNamespace(client, "pulls")["list"] = mock(async () => {
        throw Object.assign(new Error("still rate limited"), {
          response: { status: 429, headers: {} },
        });
      });

      await expect(
        client.listMergedPrs({ sinceIso: SINCE, untilIso: UNTIL, perPage: 100 }),
      ).rejects.toThrow(GitHubApiError);
    } finally {
      globalThis.setTimeout = originalSetTimeout;
    }
  });
});

// ── getPrForBranch ───────────────────────────────────────────────────────────

describe("getPrForBranch", () => {
  test("returns number and url from first matching PR", async () => {
    const client = makeClient();
    stubResolve(client, "pulls", "list", {
      data: [
        {
          number: 7,
          html_url: "https://github.com/acme/my-repo/pull/7",
          title: "Fix",
          user: { login: "bot" },
          head: { ref: "fix/7" },
        },
      ],
    });

    const result = await client.getPrForBranch("fix/7");
    expect(result).toEqual({ number: 7, url: "https://github.com/acme/my-repo/pull/7" });
  });

  test("returns null when no PR matches the branch", async () => {
    const client = makeClient();
    stubResolve(client, "pulls", "list", { data: [] });

    const result = await client.getPrForBranch("nonexistent-branch");
    expect(result).toBeNull();
  });

  test("passes owner-prefixed head to pulls.list", async () => {
    const client = makeClient();
    const capturedParams: Record<string, unknown>[] = [];
    octNamespace(client, "pulls")["list"] = mock(async (p: Record<string, unknown>) => {
      capturedParams.push(p);
      return { data: [] };
    });

    await client.getPrForBranch("my-feature");
    expect(capturedParams[0]!["head"]).toBe("acme:my-feature");
  });
});

// ── getPrDiff ────────────────────────────────────────────────────────────────

describe("getPrDiff", () => {
  test("returns diff string on success", async () => {
    const client = makeClient();
    stubResolve(client, "pulls", "get", { data: "diff --git a/foo.ts b/foo.ts\n..." });

    const diff = await client.getPrDiff(42);
    expect(diff).toBe("diff --git a/foo.ts b/foo.ts\n...");
  });

  test("throws GitHubApiError mentioning PR number on Octokit error", async () => {
    const client = makeClient();
    stubReject(client, "pulls", "get", new Error("server error"));

    await expect(client.getPrDiff(42)).rejects.toBeInstanceOf(GitHubApiError);
    await expect(client.getPrDiff(42)).rejects.toThrow("PR #42");
  });
});

// ── updatePrBody ─────────────────────────────────────────────────────────────

describe("updatePrBody", () => {
  test("calls pulls.update with correct args and resolves on success", async () => {
    const client = makeClient();
    const capturedParams: Record<string, unknown>[] = [];
    octNamespace(client, "pulls")["update"] = mock(async (p: Record<string, unknown>) => {
      capturedParams.push(p);
      return { data: {} };
    });

    await expect(client.updatePrBody(10, "New body")).resolves.toBeUndefined();
    expect(capturedParams[0]).toMatchObject({ pull_number: 10, body: "New body" });
  });

  test("throws GitHubApiError mentioning PR number on Octokit error", async () => {
    const client = makeClient();
    stubReject(client, "pulls", "update", new Error("api error"));

    await expect(client.updatePrBody(10, "body")).rejects.toBeInstanceOf(GitHubApiError);
    await expect(client.updatePrBody(10, "body")).rejects.toThrow("PR #10");
  });
});

// ── createPr ─────────────────────────────────────────────────────────────────

describe("createPr", () => {
  test("returns existing PR without calling create when one already exists for the branch", async () => {
    const client = makeClient();
    stubResolve(client, "pulls", "list", {
      data: [
        {
          number: 5,
          html_url: "https://github.com/acme/my-repo/pull/5",
          title: "Existing",
          user: { login: "bot" },
          head: { ref: "my-branch" },
        },
      ],
    });
    const createMock = stubResolve(client, "pulls", "create", {});

    const result = await client.createPr("my-branch", "Title", "Body");
    expect(result).toEqual({ number: 5, url: "https://github.com/acme/my-repo/pull/5" });
    expect(createMock.mock.calls).toHaveLength(0);
  });

  test("creates a new PR when none exists", async () => {
    const client = makeClient();
    stubResolve(client, "pulls", "list", { data: [] });
    stubResolve(client, "pulls", "create", {
      data: { number: 99, html_url: "https://github.com/acme/my-repo/pull/99" },
    });

    const result = await client.createPr("new-branch", "New PR", "Body", "main");
    expect(result).toEqual({ number: 99, url: "https://github.com/acme/my-repo/pull/99" });
  });

  test("throws GitHubApiError with 422 hint on generic validation failure", async () => {
    const client = makeClient();
    stubResolve(client, "pulls", "list", { data: [] });
    const apiErr = Object.assign(new Error("Validation Failed"), {
      response: { status: 422, data: { message: "Validation Failed", errors: [] } },
    });
    stubReject(client, "pulls", "create", apiErr);

    await expect(client.createPr("bad-branch", "T", "B")).rejects.toThrow(/422/);
  });

  test("throws with headInvalid hint when head field has code 'invalid'", async () => {
    const client = makeClient();
    stubResolve(client, "pulls", "list", { data: [] });
    const apiErr = Object.assign(new Error("Validation Failed"), {
      response: {
        status: 422,
        data: { message: "Validation Failed", errors: [{ field: "head", code: "invalid" }] },
      },
    });
    stubReject(client, "pulls", "create", apiErr);

    await expect(client.createPr("missing-branch", "T", "B")).rejects.toThrow(
      /ensure the branch exists/,
    );
  });

  test("throws with refsNotReadable hint when message includes 'not all refs are readable'", async () => {
    const client = makeClient();
    stubResolve(client, "pulls", "list", { data: [] });
    const apiErr = Object.assign(new Error("Unprocessable"), {
      response: {
        status: 422,
        data: { message: "not all refs are readable", errors: [] },
      },
    });
    stubReject(client, "pulls", "create", apiErr);

    await expect(client.createPr("branch", "T", "B")).rejects.toThrow(/If App has Contents/);
  });

  test("throws GitHubApiError on generic (non-422) error", async () => {
    const client = makeClient();
    stubResolve(client, "pulls", "list", { data: [] });
    stubReject(client, "pulls", "create", new Error("network error"));

    await expect(client.createPr("branch", "T", "B")).rejects.toBeInstanceOf(GitHubApiError);
  });

  test("forwards draft=true to pulls.create when called with draft=true", async () => {
    const client = makeClient();
    stubResolve(client, "pulls", "list", { data: [] });
    const capturedParams: Record<string, unknown>[] = [];
    octNamespace(client, "pulls")["create"] = mock(async (p: Record<string, unknown>) => {
      capturedParams.push(p);
      return { data: { number: 10, html_url: "https://github.com/acme/my-repo/pull/10" } };
    });

    await client.createPr("draft-branch", "Draft PR", "Body", "develop", true);
    expect(capturedParams[0]!["draft"]).toBe(true);
  });

  test("forwards draft=false to pulls.create when called without draft argument", async () => {
    const client = makeClient();
    stubResolve(client, "pulls", "list", { data: [] });
    const capturedParams: Record<string, unknown>[] = [];
    octNamespace(client, "pulls")["create"] = mock(async (p: Record<string, unknown>) => {
      capturedParams.push(p);
      return { data: { number: 11, html_url: "https://github.com/acme/my-repo/pull/11" } };
    });

    await client.createPr("ready-branch", "Ready PR", "Body", "develop");
    expect(capturedParams[0]!["draft"]).toBe(false);
  });
});

// ── listRepoLabels ───────────────────────────────────────────────────────────

describe("listRepoLabels", () => {
  test("returns label names on single-page response", async () => {
    const client = makeClient();
    stubResolve(client, "issues", "listLabelsForRepo", {
      data: [{ name: "bug" }, { name: "enhancement" }],
    });

    const labels = await client.listRepoLabels();
    expect(labels).toEqual(["bug", "enhancement"]);
  });

  test("paginates until a page returns fewer than 100 items", async () => {
    const client = makeClient();
    const page1 = Array.from({ length: 100 }, (_, i) => ({ name: `label-${i}` }));
    const page2 = [{ name: "label-100" }, { name: "label-101" }];
    let callCount = 0;
    octNamespace(client, "issues")["listLabelsForRepo"] = mock(async () => {
      callCount++;
      return { data: callCount === 1 ? page1 : page2 };
    });

    const labels = await client.listRepoLabels();
    expect(labels).toHaveLength(102);
    expect(callCount).toBe(2);
  });

  test("returns empty array and swallows error when Octokit throws", async () => {
    const client = makeClient();
    stubReject(client, "issues", "listLabelsForRepo", new Error("forbidden"));

    const labels = await client.listRepoLabels();
    expect(labels).toEqual([]);
  });
});

// ── addLabels ────────────────────────────────────────────────────────────────

describe("addLabels", () => {
  test("returns early without calling Octokit when labels array is empty", async () => {
    const client = makeClient();
    const addMock = stubResolve(client, "issues", "addLabels", { data: [] });

    await client.addLabels(1, []);
    expect(addMock.mock.calls).toHaveLength(0);
  });

  test("calls issues.addLabels with correct args on non-empty labels", async () => {
    const client = makeClient();
    const capturedParams: Record<string, unknown>[] = [];
    octNamespace(client, "issues")["addLabels"] = mock(async (p: Record<string, unknown>) => {
      capturedParams.push(p);
      return { data: [] };
    });

    await client.addLabels(5, ["bug", "hotfix"]);
    expect(capturedParams[0]).toMatchObject({ issue_number: 5, labels: ["bug", "hotfix"] });
  });

  test("swallows Octokit errors (logs warn, does not throw)", async () => {
    const client = makeClient();
    stubReject(client, "issues", "addLabels", new Error("label error"));

    await expect(client.addLabels(5, ["bug"])).resolves.toBeUndefined();
  });
});

// ── closePr ──────────────────────────────────────────────────────────────────

describe("closePr", () => {
  test("calls pulls.update with state: closed", async () => {
    const client = makeClient();
    const capturedParams: Record<string, unknown>[] = [];
    octNamespace(client, "pulls")["update"] = mock(async (p: Record<string, unknown>) => {
      capturedParams.push(p);
      return { data: {} };
    });

    await client.closePr(3);
    expect(capturedParams[0]).toMatchObject({ pull_number: 3, state: "closed" });
  });

  test("throws GitHubApiError mentioning PR number on Octokit error", async () => {
    const client = makeClient();
    stubReject(client, "pulls", "update", new Error("api error"));

    await expect(client.closePr(3)).rejects.toBeInstanceOf(GitHubApiError);
    await expect(client.closePr(3)).rejects.toThrow("PR #3");
  });
});

// ── getCommentBodyHtml ────────────────────────────────────────────────────────

describe("getCommentBodyHtml", () => {
  test("returns body_html on success", async () => {
    const client = makeClient();
    stubResolve(client, "issues", "getComment", { data: { body_html: "<p>Hello</p>" } });

    const html = await client.getCommentBodyHtml(111);
    expect(html).toBe("<p>Hello</p>");
  });

  test("returns null when body_html is absent from response", async () => {
    const client = makeClient();
    stubResolve(client, "issues", "getComment", { data: {} });

    const html = await client.getCommentBodyHtml(111);
    expect(html).toBeNull();
  });

  test("returns null on Octokit error (error swallowed)", async () => {
    const client = makeClient();
    stubReject(client, "issues", "getComment", new Error("not found"));

    const html = await client.getCommentBodyHtml(111);
    expect(html).toBeNull();
  });
});

// ── listIssueComments ────────────────────────────────────────────────────────

describe("listIssueComments", () => {
  test("maps comments correctly with all fields present", async () => {
    const client = makeClient();
    stubResolve(client, "issues", "listComments", {
      data: [
        {
          id: 1,
          body: "Great work!",
          user: { login: "reviewer" },
          created_at: "2024-01-01T00:00:00Z",
          html_url: "https://github.com/acme/my-repo/issues/10#issuecomment-1",
        },
      ],
    });

    const comments = await client.listIssueComments(10);
    expect(comments).toHaveLength(1);
    expect(comments[0]).toEqual({
      id: 1,
      body: "Great work!",
      user: { login: "reviewer" },
      created_at: "2024-01-01T00:00:00Z",
      html_url: "https://github.com/acme/my-repo/issues/10#issuecomment-1",
    });
  });

  test("maps null user, undefined body, and null html_url to null / empty string", async () => {
    const client = makeClient();
    stubResolve(client, "issues", "listComments", {
      data: [
        { id: 2, body: undefined, user: null, created_at: "2024-01-01T00:00:00Z", html_url: null },
      ],
    });

    const comments = await client.listIssueComments(10);
    expect(comments[0]!.user).toBeNull();
    expect(comments[0]!.body).toBeNull();
    expect(comments[0]!.html_url).toBe("");
  });

  test("passes perPage and page options to Octokit", async () => {
    const client = makeClient();
    const capturedParams: Record<string, unknown>[] = [];
    octNamespace(client, "issues")["listComments"] = mock(async (p: Record<string, unknown>) => {
      capturedParams.push(p);
      return { data: [] };
    });

    await client.listIssueComments(10, { perPage: 50, page: 2 });
    expect(capturedParams[0]).toMatchObject({ per_page: 50, page: 2 });
  });

  test("throws GitHubApiError mentioning issue number on Octokit error", async () => {
    const client = makeClient();
    stubReject(client, "issues", "listComments", new Error("error"));

    await expect(client.listIssueComments(10)).rejects.toBeInstanceOf(GitHubApiError);
    await expect(client.listIssueComments(10)).rejects.toThrow("#10");
  });
});

// ── listPullReviewComments ────────────────────────────────────────────────────

describe("listPullReviewComments", () => {
  test("maps review comments correctly", async () => {
    const client = makeClient();
    stubResolve(client, "pulls", "listReviewComments", {
      data: [
        {
          id: 10,
          pull_request_review_id: 5,
          path: "src/foo.ts",
          line: 42,
          body: "Consider using const",
          user: { login: "alice" },
          created_at: "2024-01-01T00:00:00Z",
          commit_id: "abc123",
          in_reply_to_id: null,
        },
      ],
    });

    const comments = await client.listPullReviewComments(7);
    expect(comments).toHaveLength(1);
    expect(comments[0]).toEqual({
      id: 10,
      pull_request_review_id: 5,
      path: "src/foo.ts",
      line: 42,
      body: "Consider using const",
      user: { login: "alice" },
      created_at: "2024-01-01T00:00:00Z",
      commit_id: "abc123",
      in_reply_to_id: null,
    });
  });

  test("maps undefined pull_request_review_id, line, and in_reply_to_id to null", async () => {
    const client = makeClient();
    stubResolve(client, "pulls", "listReviewComments", {
      data: [
        {
          id: 11,
          pull_request_review_id: undefined,
          path: "src/bar.ts",
          line: undefined,
          body: "ok",
          user: null,
          created_at: "2024-01-01T00:00:00Z",
          commit_id: "def",
          in_reply_to_id: undefined,
        },
      ],
    });

    const comments = await client.listPullReviewComments(7);
    expect(comments[0]!.pull_request_review_id).toBeNull();
    expect(comments[0]!.line).toBeNull();
    expect(comments[0]!.in_reply_to_id).toBeNull();
  });

  test("throws GitHubApiError mentioning PR number on Octokit error", async () => {
    const client = makeClient();
    stubReject(client, "pulls", "listReviewComments", new Error("error"));

    await expect(client.listPullReviewComments(7)).rejects.toBeInstanceOf(GitHubApiError);
    await expect(client.listPullReviewComments(7)).rejects.toThrow("PR #7");
  });
});

// ── getPullReviewComment ─────────────────────────────────────────────────────

describe("getPullReviewComment", () => {
  test("returns mapped comment object on success", async () => {
    const client = makeClient();
    stubResolve(client, "pulls", "getReviewComment", {
      data: {
        id: 55,
        path: "src/bar.ts",
        line: 10,
        body: "Nice",
        user: { login: "bob" },
        created_at: "2024-02-01T00:00:00Z",
        commit_id: "def456",
        in_reply_to_id: 50,
      },
    });

    const comment = await client.getPullReviewComment(55);
    expect(comment).toEqual({
      id: 55,
      path: "src/bar.ts",
      line: 10,
      body: "Nice",
      user: { login: "bob" },
      created_at: "2024-02-01T00:00:00Z",
      commit_id: "def456",
      in_reply_to_id: 50,
    });
  });

  test("maps null user and undefined line / in_reply_to_id to null", async () => {
    const client = makeClient();
    stubResolve(client, "pulls", "getReviewComment", {
      data: {
        id: 56,
        path: "src/bar.ts",
        line: undefined,
        body: "ok",
        user: null,
        created_at: "2024-02-01T00:00:00Z",
        commit_id: "def456",
        in_reply_to_id: undefined,
      },
    });

    const comment = await client.getPullReviewComment(56);
    expect(comment.line).toBeNull();
    expect(comment.user).toBeNull();
    expect(comment.in_reply_to_id).toBeNull();
  });

  test("throws GitHubApiError mentioning comment ID on Octokit error", async () => {
    const client = makeClient();
    stubReject(client, "pulls", "getReviewComment", new Error("error"));

    await expect(client.getPullReviewComment(55)).rejects.toBeInstanceOf(GitHubApiError);
    await expect(client.getPullReviewComment(55)).rejects.toThrow("55");
  });
});

// ── listPullReviews ───────────────────────────────────────────────────────────

describe("listPullReviews", () => {
  test("maps reviews correctly", async () => {
    const client = makeClient();
    stubResolve(client, "pulls", "listReviews", {
      data: [
        {
          id: 100,
          state: "APPROVED",
          user: { login: "reviewer" },
          body: "LGTM",
          submitted_at: "2024-01-01T00:00:00Z",
        },
      ],
    });

    const reviews = await client.listPullReviews(20);
    expect(reviews).toHaveLength(1);
    expect(reviews[0]).toEqual({
      id: 100,
      state: "APPROVED",
      user: { login: "reviewer" },
      body: "LGTM",
      submitted_at: "2024-01-01T00:00:00Z",
    });
  });

  test("maps null user and undefined body / submitted_at to null", async () => {
    const client = makeClient();
    stubResolve(client, "pulls", "listReviews", {
      data: [
        {
          id: 101,
          state: "COMMENTED",
          user: null,
          body: undefined,
          submitted_at: undefined,
        },
      ],
    });

    const reviews = await client.listPullReviews(20);
    expect(reviews[0]!.user).toBeNull();
    expect(reviews[0]!.body).toBeNull();
    expect(reviews[0]!.submitted_at).toBeNull();
  });

  test("throws GitHubApiError mentioning PR number on Octokit error", async () => {
    const client = makeClient();
    stubReject(client, "pulls", "listReviews", new Error("error"));

    await expect(client.listPullReviews(20)).rejects.toBeInstanceOf(GitHubApiError);
    await expect(client.listPullReviews(20)).rejects.toThrow("PR #20");
  });
});

// ── createIssueComment ────────────────────────────────────────────────────────

describe("createIssueComment", () => {
  test("calls issues.createComment with correct args", async () => {
    const client = makeClient();
    const capturedParams: Record<string, unknown>[] = [];
    octNamespace(client, "issues")["createComment"] = mock(async (p: Record<string, unknown>) => {
      capturedParams.push(p);
      return { data: {} };
    });

    await client.createIssueComment(15, "Hello!");
    expect(capturedParams[0]).toMatchObject({ issue_number: 15, body: "Hello!" });
  });

  test("throws GitHubApiError mentioning issue number on Octokit error", async () => {
    const client = makeClient();
    stubReject(client, "issues", "createComment", new Error("error"));

    await expect(client.createIssueComment(15, "msg")).rejects.toBeInstanceOf(GitHubApiError);
    await expect(client.createIssueComment(15, "msg")).rejects.toThrow("#15");
  });
});

// ── createReviewCommentReply ──────────────────────────────────────────────────

describe("createReviewCommentReply", () => {
  test("calls pulls.createReplyForReviewComment with correct args", async () => {
    const client = makeClient();
    const capturedParams: Record<string, unknown>[] = [];
    octNamespace(client, "pulls")["createReplyForReviewComment"] = mock(
      async (p: Record<string, unknown>) => {
        capturedParams.push(p);
        return { data: {} };
      },
    );

    await client.createReviewCommentReply(5, 99, "Acknowledged");
    expect(capturedParams[0]).toMatchObject({
      pull_number: 5,
      comment_id: 99,
      body: "Acknowledged",
    });
  });

  test("throws GitHubApiError mentioning comment ID on Octokit error", async () => {
    const client = makeClient();
    stubReject(client, "pulls", "createReplyForReviewComment", new Error("error"));

    await expect(client.createReviewCommentReply(5, 99, "msg")).rejects.toBeInstanceOf(
      GitHubApiError,
    );
    await expect(client.createReviewCommentReply(5, 99, "msg")).rejects.toThrow("99");
  });
});

// ── buildPrTitle ──────────────────────────────────────────────────────────────

describe("buildPrTitle", () => {
  const client = makeClient();

  test("uses default format when no template is provided", () => {
    const title = client.buildPrTitle("HU-42", "Fix the bug", null);
    expect(title).toBe("[HU-42] | Fix the bug");
  });

  test("substitutes {key} and {summary} in template", () => {
    const title = client.buildPrTitle("HU-42", "Fix bug", null, "{key} – {summary}");
    expect(title).toBe("HU-42 – Fix bug");
  });

  test("substitutes {type} with flow value", () => {
    const title = client.buildPrTitle("HU-42", "Fix bug", "hotfix" as IssueFlow, "{key} | {type}");
    expect(title).toBe("HU-42 | hotfix");
  });

  test("cleans up double pipe when {type} is empty (null flow)", () => {
    const title = client.buildPrTitle("HU-42", "Fix bug", null, "{key} | {type} | end");
    expect(title).not.toMatch(/\|\s*\|/);
    expect(title).toContain("HU-42");
  });

  test("strips leading and trailing pipes from title", () => {
    const title = client.buildPrTitle("HU-1", "Test", null, "| {key} |");
    expect(title).not.toMatch(/^\s*\|/);
    expect(title).not.toMatch(/\|\s*$/);
  });
});

// ── buildPrBody ───────────────────────────────────────────────────────────────

describe("buildPrBody", () => {
  const client = makeClient();

  test("generates body with Jira link and flow section marked for bugfix", () => {
    const body = client.buildPrBody("HU-42", "Fixed the bug", true, true, "bugfix");
    expect(body).toContain("## Summary");
    expect(body).toContain("Fixed the bug");
    expect(body).toContain(`https://${JIRA_DOMAIN}.atlassian.net/browse/HU-42`);
    expect(body).toContain("[x] Bugfix");
    expect(body).toContain("[ ] Regular");
    expect(body).toContain("[ ] Hotfix");
    expect(body).toContain("PASSED");
  });

  test("uses fallback summary text when agentSummary is empty", () => {
    const body = client.buildPrBody("HU-99", "", false, false);
    expect(body).toContain("See commit diff for details.");
  });

  test("shows N/A for build and test when buildPassed and testPassed are false", () => {
    const body = client.buildPrBody("HU-1", "Summary", false, false, "regular");
    expect(body).toContain("N/A");
    expect(body).not.toContain("PASSED");
  });

  test("marks hotfix as checked when flow is hotfix", () => {
    const body = client.buildPrBody("HU-1", "Summary", true, true, "hotfix");
    expect(body).toContain("[x] Hotfix");
    expect(body).toContain("[ ] Regular");
    expect(body).toContain("[ ] Bugfix");
  });

  test("marks regular as checked when flow is regular", () => {
    const body = client.buildPrBody("HU-1", "Summary", true, true, "regular");
    expect(body).toContain("[x] Regular");
    expect(body).toContain("[ ] Hotfix");
    expect(body).toContain("[ ] Bugfix");
  });

  test("includes bot attribution footer", () => {
    const body = client.buildPrBody("HU-1", "Summary", true, true);
    expect(body).toContain("hu-developer-bot");
  });

  test("uses jiraDomain injected at construction time in the Jira URL", () => {
    const customClient = new GitHubClientImpl(TOKEN, VALID_REPO_URL, "mycompany", logger);
    const body = customClient.buildPrBody("HU-1", "Summary", true, true);
    expect(body).toContain("mycompany.atlassian.net");
  });
});
