import { describe, test, expect, beforeEach, afterEach, spyOn } from "bun:test";
import { JiraClientImpl } from "@/jira/client.js";
import { JiraApiError } from "@/core/errors.js";
import { JIRA_FLOW_FIELD_ID } from "@/core/constants.js";
import { mockLogger } from "../_helpers/mock-logger.js";
import { jsonResponse, errorResponse } from "../_helpers/fake-fetch.js";

const logger = mockLogger();

// ── Client factory ───────────────────────────────────────────────────────────

function makeClient(): JiraClientImpl {
  return new JiraClientImpl("mydomain", "test@example.com", "token123", logger);
}

// ── Minimal issue fixture ────────────────────────────────────────────────────

const EMPTY_CHILDREN_RESPONSE = { issues: [] };

function makeIssueRaw(overrides: Record<string, unknown> = {}): Record<string, unknown> {
  return {
    id: "10001",
    key: "PROJ-123",
    fields: {
      summary: "Fix the bug",
      description: null,
      issuetype: { name: "Bug", subtask: false },
      status: { name: "In Progress" },
      created: "2024-01-01T00:00:00Z",
      assignee: null,
      parent: null,
      subtasks: [],
      attachment: [],
      comment: { comments: [] },
      components: [],
      customfield_10035: null,
      [JIRA_FLOW_FIELD_ID]: null,
      ...overrides,
    },
  };
}

// ── Constructor ──────────────────────────────────────────────────────────────

describe("JiraClientImpl constructor", () => {
  test("exposes email property", () => {
    const client = makeClient();
    expect(client.email).toBe("test@example.com");
  });

  test("sets correct base URL from domain", () => {
    const client = makeClient() as unknown as { baseUrl: string };
    expect(client.baseUrl).toBe("https://mydomain.atlassian.net/rest/api/3");
  });

  test("encodes auth header as Basic base64", () => {
    const client = makeClient() as unknown as { authHeader: string };
    const expected = "Basic " + Buffer.from("test@example.com:token123").toString("base64");
    expect(client.authHeader).toBe(expected);
  });
});

// ── ADF → Markdown conversion ────────────────────────────────────────────────

describe("JiraClientImpl – ADF → Markdown (adfToMarkdown private)", () => {
  let client: ReturnType<typeof makeClient>;

  beforeEach(() => {
    client = makeClient();
  });

  // Access the private method via cast-to-any

  function adf(node: unknown): string {
    return (client as unknown as { adfToMarkdown: (n: unknown) => string }).adfToMarkdown(node);
  }

  test("returns empty string for null-ish nodes", () => {
    expect(adf(null)).toBe("");
    expect(adf(undefined)).toBe("");
  });

  test("renders plain text node", () => {
    expect(adf({ type: "text", text: "hello" })).toBe("hello");
  });

  test("renders bold text (strong mark)", () => {
    expect(adf({ type: "text", text: "bold", marks: [{ type: "strong" }] })).toBe("**bold**");
  });

  test("renders italic text (em mark)", () => {
    expect(adf({ type: "text", text: "italic", marks: [{ type: "em" }] })).toBe("*italic*");
  });

  test("renders inline code (code mark)", () => {
    expect(adf({ type: "text", text: "code", marks: [{ type: "code" }] })).toBe("`code`");
  });

  test("renders link text", () => {
    const node = {
      type: "text",
      text: "click",
      marks: [{ type: "link", attrs: { href: "https://example.com" } }],
    };
    expect(adf(node)).toBe("[click](https://example.com)");
  });

  test("renders inlineCard with URL", () => {
    expect(adf({ type: "inlineCard", attrs: { url: "https://x.com" } })).toBe("<https://x.com>");
  });

  test("renders inlineCard without URL as empty string", () => {
    expect(adf({ type: "inlineCard", attrs: {} })).toBe("");
  });

  test("renders paragraph with trailing double newline", () => {
    const node = {
      type: "paragraph",
      content: [{ type: "text", text: "hello" }],
    };
    expect(adf(node)).toBe("hello\n\n");
  });

  test("renders heading with correct number of hashes", () => {
    const h2 = {
      type: "heading",
      attrs: { level: 2 },
      content: [{ type: "text", text: "My Title" }],
    };
    expect(adf(h2)).toBe("## My Title\n\n");
  });

  test("renders heading defaults to h1 when level missing", () => {
    const h = { type: "heading", attrs: {}, content: [{ type: "text", text: "T" }] };
    expect(adf(h)).toBe("# T\n\n");
  });

  test("renders bulletList by joining listItem children", () => {
    const node = {
      type: "bulletList",
      content: [
        {
          type: "listItem",
          content: [{ type: "paragraph", content: [{ type: "text", text: "item 1" }] }],
        },
        {
          type: "listItem",
          content: [{ type: "paragraph", content: [{ type: "text", text: "item 2" }] }],
        },
      ],
    };
    const result = adf(node);
    expect(result).toContain("- item 1");
    expect(result).toContain("- item 2");
  });

  test("renders orderedList similarly to bulletList", () => {
    const node = {
      type: "orderedList",
      content: [
        {
          type: "listItem",
          content: [{ type: "paragraph", content: [{ type: "text", text: "first" }] }],
        },
      ],
    };
    expect(adf(node)).toContain("- first");
  });

  test("renders codeBlock with language prefix", () => {
    const node = {
      type: "codeBlock",
      attrs: { language: "typescript" },
      content: [{ type: "text", text: "const x = 1;" }],
    };
    expect(adf(node)).toBe("```typescript\nconst x = 1;```\n\n");
  });

  test("renders codeBlock without language", () => {
    const node = {
      type: "codeBlock",
      attrs: {},
      content: [{ type: "text", text: "code" }],
    };
    expect(adf(node)).toBe("```\ncode```\n\n");
  });

  test("renders blockquote with leading > on each line", () => {
    const node = {
      type: "blockquote",
      content: [{ type: "paragraph", content: [{ type: "text", text: "quoted" }] }],
    };
    const result = adf(node);
    // Each line of the children should be prefixed with "> "
    expect(result).toContain("> quoted");
  });

  test("renders hardBreak as newline", () => {
    expect(adf({ type: "hardBreak" })).toBe("\n");
  });

  test("renders doc by joining children", () => {
    const node = {
      type: "doc",
      content: [
        { type: "paragraph", content: [{ type: "text", text: "p1" }] },
        { type: "paragraph", content: [{ type: "text", text: "p2" }] },
      ],
    };
    expect(adf(node)).toBe("p1\n\np2\n\n");
  });

  test("renders mention with id and display text", () => {
    const node = {
      type: "mention",
      attrs: { id: "1", text: "@User" },
      content: [],
    };
    expect(adf(node)).toBe("[mention:1]@User");
  });

  test("renders unknown node type by joining children (default case)", () => {
    const node = {
      type: "unknownExtensionNode",
      content: [],
    };
    expect(adf(node)).toBe("");
  });

  test("renders table by joining children via default case", () => {
    const node = {
      type: "table",
      content: [
        {
          type: "tableRow",
          content: [
            {
              type: "tableCell",
              content: [{ type: "paragraph", content: [{ type: "text", text: "cell" }] }],
            },
          ],
        },
      ],
    };
    const result = adf(node);
    expect(result).toContain("cell");
  });

  test("node with no content and non-text type returns empty string", () => {
    expect(adf({ type: "rule" })).toBe("");
  });
});

