import { describe, test, expect, mock, beforeEach, afterEach, spyOn } from "bun:test";
import { rmSync, existsSync } from "node:fs";
import { mockLogger } from "../_helpers/mock-logger.js";
import { FakeMetrics } from "../_helpers/fake-metrics.js";
import { createMentionPipelineDeps, buildMentionJob } from "../_helpers/pipeline-deps.js";
import { jsonResponse } from "../_helpers/fake-fetch.js";
import { RepoManagerImpl } from "@/repo/manager.js";
import * as repoManagerMod from "@/repo/manager.js";
import * as rulesSyncMod from "@/repo/rules-sync.js";
import { PrMentionPipeline } from "@/pipeline/pr-mention-pipeline.js";
import { METRIC } from "@/metrics/names.js";

// ── Use spyOn instead of mock.module — mock.module pollutes the shared module
// cache in Bun and leaks into test/repo/manager.test.ts when run in the same process.

let currentMockManager: ReturnType<typeof createMentionPipelineDeps>["mockManager"];

beforeEach(() => {
  const delegate =
    <K extends keyof typeof currentMockManager>(key: K) =>
    (...args: unknown[]) =>
      (currentMockManager[key] as (...a: unknown[]) => unknown)(...args) as never;
  spyOn(RepoManagerImpl.prototype, "ensureCloned").mockImplementation(delegate("ensureCloned"));
  spyOn(RepoManagerImpl.prototype, "checkoutBranch" as never).mockResolvedValue(undefined as never);
  spyOn(RepoManagerImpl.prototype, "checkoutBase" as never).mockResolvedValue(undefined as never);
  spyOn(RepoManagerImpl.prototype, "installDependencies").mockImplementation(
    delegate("installDependencies"),
  );
  spyOn(RepoManagerImpl.prototype, "getChangedFiles").mockImplementation(
    delegate("getChangedFiles"),
  );
  spyOn(RepoManagerImpl.prototype, "hasChanges").mockImplementation(delegate("hasChanges"));
  spyOn(RepoManagerImpl.prototype, "getDiffStats").mockImplementation(delegate("getDiffStats"));
  spyOn(RepoManagerImpl.prototype, "commitAndPush").mockImplementation(delegate("commitAndPush"));
  spyOn(RepoManagerImpl.prototype, "cleanStaleBranches").mockImplementation(
    delegate("cleanStaleBranches"),
  );
  spyOn(repoManagerMod as never, "configureGitAuth").mockResolvedValue(undefined);
  spyOn(repoManagerMod as never, "syncAllRepos").mockResolvedValue([]);
  spyOn(rulesSyncMod as never, "syncRemoteRulesRepos").mockResolvedValue(undefined);
});

afterEach(() => mock.restore());

// ── Helpers ───────────────────────────────────────────────────────────────────

const MEMORY_FILE = "memory/my-api#42.json";

function cleanMemory() {
  if (existsSync(MEMORY_FILE)) rmSync(MEMORY_FILE);
}