// ── getIssue ─────────────────────────────────────────────────────────────────

describe("JiraClientImpl – getIssue", () => {
  let fetchSpy: ReturnType<typeof spyOn>;

  afterEach(() => {
    fetchSpy.mockRestore();
  });

  test("fetches and parses a basic issue (no parent, no children)", async () => {
    const client = makeClient();
    const raw = makeIssueRaw();
    fetchSpy = spyOn(globalThis, "fetch")
      // First call: the issue itself
      .mockResolvedValueOnce(jsonResponse(raw))
      // Second call: fetchChildrenByParent
      .mockResolvedValueOnce(jsonResponse(EMPTY_CHILDREN_RESPONSE));

    const issue = await client.getIssue("PROJ-123");
    expect(issue.key).toBe("PROJ-123");
    expect(issue.id).toBe("10001");
    expect(issue.summary).toBe("Fix the bug");
    expect(issue.issueType).toBe("Bug");
    expect(issue.status).toBe("In Progress");
    expect(issue.parent).toBeNull();
    expect(issue.children).toEqual([]);
    expect(issue.assignee).toBeNull();
    expect(issue.description).toBeNull();
    expect(fetchSpy).toHaveBeenCalledTimes(2);
  });

  test("parses assignee when present", async () => {
    const client = makeClient();
    const raw = makeIssueRaw({
      assignee: { accountId: "abc123", displayName: "Alice", emailAddress: "alice@example.com" },
    });
    fetchSpy = spyOn(globalThis, "fetch")
      .mockResolvedValueOnce(jsonResponse(raw))
      .mockResolvedValueOnce(jsonResponse(EMPTY_CHILDREN_RESPONSE));

    const issue = await client.getIssue("PROJ-123");
    expect(issue.assignee).toEqual({
      accountId: "abc123",
      displayName: "Alice",
      emailAddress: "alice@example.com",
    });
  });

  test("parses description from ADF doc node", async () => {
    const client = makeClient();
    const raw = makeIssueRaw({
      description: {
        type: "doc",
        version: 1,
        content: [
          {
            type: "paragraph",
            content: [{ type: "text", text: "This is a description" }],
          },
        ],
      },
    });
    fetchSpy = spyOn(globalThis, "fetch")
      .mockResolvedValueOnce(jsonResponse(raw))
      .mockResolvedValueOnce(jsonResponse(EMPTY_CHILDREN_RESPONSE));

    const issue = await client.getIssue("PROJ-123");
    expect(issue.description).toContain("This is a description");
  });

  test("extracts acceptanceCriteria from description", async () => {
    const client = makeClient();
    const markdownDescription = "Some intro\n\nAcceptance Criteria:\n- item one\n- item two";
    const raw = makeIssueRaw({
      description: {
        type: "doc",
        version: 1,
        content: [
          {
            type: "paragraph",
            content: [{ type: "text", text: markdownDescription }],
          },
        ],
      },
    });
    fetchSpy = spyOn(globalThis, "fetch")
      .mockResolvedValueOnce(jsonResponse(raw))
      .mockResolvedValueOnce(jsonResponse(EMPTY_CHILDREN_RESPONSE));

    const issue = await client.getIssue("PROJ-123");
    expect(issue.acceptanceCriteria).not.toBeNull();
  });

  test("sets parent correctly when issue has parent", async () => {
    const client = makeClient();
    const raw = makeIssueRaw({
      parent: {
        id: "20000",
        key: "PROJ-100",
        fields: {
          summary: "Parent story",
          issuetype: { name: "Story" },
          status: { name: "Open" },
        },
      },
    });

    const parentDetailsResponse = {
      issues: [
        {
          key: "PROJ-100",
          fields: {
            description: null,
            attachment: [],
            [JIRA_FLOW_FIELD_ID]: null,
          },
        },
      ],
    };

    fetchSpy = spyOn(globalThis, "fetch")
      // Issue fetch
      .mockResolvedValueOnce(jsonResponse(raw))
      // fetchChildrenByParent and fetchParentDetails run in Promise.all
      .mockResolvedValueOnce(jsonResponse(EMPTY_CHILDREN_RESPONSE))
      .mockResolvedValueOnce(jsonResponse(parentDetailsResponse));

    const issue = await client.getIssue("PROJ-123");
    expect(issue.parent).not.toBeNull();
    expect(issue.parent?.key).toBe("PROJ-100");
    expect(issue.parent?.summary).toBe("Parent story");
    expect(issue.parent?.issueType).toBe("Story");
  });

  test("returns children from fetchChildrenByParent", async () => {
    const client = makeClient();
    const raw = makeIssueRaw();
    const childrenResponse = {
      issues: [
        {
          id: "30001",
          key: "PROJ-124",
          fields: {
            summary: "Child task",
            issuetype: { name: "Sub-task" },
            status: { name: "To Do" },
            parent: { key: "PROJ-123" },
          },
        },
      ],
    };

    fetchSpy = spyOn(globalThis, "fetch")
      .mockResolvedValueOnce(jsonResponse(raw))
      .mockResolvedValueOnce(jsonResponse(childrenResponse));

    const issue = await client.getIssue("PROJ-123");
    expect(issue.children).toHaveLength(1);
    expect(issue.children[0]!.key).toBe("PROJ-124");
    expect(issue.children[0]!.summary).toBe("Child task");
  });

  test("maps subtasks", async () => {
    const client = makeClient();
    const raw = makeIssueRaw({
      subtasks: [
        {
          id: "sub1",
          key: "PROJ-124",
          fields: {
            summary: "Do the subtask",
            issuetype: { name: "Sub-task" },
            status: { name: "To Do" },
          },
        },
      ],
    });
    fetchSpy = spyOn(globalThis, "fetch")
      .mockResolvedValueOnce(jsonResponse(raw))
      .mockResolvedValueOnce(jsonResponse(EMPTY_CHILDREN_RESPONSE));

    const issue = await client.getIssue("PROJ-123");
    expect(issue.subtasks).toHaveLength(1);
    expect(issue.subtasks[0]!.key).toBe("PROJ-124");
    expect(issue.subtasks[0]!.summary).toBe("Do the subtask");
    expect(issue.subtasks[0]!.issueType).toBe("Sub-task");
    expect(issue.subtasks[0]!.status).toBe("To Do");
  });

  test("maps attachments", async () => {
    const client = makeClient();
    const raw = makeIssueRaw({
      attachment: [
        {
          id: "att1",
          filename: "screenshot.png",
          mimeType: "image/png",
          size: 1024,
          content: "https://example.com/screenshot.png",
          thumbnail: null,
          created: "2024-01-01T00:00:00Z",
        },
      ],
    });
    fetchSpy = spyOn(globalThis, "fetch")
      .mockResolvedValueOnce(jsonResponse(raw))
      .mockResolvedValueOnce(jsonResponse(EMPTY_CHILDREN_RESPONSE));

    const issue = await client.getIssue("PROJ-123");
    expect(issue.attachments).toHaveLength(1);
    expect(issue.attachments[0]!.filename).toBe("screenshot.png");
  });

  test("maps comments", async () => {
    const client = makeClient();
    const raw = makeIssueRaw({
      comment: {
        comments: [
          {
            id: "cmt1",
            author: { accountId: "usr1", displayName: "Bob", emailAddress: "bob@example.com" },
            body: { type: "doc", version: 1, content: [] },
            created: "2024-01-02T00:00:00Z",
          },
        ],
      },
    });
    fetchSpy = spyOn(globalThis, "fetch")
      .mockResolvedValueOnce(jsonResponse(raw))
      .mockResolvedValueOnce(jsonResponse(EMPTY_CHILDREN_RESPONSE));

    const issue = await client.getIssue("PROJ-123");
    expect(issue.comments).toHaveLength(1);
    expect(issue.comments[0]!.author.displayName).toBe("Bob");
  });

  test("maps flow field from string value", async () => {
    const client = makeClient();
    const raw = makeIssueRaw({ [JIRA_FLOW_FIELD_ID]: "hotfix" });
    fetchSpy = spyOn(globalThis, "fetch")
      .mockResolvedValueOnce(jsonResponse(raw))
      .mockResolvedValueOnce(jsonResponse(EMPTY_CHILDREN_RESPONSE));

    const issue = await client.getIssue("PROJ-123");
    expect(issue.flow).toBe("hotfix");
  });

  test("maps flow field from object with value property", async () => {
    const client = makeClient();
    const raw = makeIssueRaw({ [JIRA_FLOW_FIELD_ID]: { value: "bugfix" } });
    fetchSpy = spyOn(globalThis, "fetch")
      .mockResolvedValueOnce(jsonResponse(raw))
      .mockResolvedValueOnce(jsonResponse(EMPTY_CHILDREN_RESPONSE));

    const issue = await client.getIssue("PROJ-123");
    expect(issue.flow).toBe("bugfix");
  });

  test("throws JiraApiError on 401", async () => {
    const client = makeClient();
    fetchSpy = spyOn(globalThis, "fetch").mockResolvedValueOnce(errorResponse(401, "Unauthorized"));
    await expect(client.getIssue("PROJ-123")).rejects.toThrow(JiraApiError);
  });

  test("throws JiraApiError on 404", async () => {
    const client = makeClient();
    fetchSpy = spyOn(globalThis, "fetch").mockResolvedValueOnce(errorResponse(404, "Not Found"));
    await expect(client.getIssue("PROJ-123")).rejects.toThrow(JiraApiError);
  });

  test("throws JiraApiError on 500", async () => {
    const client = makeClient();
    fetchSpy = spyOn(globalThis, "fetch").mockResolvedValueOnce(
      errorResponse(500, "Internal Server Error"),
    );
    await expect(client.getIssue("PROJ-123")).rejects.toThrow(JiraApiError);
  });
});

// ── searchIssues ─────────────────────────────────────────────────────────────

describe("JiraClientImpl – searchIssues", () => {
  let fetchSpy: ReturnType<typeof spyOn>;

  afterEach(() => {
    fetchSpy.mockRestore();
  });

  function makeSearchResponse(issues: unknown[] = []): unknown {
    return { total: issues.length, issues };
  }

  function makeSearchIssue(key = "PROJ-1"): unknown {
    return {
      id: "10001",
      key,
      fields: {
        summary: "Search result issue",
        description: null,
        issuetype: { name: "Bug" },
        status: { name: "Open" },
        created: "2024-01-01T00:00:00Z",
        assignee: null,
        parent: null,
        subtasks: [],
        attachment: [],
        comment: { comments: [] },
        components: [],
      },
    };
  }

  test("returns mapped issues from JQL search", async () => {
    const client = makeClient();
    const searchResponse = makeSearchResponse([
      makeSearchIssue("PROJ-10"),
      makeSearchIssue("PROJ-11"),
    ]);
    fetchSpy = spyOn(globalThis, "fetch")
      // search results
      .mockResolvedValueOnce(jsonResponse(searchResponse))
      // fetchChildrenByParent
      .mockResolvedValueOnce(jsonResponse(EMPTY_CHILDREN_RESPONSE))
      // fetchParentDetails (no parent keys = no call needed... but Promise.all always resolves)
      .mockResolvedValueOnce(jsonResponse(EMPTY_CHILDREN_RESPONSE));

    const result = await client.searchIssues("project = PROJ");
    expect(result.issues).toHaveLength(2);
    expect(result.total).toBe(2);
    expect(result.issues[0]!.key).toBe("PROJ-10");
  });

  test("returns empty array when no issues found", async () => {
    const client = makeClient();
    fetchSpy = spyOn(globalThis, "fetch")
      .mockResolvedValueOnce(jsonResponse(makeSearchResponse([])))
      .mockResolvedValueOnce(jsonResponse(EMPTY_CHILDREN_RESPONSE))
      .mockResolvedValueOnce(jsonResponse(EMPTY_CHILDREN_RESPONSE));

    const result = await client.searchIssues("project = EMPTY");
    expect(result.issues).toEqual([]);
    expect(result.total).toBe(0);
  });

  test("throws JiraApiError on non-ok response", async () => {
    const client = makeClient();
    fetchSpy = spyOn(globalThis, "fetch").mockResolvedValueOnce(
      errorResponse(400, "Bad Request", "invalid jql"),
    );
    await expect(client.searchIssues("bad jql!")).rejects.toThrow(JiraApiError);
  });

  test("maps subtasks on search results", async () => {
    const client = makeClient();
    const issueWithSubtask = {
      ...(makeSearchIssue("PROJ-10") as Record<string, unknown>),
      fields: {
        ...((makeSearchIssue("PROJ-10") as Record<string, unknown>).fields as Record<
          string,
          unknown
        >),
        subtasks: [
          {
            id: "sub1",
            key: "PROJ-11",
            fields: {
              summary: "Subtask one",
              issuetype: { name: "Sub-task" },
              status: { name: "Done" },
            },
          },
        ],
      },
    };
    fetchSpy = spyOn(globalThis, "fetch")
      .mockResolvedValueOnce(jsonResponse(makeSearchResponse([issueWithSubtask])))
      .mockResolvedValueOnce(jsonResponse(EMPTY_CHILDREN_RESPONSE))
      .mockResolvedValueOnce(jsonResponse(EMPTY_CHILDREN_RESPONSE));

    const result = await client.searchIssues("project = PROJ");
    expect(result.issues[0]!.subtasks).toHaveLength(1);
    expect(result.issues[0]!.subtasks[0]!.key).toBe("PROJ-11");
    expect(result.issues[0]!.subtasks[0]!.issueType).toBe("Sub-task");
  });

  test("returns null flow when no flow field is present on search result", async () => {
    // Note: jiraSearchResponseSchema uses baseIssueFieldsSchema which strips unknown fields including
    // JIRA_FLOW_FIELD_ID — so mapFlowField always returns null on the parsed issue object.
    // Flow can only be inherited from parent.
    const client = makeClient();
    fetchSpy = spyOn(globalThis, "fetch")
      .mockResolvedValueOnce(jsonResponse(makeSearchResponse([makeSearchIssue("PROJ-5")])))
      .mockResolvedValueOnce(jsonResponse(EMPTY_CHILDREN_RESPONSE))
      .mockResolvedValueOnce(jsonResponse(EMPTY_CHILDREN_RESPONSE));

    const result = await client.searchIssues("project = PROJ");
    expect(result.issues[0]!.flow).toBeNull();
  });
});