/** Default canned fetch responses for GitHubClientImpl's Octokit calls. */
function makeFetchSpy(
  overrides: {
    getPrDiffResponse?: Response;
    listIssueCommentsResponse?: Response;
    listReviewCommentsResponse?: Response;
    createCommentResponse?: Response | null;
    getPullReviewCommentResponse?: Response;
  } = {},
) {
  const impl = async (...args: Parameters<typeof fetch>) => {
    const [input, init] = args;
    const url =
      typeof input === "string"
        ? input
        : input instanceof URL
          ? input.href
          : (input as Request).url;
    const method =
      typeof input === "string"
        ? ((init as RequestInit | undefined)?.method ?? "GET")
        : input instanceof Request
          ? input.method
          : "GET";

    // GET /repos/.../pulls/{prNumber} → getPrDiff
    if (
      url.includes("/pulls/") &&
      !url.includes("/comments") &&
      !url.includes("/reviews") &&
      !url.includes("/review_comment")
    ) {
      return (
        overrides.getPrDiffResponse ?? jsonResponse("diff --git a/src/foo.ts b/src/foo.ts", 200)
      );
    }

    // POST /repos/.../pulls/{prNumber}/comments/{commentId}/replies → createReviewCommentReply
    if (url.includes("/comments/") && url.includes("/replies")) {
      return jsonResponse({ id: 2, html_url: "https://github.com/org/repo#review-2" }, 201);
    }

    // GET /repos/.../pulls/comments/{commentId} → getPullReviewComment (must come before /pulls/.../comments)
    if (url.match(/\/pulls\/comments\/\d+$/)) {
      return (
        overrides.getPullReviewCommentResponse ??
        jsonResponse({
          id: 99,
          user: { login: "dev" },
          body: "parent comment",
          path: "src/foo.ts",
          line: 1,
          created_at: "2026-05-01T10:00:00Z",
          in_reply_to_id: null,
          pull_request_review_id: null,
          commit_id: "abc123",
        })
      );
    }

    // GET /repos/.../pulls/{prNumber}/comments → listPullReviewComments
    if (url.includes("/pulls/") && url.includes("/comments")) {
      return overrides.listReviewCommentsResponse ?? jsonResponse([]);
    }

    // GET /repos/.../issues/{prNumber}/comments → listIssueComments or getCommentBodyHtml
    if (url.includes("/issues/") && url.includes("/comments") && method !== "POST") {
      return overrides.listIssueCommentsResponse ?? jsonResponse([]);
    }

    // POST /repos/.../issues/{number}/comments → createIssueComment
    if (url.includes("/issues/") && url.includes("/comments") && method === "POST") {
      if (overrides.createCommentResponse === null) throw new Error("GitHub API error");
      return (
        overrides.createCommentResponse ??
        jsonResponse({ id: 1, html_url: "https://github.com/org/repo/issues/42#comment-1" }, 201)
      );
    }

    // GET /repos/{owner}/{repo} → repo info (used by GitHubClientImpl constructor)
    if (url.includes("api.github.com/repos/")) {
      return jsonResponse({ full_name: "org/my-api", default_branch: "main" });
    }

    return jsonResponse({});
  };
  return spyOn(globalThis, "fetch").mockImplementation(impl as typeof fetch);
}

function makePipeline(depsOverrides: Parameters<typeof createMentionPipelineDeps>[0] = {}) {
  const deps = createMentionPipelineDeps(depsOverrides);
  currentMockManager = deps.mockManager;
  const metrics = new FakeMetrics();

  const pipeline = new PrMentionPipeline({
    agentCli: deps.agentCli as unknown as ConstructorParameters<
      typeof PrMentionPipeline
    >[0]["agentCli"],
    repoRegistry: deps.repoRegistry as unknown as ConstructorParameters<
      typeof PrMentionPipeline
    >[0]["repoRegistry"],
    slackNotifier: deps.slackNotifier as unknown as ConstructorParameters<
      typeof PrMentionPipeline
    >[0]["slackNotifier"],
    getGitHubToken: async () => "ghs_token",
    workdirPath: "/tmp/workdir",
    getPendingCount: () => 0,
    logger: mockLogger(),
    metrics,
  });
  return { pipeline, deps, metrics };
}

// ── Tests ─────────────────────────────────────────────────────────────────────