// ── listProjects ─────────────────────────────────────────────────────────────

describe("JiraClientImpl – listProjects", () => {
  let fetchSpy: ReturnType<typeof spyOn>;

  afterEach(() => {
    fetchSpy.mockRestore();
  });

  const projectResponse = {
    startAt: 0,
    maxResults: 50,
    total: 2,
    isLast: true,
    values: [
      { id: "10001", key: "PROJ", name: "Project One", projectTypeKey: "software" },
      { id: "10002", key: "HELP", name: "Help Desk", projectTypeKey: "service_desk" },
    ],
  };

  test("returns mapped projects on success", async () => {
    const client = makeClient();
    fetchSpy = spyOn(globalThis, "fetch").mockResolvedValueOnce(jsonResponse(projectResponse));

    const result = await client.listProjects();
    expect(result.total).toBe(2);
    expect(result.isLast).toBe(true);
    expect(result.projects).toHaveLength(2);
    expect(result.projects[0]!.key).toBe("PROJ");
    expect(result.projects[1]!.projectTypeKey).toBe("service_desk");
  });

  test("accepts pagination options", async () => {
    const client = makeClient();
    fetchSpy = spyOn(globalThis, "fetch").mockResolvedValueOnce(
      jsonResponse({ ...projectResponse, values: [] }),
    );

    await client.listProjects({ startAt: 50, maxResults: 25 });
    const [url] = fetchSpy.mock.calls[0] as [string];
    expect(url).toContain("startAt=50");
    expect(url).toContain("maxResults=25");
  });

  test("throws JiraApiError on non-ok response", async () => {
    const client = makeClient();
    fetchSpy = spyOn(globalThis, "fetch").mockResolvedValueOnce(errorResponse(403, "Forbidden"));
    await expect(client.listProjects()).rejects.toThrow(JiraApiError);
  });
});

// ── getTransitions ───────────────────────────────────────────────────────────

describe("JiraClientImpl – getTransitions", () => {
  let fetchSpy: ReturnType<typeof spyOn>;

  afterEach(() => {
    fetchSpy.mockRestore();
  });

  test("returns list of transitions", async () => {
    const client = makeClient();
    fetchSpy = spyOn(globalThis, "fetch").mockResolvedValueOnce(
      jsonResponse({
        transitions: [
          { id: "11", name: "To Do" },
          { id: "21", name: "In Progress" },
          { id: "31", name: "Done" },
        ],
      }),
    );

    const transitions = await client.getTransitions("PROJ-123");
    expect(transitions).toHaveLength(3);
    expect(transitions[0]).toEqual({ id: "11", name: "To Do" });
    expect(transitions[2]).toEqual({ id: "31", name: "Done" });
  });

  test("throws JiraApiError on non-ok response", async () => {
    const client = makeClient();
    fetchSpy = spyOn(globalThis, "fetch").mockResolvedValueOnce(errorResponse(500, "Server Error"));
    await expect(client.getTransitions("PROJ-123")).rejects.toThrow(JiraApiError);
  });
});

// ── transitionIssue ──────────────────────────────────────────────────────────

describe("JiraClientImpl – transitionIssue", () => {
  let fetchSpy: ReturnType<typeof spyOn>;

  afterEach(() => {
    fetchSpy.mockRestore();
  });

  test("sends POST with transition ID matching target status", async () => {
    const client = makeClient();
    fetchSpy = spyOn(globalThis, "fetch")
      // GET transitions
      .mockResolvedValueOnce(jsonResponse({ transitions: [{ id: "21", name: "In Progress" }] }))
      // POST transition
      .mockResolvedValueOnce(new Response(null, { status: 204 }));

    await expect(client.transitionIssue("PROJ-123", "In Progress")).resolves.toBeUndefined();
    expect(fetchSpy).toHaveBeenCalledTimes(2);
    const [, postInit] = fetchSpy.mock.calls[1] as [string, RequestInit];
    expect(postInit.method).toBe("POST");
    const body = JSON.parse(postInit.body as string) as Record<string, unknown>;
    expect((body.transition as Record<string, unknown>).id).toBe("21");
  });

  test("is case-insensitive for transition name matching", async () => {
    const client = makeClient();
    fetchSpy = spyOn(globalThis, "fetch")
      .mockResolvedValueOnce(jsonResponse({ transitions: [{ id: "31", name: "Done" }] }))
      .mockResolvedValueOnce(new Response(null, { status: 204 }));

    await expect(client.transitionIssue("PROJ-123", "done")).resolves.toBeUndefined();
  });

  test("throws JiraApiError when transition not found", async () => {
    const client = makeClient();
    fetchSpy = spyOn(globalThis, "fetch").mockResolvedValueOnce(
      jsonResponse({ transitions: [{ id: "11", name: "To Do" }] }),
    );

    await expect(client.transitionIssue("PROJ-123", "Nonexistent")).rejects.toThrow(JiraApiError);
  });

  test("throws JiraApiError when GET transitions fails", async () => {
    const client = makeClient();
    fetchSpy = spyOn(globalThis, "fetch").mockResolvedValueOnce(errorResponse(401, "Unauthorized"));
    await expect(client.transitionIssue("PROJ-123", "Done")).rejects.toThrow(JiraApiError);
  });

  test("throws JiraApiError when POST transition fails", async () => {
    const client = makeClient();
    fetchSpy = spyOn(globalThis, "fetch")
      .mockResolvedValueOnce(jsonResponse({ transitions: [{ id: "31", name: "Done" }] }))
      .mockResolvedValueOnce(errorResponse(400, "Bad Request"));

    await expect(client.transitionIssue("PROJ-123", "Done")).rejects.toThrow(JiraApiError);
  });
});

// ── addComment ───────────────────────────────────────────────────────────────

describe("JiraClientImpl – addComment", () => {
  let fetchSpy: ReturnType<typeof spyOn>;

  afterEach(() => {
    fetchSpy.mockRestore();
  });

  test("sends POST with ADF body and returns comment ID", async () => {
    const client = makeClient();
    fetchSpy = spyOn(globalThis, "fetch").mockResolvedValueOnce(
      jsonResponse({ id: "cmt123" }, 201),
    );

    const result = await client.addComment("PROJ-123", "This is a comment");
    expect(result.id).toBe("cmt123");
    const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
    expect(url).toContain("/issue/PROJ-123/comment");
    expect(init.method).toBe("POST");
    const body = JSON.parse(init.body as string) as Record<string, unknown>;
    expect((body.body as Record<string, unknown>).type).toBe("doc");
  });

  test("splits multi-paragraph comment into ADF paragraphs", async () => {
    const client = makeClient();
    fetchSpy = spyOn(globalThis, "fetch").mockResolvedValueOnce(
      jsonResponse({ id: "cmt456" }, 201),
    );

    await client.addComment("PROJ-123", "Para one\n\nPara two");
    const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
    const body = JSON.parse(init.body as string) as Record<string, unknown>;
    const docContent = (body.body as Record<string, unknown>).content as unknown[];
    expect(docContent).toHaveLength(2);
  });

  test("throws JiraApiError on non-ok response", async () => {
    const client = makeClient();
    fetchSpy = spyOn(globalThis, "fetch").mockResolvedValueOnce(errorResponse(403, "Forbidden"));
    await expect(client.addComment("PROJ-123", "test")).rejects.toThrow(JiraApiError);
  });
});