describe("PrMentionPipeline.execute()", () => {
  let fetchSpy: ReturnType<typeof spyOn>;

  beforeEach(() => {
    cleanMemory();
    fetchSpy = makeFetchSpy();
  });

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

  // 1. Repo not found
  test("returns completed with abort reason when repo not found", async () => {
    const { pipeline } = makePipeline({ repoFound: false });

    const result = await pipeline.execute(buildMentionJob({ repoName: "unknown-repo" }));

    expect(result.success).toBe(false);
    expect(result.outcome).toBe("completed");
    expect(result.abortReason).toMatch(/Repo not found/);
  });

  // 2. Happy path — no code changes, issue comment
  test("happy path: no code changes → posts reply via createIssueComment", async () => {
    const { pipeline } = makePipeline({ hasChanges: false });

    const result = await pipeline.execute(buildMentionJob({ isReviewComment: false }));

    expect(result.success).toBe(true);
    expect(result.outcome).toBe("completed");

    // createIssueComment should have been called (POST to /issues/{n}/comments)
    const postCalls = fetchSpy.mock.calls.filter((c: unknown[]) => {
      const url = typeof c[0] === "string" ? c[0] : ((c[0] as Request).url ?? "");
      const reqMethod =
        typeof c[0] === "string"
          ? ((c[1] as RequestInit)?.method ?? "GET")
          : (c[0] as Request).method;
      return url.includes("/issues/") && url.includes("/comments") && reqMethod === "POST";
    });
    expect(postCalls.length).toBe(1);

    // createReviewCommentReply should NOT have been called
    const replyCalls = fetchSpy.mock.calls.filter((c: unknown[]) => {
      const url = typeof c[0] === "string" ? c[0] : ((c[0] as Request).url ?? "");
      return url.includes("/replies");
    });
    expect(replyCalls.length).toBe(0);
  });

  // 3. Review comment uses createReviewCommentReply
  test("review comment: uses createReviewCommentReply instead of createIssueComment", async () => {
    const { pipeline } = makePipeline({ hasChanges: false });

    const result = await pipeline.execute(
      buildMentionJob({ isReviewComment: true, inReplyToId: null }),
    );

    expect(result.success).toBe(true);

    // createReviewCommentReply should have been called (POST to /comments/{id}/replies)
    const replyCalls = fetchSpy.mock.calls.filter((c: unknown[]) => {
      const url = typeof c[0] === "string" ? c[0] : ((c[0] as Request).url ?? "");
      return url.includes("/replies");
    });
    expect(replyCalls.length).toBeGreaterThanOrEqual(1);

    // createIssueComment should NOT have been called
    const issuePosts = fetchSpy.mock.calls.filter((c: unknown[]) => {
      const url = typeof c[0] === "string" ? c[0] : ((c[0] as Request).url ?? "");
      const reqMethod =
        typeof c[0] === "string"
          ? ((c[1] as RequestInit)?.method ?? "GET")
          : (c[0] as Request).method;
      return url.includes("/issues/") && url.includes("/comments") && reqMethod === "POST";
    });
    expect(issuePosts.length).toBe(0);
  });

  // 4. Code changes with safety passed → commits and pushes
  test("code changes with safety passed: commits, pushes, then posts reply", async () => {
    const { pipeline, deps } = makePipeline({ hasChanges: true, safetyPassed: true });

    const result = await pipeline.execute(buildMentionJob());

    expect(result.success).toBe(true);
    expect((deps.mockManager.commitAndPush as ReturnType<typeof mock>).mock.calls.length).toBe(1);

    // Should post a reply comment
    const postCalls = fetchSpy.mock.calls.filter((c: unknown[]) => {
      const url = typeof c[0] === "string" ? c[0] : ((c[0] as Request).url ?? "");
      const reqMethod =
        typeof c[0] === "string"
          ? ((c[1] as RequestInit)?.method ?? "GET")
          : (c[0] as Request).method;
      return url.includes("/issues/") && url.includes("/comments") && reqMethod === "POST";
    });
    expect(postCalls.length).toBeGreaterThanOrEqual(1);
  });

  // 5. Safety violation: no push, reply includes violation prefix
  test("safety violation: no push, reply includes safety limits mention", async () => {
    const { pipeline, deps } = makePipeline({ hasChanges: true, safetyPassed: false });

    await pipeline.execute(buildMentionJob());

    expect((deps.mockManager.commitAndPush as ReturnType<typeof mock>).mock.calls.length).toBe(0);

    // The reply body should include the safety violation message
    const postCalls = fetchSpy.mock.calls.filter((c: unknown[]) => {
      const url = typeof c[0] === "string" ? c[0] : ((c[0] as Request).url ?? "");
      const reqMethod =
        typeof c[0] === "string"
          ? ((c[1] as RequestInit)?.method ?? "GET")
          : (c[0] as Request).method;
      return url.includes("/issues/") && url.includes("/comments") && reqMethod === "POST";
    });
    expect(postCalls.length).toBe(1);
    // Check request body contains safety limits mention
    const body = JSON.parse(((postCalls[0]?.[1] as RequestInit)?.body as string) ?? "{}");
    expect((body as { body: string }).body).toContain("safety limits");
  });

  // 6. Push failure: reply includes push-failure prefix
  test("push failure: reply includes push-failure prefix, still posts reply", async () => {
    const { pipeline } = makePipeline({
      hasChanges: true,
      safetyPassed: true,
      pushError: new Error("push rejected"),
    });

    await pipeline.execute(buildMentionJob());

    const postCalls = fetchSpy.mock.calls.filter((c: unknown[]) => {
      const url = typeof c[0] === "string" ? c[0] : ((c[0] as Request).url ?? "");
      const reqMethod =
        typeof c[0] === "string"
          ? ((c[1] as RequestInit)?.method ?? "GET")
          : (c[0] as Request).method;
      return url.includes("/issues/") && url.includes("/comments") && reqMethod === "POST";
    });
    expect(postCalls.length).toBe(1);
    const body = JSON.parse(((postCalls[0]?.[1] as RequestInit)?.body as string) ?? "{}");
    expect((body as { body: string }).body).toContain("unable to push");
  });

  // 7. Agent produces empty output
  test("returns completed with abort reason when agent produces empty output", async () => {
    const { pipeline } = makePipeline({ agentOutput: "" });

    const result = await pipeline.execute(buildMentionJob());

    expect(result.success).toBe(false);
    expect(result.outcome).toBe("completed");
    expect(result.abortReason).toMatch(/no reply text/i);
  });

  // 8. Memory appended after reply (real pr-memory store writes file)
  test("appends PR memory after successful agent reply", async () => {
    const { pipeline } = makePipeline();

    await pipeline.execute(buildMentionJob());

    // The real pr-memory store should have written the memory file
    expect(existsSync(MEMORY_FILE)).toBe(true);
  });

  // 9. Install failure is non-blocking
  test("install failure is non-blocking: completes successfully", async () => {
    const { pipeline } = makePipeline();
    currentMockManager.installDependencies = mock(async () => {
      throw new Error("npm install failed");
    });

    const result = await pipeline.execute(buildMentionJob());

    expect(result.success).toBe(true);
  });

  // 10. Slack notification sent
  test("sends Slack notification after successful reply", async () => {
    const { pipeline, deps } = makePipeline();

    await pipeline.execute(buildMentionJob({ prUrl: "https://github.com/org/my-api/pull/42" }));

    expect(
      (deps.slackNotifier.notifyCommentAttended as ReturnType<typeof mock>).mock.calls.length,
    ).toBe(1);
  });

  // 11. cleanStaleBranches called
  test("calls cleanStaleBranches after completing", async () => {
    const { pipeline, deps } = makePipeline();

    await pipeline.execute(buildMentionJob());

    expect((deps.mockManager.cleanStaleBranches as ReturnType<typeof mock>).mock.calls.length).toBe(
      1,
    );
  });

  // 12. Unexpected exception re-throws for queue retry
  test("re-throws on unexpected error for queue retry", async () => {
    const { pipeline } = makePipeline();
    currentMockManager.ensureCloned = mock(async () => {
      throw new Error("git clone failed");
    });

    await expect(pipeline.execute(buildMentionJob())).rejects.toThrow("git clone failed");
  });

  // 13. inReplyToId: fetches parent thread comment for review reply
  test("review comment reply: fetches parent thread comment", async () => {
    const { pipeline } = makePipeline({ hasChanges: false });

    const result = await pipeline.execute(
      buildMentionJob({ isReviewComment: true, inReplyToId: 77 }),
    );

    // The getPullReviewComment endpoint should have been fetched
    const reviewCommentFetches = fetchSpy.mock.calls.filter((c: unknown[]) => {
      const url = typeof c[0] === "string" ? c[0] : ((c[0] as Request).url ?? "");
      return url.match(/\/pulls\/comments\/\d+$/);
    });
    expect(reviewCommentFetches.length).toBeGreaterThanOrEqual(1);
    expect(result.success).toBe(true);
  });

  // 14. getPrDiff failure is non-blocking
  test("getPrDiff failure is non-blocking: continues without diff", async () => {
    fetchSpy.mockRestore();
    fetchSpy = makeFetchSpy({
      getPrDiffResponse: new Response(JSON.stringify({ message: "Not Found" }), { status: 404 }),
    });

    const { pipeline } = makePipeline();

    const result = await pipeline.execute(buildMentionJob());

    expect(result.success).toBe(true);
  });

  // 15. Memory append failure is non-blocking (simulate by making memory dir read-only - just verify success)
  test("PR memory append failure is non-blocking: completes successfully", async () => {
    // We test this by verifying the pipeline succeeds even if it can't write memory
    // The real store silently catches write errors, so success is guaranteed
    const { pipeline } = makePipeline();

    const result = await pipeline.execute(buildMentionJob());

    expect(result.success).toBe(true);
  });

  // 16. Metrics: pr_mention.handled emitted with action tag
  test("emits pr_mention.handled metric with action tag on success", async () => {
    const { pipeline, metrics } = makePipeline({ hasChanges: false });

    await pipeline.execute(buildMentionJob());

    const handledCalls = metrics.calls.filter((c) => c.name === "pr_mention.handled");
    expect(handledCalls.length).toBeGreaterThanOrEqual(1);
    const handledIncrement = handledCalls.find((c) => c.kind === "increment");
    expect(handledIncrement).toBeDefined();
    expect(handledIncrement?.tags?.action).toBe("reply_only");
    expect(handledIncrement?.tags?.repo).toBe("my-api");
  });

  // 16b. Metrics: pr_mention.duration timing emitted (separate name to avoid DD type conflict)
  test("emits pr_mention.duration timing on success", async () => {
    const { pipeline, metrics } = makePipeline({ hasChanges: false });

    await pipeline.execute(buildMentionJob());

    const durationTimings = metrics.calls.filter(
      (c) => c.kind === "timing" && c.name === METRIC.prMentionDuration,
    );
    expect(durationTimings.length).toBe(1);
    expect(durationTimings[0]?.tags?.repo).toBe("my-api");
  });

  // 17. Metrics: agent.invocations emitted with phase:"pr_mention"
  test("emits agent.invocations metric with phase:pr_mention", async () => {
    const { pipeline, metrics } = makePipeline();

    await pipeline.execute(buildMentionJob());

    const hasInvocation = metrics
      .tagsOf("agent.invocations")
      .some((t) => t?.phase === "pr_mention");
    expect(hasInvocation).toBe(true);
  });

  // 18. Metrics: action is code_change when push succeeds
  test("emits pr_mention.handled with action:code_change when push succeeds", async () => {
    const { pipeline, metrics } = makePipeline({ hasChanges: true, safetyPassed: true });

    await pipeline.execute(buildMentionJob());

    const handledIncrement = metrics.calls
      .filter((c) => c.name === "pr_mention.handled" && c.kind === "increment")
      .at(-1);
    expect(handledIncrement?.tags?.action).toBe("code_change");
  });
});