// ── createSubtask ─────────────────────────────────────────────────────────────

describe("JiraClientImpl – createSubtask", () => {
  let fetchSpy: ReturnType<typeof spyOn>;

  afterEach(() => {
    fetchSpy.mockRestore();
  });

  test("sends POST with correct fields and returns created issue key", async () => {
    const client = makeClient();
    fetchSpy = spyOn(globalThis, "fetch").mockResolvedValueOnce(
      jsonResponse({ id: "20001", key: "PROJ-200" }, 201),
    );

    const result = await client.createSubtask("PROJ-100", "Implement feature", "user123");
    expect(result.key).toBe("PROJ-200");
    expect(result.id).toBe("20001");

    const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
    expect(url).toContain("/issue");
    expect(init.method).toBe("POST");
    const body = JSON.parse(init.body as string) as { fields: Record<string, unknown> };
    expect(body.fields.project).toEqual({ key: "PROJ" });
    expect(body.fields.parent).toEqual({ key: "PROJ-100" });
    expect(body.fields.summary).toBe("Implement feature");
    expect(body.fields.assignee).toEqual({ accountId: "user123" });
  });

  test("includes component IDs when provided", async () => {
    const client = makeClient();
    fetchSpy = spyOn(globalThis, "fetch").mockResolvedValueOnce(
      jsonResponse({ id: "20001", key: "PROJ-201" }, 201),
    );

    const components = [{ id: "comp1" }, { id: "comp2" }];
    await client.createSubtask("PROJ-100", "Task", "user123", components);

    const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
    const body = JSON.parse(init.body as string) as { fields: Record<string, unknown> };
    expect(body.fields.components).toEqual(components);
  });

  test("does not include components when not provided", async () => {
    const client = makeClient();
    fetchSpy = spyOn(globalThis, "fetch").mockResolvedValueOnce(
      jsonResponse({ id: "20001", key: "PROJ-202" }, 201),
    );

    await client.createSubtask("PROJ-100", "Task", "user123");

    const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
    const body = JSON.parse(init.body as string) as { fields: Record<string, unknown> };
    expect(body.fields.components).toBeUndefined();
  });

  test("throws JiraApiError on non-ok response", async () => {
    const client = makeClient();
    fetchSpy = spyOn(globalThis, "fetch").mockResolvedValueOnce(
      errorResponse(400, "Bad Request", "validation error"),
    );
    await expect(client.createSubtask("PROJ-100", "Task", "user123")).rejects.toThrow(JiraApiError);
  });
});

// ── getMyself ─────────────────────────────────────────────────────────────────

describe("JiraClientImpl – getMyself", () => {
  let fetchSpy: ReturnType<typeof spyOn>;

  afterEach(() => {
    fetchSpy.mockRestore();
  });

  test("returns account ID and display name", async () => {
    const client = makeClient();
    fetchSpy = spyOn(globalThis, "fetch").mockResolvedValueOnce(
      jsonResponse({
        accountId: "acc123",
        displayName: "Bot Account",
        emailAddress: "bot@example.com",
      }),
    );

    const user = await client.getMyself();
    expect(user.accountId).toBe("acc123");
    expect(user.displayName).toBe("Bot Account");
    expect(user.emailAddress).toBe("bot@example.com");
  });

  test("throws JiraApiError on non-ok response", async () => {
    const client = makeClient();
    fetchSpy = spyOn(globalThis, "fetch").mockResolvedValueOnce(errorResponse(401, "Unauthorized"));
    await expect(client.getMyself()).rejects.toThrow(JiraApiError);
  });
});

// ── findUserByEmail ───────────────────────────────────────────────────────────

describe("JiraClientImpl – findUserByEmail", () => {
  let fetchSpy: ReturnType<typeof spyOn>;

  afterEach(() => {
    fetchSpy.mockRestore();
  });

  test("finds user by exact email match", async () => {
    const client = makeClient();
    fetchSpy = spyOn(globalThis, "fetch").mockResolvedValueOnce(
      jsonResponse([
        { accountId: "usr1", displayName: "Alice", emailAddress: "alice@example.com" },
        { accountId: "usr2", displayName: "Other", emailAddress: "other@example.com" },
      ]),
    );

    const user = await client.findUserByEmail("alice@example.com");
    expect(user?.accountId).toBe("usr1");
    expect(user?.displayName).toBe("Alice");
  });

  test("falls back to first result when no exact match", async () => {
    const client = makeClient();
    fetchSpy = spyOn(globalThis, "fetch").mockResolvedValueOnce(
      jsonResponse([{ accountId: "usr1", displayName: "Alice" }]),
    );

    const user = await client.findUserByEmail("different@example.com");
    expect(user?.accountId).toBe("usr1");
  });

  test("returns null when user list is empty", async () => {
    const client = makeClient();
    fetchSpy = spyOn(globalThis, "fetch").mockResolvedValueOnce(jsonResponse([]));

    const user = await client.findUserByEmail("nobody@example.com");
    expect(user).toBeNull();
  });

  test("returns null when response is not ok", async () => {
    const client = makeClient();
    fetchSpy = spyOn(globalThis, "fetch").mockResolvedValueOnce(errorResponse(500, "Server Error"));
    const user = await client.findUserByEmail("test@example.com");
    expect(user).toBeNull();
  });

  test("returns null on network error", async () => {
    const client = makeClient();
    fetchSpy = spyOn(globalThis, "fetch").mockRejectedValueOnce(new Error("Network error"));
    const user = await client.findUserByEmail("test@example.com");
    expect(user).toBeNull();
  });
});

// ── resolveAccountIdForBotEmail ───────────────────────────────────────────────

describe("JiraClientImpl – resolveAccountIdForBotEmail", () => {
  let fetchSpy: ReturnType<typeof spyOn>;

  afterEach(() => {
    fetchSpy.mockRestore();
  });

  test("returns accountId from getMyself on success", async () => {
    const client = makeClient();
    fetchSpy = spyOn(globalThis, "fetch").mockResolvedValueOnce(
      jsonResponse({
        accountId: "bot-acc-id",
        displayName: "Bot",
        emailAddress: "test@example.com",
      }),
    );

    const id = await client.resolveAccountIdForBotEmail("test@example.com");
    expect(id).toBe("bot-acc-id");
  });

  test("falls back to findUserByEmail when getMyself fails", async () => {
    const client = makeClient();
    fetchSpy = spyOn(globalThis, "fetch")
      // getMyself fails
      .mockResolvedValueOnce(errorResponse(401, "Unauthorized"))
      // findUserByEmail succeeds
      .mockResolvedValueOnce(
        jsonResponse([{ accountId: "fallback-id", displayName: "Bot Fallback" }]),
      );

    const id = await client.resolveAccountIdForBotEmail("test@example.com");
    expect(id).toBe("fallback-id");
  });

  test("returns null when both getMyself and findUserByEmail fail", async () => {
    const client = makeClient();
    fetchSpy = spyOn(globalThis, "fetch")
      // getMyself fails
      .mockResolvedValueOnce(errorResponse(401, "Unauthorized"))
      // findUserByEmail returns empty
      .mockResolvedValueOnce(jsonResponse([]));

    const id = await client.resolveAccountIdForBotEmail("test@example.com");
    expect(id).toBeNull();
  });
});

// ── getIssueComponents ────────────────────────────────────────────────────────

describe("JiraClientImpl – getIssueComponents", () => {
  let fetchSpy: ReturnType<typeof spyOn>;

  afterEach(() => {
    fetchSpy.mockRestore();
  });

  test("returns component IDs on success", async () => {
    const client = makeClient();
    fetchSpy = spyOn(globalThis, "fetch").mockResolvedValueOnce(
      jsonResponse({ fields: { components: [{ id: "c1" }, { id: "c2" }] } }),
    );

    const components = await client.getIssueComponents("PROJ-123");
    expect(components).toEqual([{ id: "c1" }, { id: "c2" }]);
  });

  test("returns empty array on non-ok response", async () => {
    const client = makeClient();
    fetchSpy = spyOn(globalThis, "fetch").mockResolvedValueOnce(errorResponse(404, "Not Found"));
    const components = await client.getIssueComponents("PROJ-999");
    expect(components).toEqual([]);
  });

  test("returns empty array when components field missing", async () => {
    const client = makeClient();
    fetchSpy = spyOn(globalThis, "fetch").mockResolvedValueOnce(jsonResponse({ fields: {} }));
    const components = await client.getIssueComponents("PROJ-123");
    expect(components).toEqual([]);
  });
});

// ── downloadAttachment ────────────────────────────────────────────────────────

describe("JiraClientImpl – downloadAttachment", () => {
  let fetchSpy: ReturnType<typeof spyOn>;

  afterEach(() => {
    fetchSpy.mockRestore();
  });

  test("returns buffer on success", async () => {
    const client = makeClient();
    const content = new Uint8Array([1, 2, 3, 4, 5]);
    fetchSpy = spyOn(globalThis, "fetch").mockResolvedValueOnce(
      new Response(content, { status: 200 }),
    );

    const buf = await client.downloadAttachment("https://example.com/file.png");
    expect(buf).toBeInstanceOf(Buffer);
    expect(buf.length).toBe(5);
  });

  test("throws JiraApiError on non-ok response", async () => {
    const client = makeClient();
    fetchSpy = spyOn(globalThis, "fetch").mockResolvedValueOnce(errorResponse(403, "Forbidden"));
    await expect(client.downloadAttachment("https://example.com/file.png")).rejects.toThrow(
      JiraApiError,
    );
  });
});

// ── getLastStatusChangeDate / Author ──────────────────────────────────────────

describe("JiraClientImpl – changelog / status change methods", () => {
  let fetchSpy: ReturnType<typeof spyOn>;

  afterEach(() => {
    fetchSpy.mockRestore();
  });

  const changelogWithStatusChange = {
    values: [
      {
        created: "2024-01-01T10:00:00Z",
        author: { displayName: "Bot", emailAddress: "test@example.com" },
        items: [{ field: "status", fieldtype: "jira" }],
      },
      {
        created: "2024-01-02T10:00:00Z",
        author: { displayName: "Alice", emailAddress: "alice@example.com" },
        items: [{ field: "status", fieldtype: "jira" }],
      },
    ],
  };

  test("getLastStatusChangeDate returns most recent status change date", async () => {
    const client = makeClient();
    fetchSpy = spyOn(globalThis, "fetch").mockResolvedValueOnce(
      jsonResponse(changelogWithStatusChange),
    );

    const date = await client.getLastStatusChangeDate("PROJ-123");
    // The method iterates in reverse, so the last entry in values array is pushed first
    // values[1] = Alice 2024-01-02 → first iterated (i from length-1 down to 0)
    expect(date).toBe("2024-01-02T10:00:00Z");
  });

  test("getLastStatusChangeDate returns null when no status change in changelog", async () => {
    const client = makeClient();
    fetchSpy = spyOn(globalThis, "fetch").mockResolvedValueOnce(
      jsonResponse({
        values: [
          {
            created: "2024-01-01T00:00:00Z",
            author: { displayName: "User" },
            items: [{ field: "assignee", fieldtype: "jira" }],
          },
        ],
      }),
    );

    const date = await client.getLastStatusChangeDate("PROJ-123");
    expect(date).toBeNull();
  });

  test("getLastStatusChangeDate returns null on non-ok response", async () => {
    const client = makeClient();
    fetchSpy = spyOn(globalThis, "fetch").mockResolvedValueOnce(errorResponse(404, "Not Found"));
    const date = await client.getLastStatusChangeDate("PROJ-999");
    expect(date).toBeNull();
  });

  test("getLastStatusChangeAuthor skips bot email and returns human", async () => {
    const client = makeClient();
    // client.email is "test@example.com"
    fetchSpy = spyOn(globalThis, "fetch").mockResolvedValueOnce(
      jsonResponse(changelogWithStatusChange),
    );

    const author = await client.getLastStatusChangeAuthor("PROJ-123");
    // Bot has email test@example.com → should be skipped
    // The method looks for entries where author.emailAddress !== this.email
    expect(author?.displayName).toBe("Alice");
  });

  test("getLastStatusChangeDates returns map with results", async () => {
    const client = makeClient();
    fetchSpy = spyOn(globalThis, "fetch")
      .mockResolvedValueOnce(jsonResponse(changelogWithStatusChange))
      .mockResolvedValueOnce(jsonResponse(changelogWithStatusChange));

    const map = await client.getLastStatusChangeDates(["PROJ-1", "PROJ-2"]);
    expect(map.size).toBe(2);
    expect(map.get("PROJ-1")).toBe("2024-01-02T10:00:00Z");
  });

  test("getLastStatusChangeDates returns empty map for empty input", async () => {
    const client = makeClient();
    fetchSpy = spyOn(globalThis, "fetch");

    const map = await client.getLastStatusChangeDates([]);
    expect(map.size).toBe(0);
    expect(fetchSpy).not.toHaveBeenCalled();
  });
});

// ── mapFlowField (tested indirectly via getIssue) ────────────────────────────

describe("JiraClientImpl – mapFlowField edge cases", () => {
  let fetchSpy: ReturnType<typeof spyOn>;

  afterEach(() => {
    fetchSpy.mockRestore();
  });

  test.each([
    ["hotfix", "hotfix"],
    ["bugfix", "bugfix"],
    ["regular", "regular"],
    ["HOTFIX", "hotfix"],
    ["BUGFIX", "bugfix"],
    ["REGULAR", "regular"],
  ])("maps flow field value %s → %s", async (flowValue, expected) => {
    const client = makeClient();
    const raw = makeIssueRaw({ [JIRA_FLOW_FIELD_ID]: flowValue });
    fetchSpy = spyOn(globalThis, "fetch")
      .mockResolvedValueOnce(jsonResponse(raw))
      .mockResolvedValueOnce(jsonResponse(EMPTY_CHILDREN_RESPONSE));

    const issue = await client.getIssue("PROJ-1");
    expect(issue.flow as string).toBe(expected);
  });

  test("returns null flow for unknown flow value", async () => {
    const client = makeClient();
    const raw = makeIssueRaw({ [JIRA_FLOW_FIELD_ID]: "unknown-type" });
    fetchSpy = spyOn(globalThis, "fetch")
      .mockResolvedValueOnce(jsonResponse(raw))
      .mockResolvedValueOnce(jsonResponse(EMPTY_CHILDREN_RESPONSE));

    const issue = await client.getIssue("PROJ-1");
    expect(issue.flow).toBeNull();
  });
});

// ── extractSections (tested indirectly) ──────────────────────────────────────

describe("JiraClientImpl – extractSections (private, tested via cast)", () => {
  test("extracts acceptance criteria section", () => {
    const client = makeClient() as unknown as {
      extractSections: (md: string) => {
        acceptanceCriteria: string | null;
        stacktrace: string | null;
      };
    };
    const md = "# Title\n\nAcceptance Criteria:\n- must do this\n- must do that\n";
    const result = client.extractSections(md);
    expect(result.acceptanceCriteria).toContain("must do this");
  });

  test("extracts stacktrace section", () => {
    const client = makeClient() as unknown as {
      extractSections: (md: string) => {
        acceptanceCriteria: string | null;
        stacktrace: string | null;
      };
    };
    const md = "Error:\nat foo.ts:1:2\nat bar.ts:3:4";
    const result = client.extractSections(md);
    expect(result.stacktrace).toContain("foo.ts");
  });

  test("returns null when sections not present", () => {
    const client = makeClient() as unknown as {
      extractSections: (md: string) => {
        acceptanceCriteria: string | null;
        stacktrace: string | null;
      };
    };
    const result = client.extractSections("Just a plain description");
    expect(result.acceptanceCriteria).toBeNull();
    expect(result.stacktrace).toBeNull();
  });
});

// ── fetchChildrenByParent error handling ──────────────────────────────────────

describe("JiraClientImpl – fetchChildrenByParent error handling", () => {
  let fetchSpy: ReturnType<typeof spyOn>;

  afterEach(() => {
    fetchSpy.mockRestore();
  });

  test("getIssue continues with empty children when children fetch returns non-ok", async () => {
    const client = makeClient();
    const raw = makeIssueRaw();
    fetchSpy = spyOn(globalThis, "fetch")
      .mockResolvedValueOnce(jsonResponse(raw))
      // fetchChildrenByParent gets a non-ok response → returns empty map (no throw)
      .mockResolvedValueOnce(errorResponse(500, "Server Error"));

    const issue = await client.getIssue("PROJ-123");
    expect(issue.children).toEqual([]);
  });
});
