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 { createPrCommentDeps, buildPrCommentJob } 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 { PrCommentPipeline } from "@/pipeline/pr-comment-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. Prototype spies are scoped to the test and cleaned up by afterEach.

let currentMockManager: ReturnType<typeof createPrCommentDeps>["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;

    // GET /repos/.../pulls/{prNumber} → getPrDiff (octokit returns a diff)
    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)
      );
    }

    // GET /repos/.../issues/{prNumber}/comments → listIssueComments
    if (url.includes("/issues/") && url.includes("/comments")) {
      const method =
        typeof input === "string"
          ? ((init as RequestInit | undefined)?.method ?? "GET")
          : ((input as Request).method ?? "GET");
      if (method !== "POST") {
        return overrides.listIssueCommentsResponse ?? jsonResponse([]);
      }
    }

    // POST /repos/.../issues/{number}/comments → createIssueComment
    if (url.includes("/issues/") && url.includes("/comments")) {
      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)
      );
    }

    // 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 generic /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,
        })
      );
    }

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

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

    // GET /repos/{owner}/{repo}/contents/... → getCommentBodyHtml
    if (url.includes("/comments/") && !url.includes("/replies")) {
      return jsonResponse(null);
    }

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

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

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

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

describe("PrCommentPipeline.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(buildPrCommentJob());

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

  // 2. Happy path — issue comment (no code changes)
  test("happy path: issue comment with no code changes posts reply and returns completed", async () => {
    const { pipeline } = makePipeline();

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

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

    // Verify a POST to the issue comments endpoint was made
    const postCalls = fetchSpy.mock.calls.filter((c: unknown[]) => {
      const req = c[1] as RequestInit | undefined;
      return req?.method === "POST" || (req as Record<string, unknown>)?.method === "POST";
    });
    // At minimum, createIssueComment should have been called
    expect(postCalls.length).toBeGreaterThanOrEqual(1);
  });

  // 3. Review comment path
  test("review comment: calls review comment reply endpoint", async () => {
    const { pipeline } = makePipeline();

    const result = await pipeline.execute(
      buildPrCommentJob({ isReviewComment: true, commentId: 55 }),
    );

    expect(result.success).toBe(true);
    // Verify the replies endpoint was 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).toBeGreaterThanOrEqual(1);
  });

  // 4. Code changes: commits and pushes before replying
  test("when agent produces code changes: commits and pushes before replying", async () => {
    const { pipeline, deps } = makePipeline({ hasChanges: true });

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

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

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

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

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

  // 6. 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(buildPrCommentJob());

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

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

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

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

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

    await pipeline.execute(buildPrCommentJob());

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

  // 9. Exception propagates (so queue can 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(buildPrCommentJob())).rejects.toThrow("git clone failed");
  });

  // 10. push failure re-throws
  test("push failure propagates so queue can retry", async () => {
    const { pipeline } = makePipeline({
      hasChanges: true,
      pushError: new Error("remote rejected"),
    });

    await expect(pipeline.execute(buildPrCommentJob())).rejects.toThrow("remote rejected");
  });

  // 11. Slack notification failure is non-blocking
  test("Slack notification failure is non-blocking", async () => {
    const { pipeline, deps } = makePipeline();
    (deps.slackNotifier.notifyCommentAttended as ReturnType<typeof mock>).mockImplementation(
      async () => {
        throw new Error("Slack down");
      },
    );

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

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

  // 12. inReplyToId: loads parent thread comment (review reply chain)
  test("review comment reply: fetches parent thread comment", async () => {
    const { pipeline } = makePipeline();

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

    // 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);
  });

  // 13. Metrics: emits agent.invocations and pr_comment.handled on happy path
  test("emits agent.invocations{phase:pr_comment} and pr_comment.handled on success", async () => {
    const { pipeline, metrics } = makePipeline();

    await pipeline.execute(buildPrCommentJob());

    // agent.invocations increment emitted
    const invocations = metrics.calls.filter(
      (c) => c.kind === "increment" && c.name === METRIC.agentInvocations,
    );
    expect(invocations.length).toBe(1);
    expect(invocations[0]?.tags).toEqual({ phase: "pr_comment" });

    // agent.duration timing emitted
    const agentTimings = metrics.calls.filter(
      (c) => c.kind === "timing" && c.name === METRIC.agentDuration,
    );
    expect(agentTimings.length).toBe(1);
    expect(agentTimings[0]?.tags).toEqual({ phase: "pr_comment" });

    // pr_comment.handled increment emitted with action tag
    const handledIncrements = metrics.calls.filter(
      (c) => c.kind === "increment" && c.name === METRIC.prCommentHandled,
    );
    expect(handledIncrements.length).toBe(1);
    const handledTags = handledIncrements[0]?.tags ?? {};
    expect(handledTags).toBeDefined();
    expect(String((handledTags as Record<string, string>)["repo"])).toBe("my-api");
    const action = String((handledTags as Record<string, string>)["action"]);
    expect(["reply_only", "code_change"]).toContain(action);

    // pr_comment.duration timing also emitted (separate name to avoid DD type conflict)
    const handledTimings = metrics.calls.filter(
      (c) => c.kind === "timing" && c.name === METRIC.prCommentDuration,
    );
    expect(handledTimings.length).toBe(1);
  });

  // 14. Metrics: action="code_change" when changes were pushed
  test("emits pr_comment.handled with action=code_change when code was pushed", async () => {
    const { pipeline, metrics } = makePipeline({ hasChanges: true });

    await pipeline.execute(buildPrCommentJob());

    const handledIncrement = metrics.calls.find(
      (c) => c.kind === "increment" && c.name === METRIC.prCommentHandled,
    );
    expect(handledIncrement).toBeDefined();
    expect((handledIncrement?.tags as Record<string, string>)["action"]).toBe("code_change");
  });

  // 15. Metrics: action="reply_only" when no code changes
  test("emits pr_comment.handled with action=reply_only when no code changes", async () => {
    const { pipeline, metrics } = makePipeline({ hasChanges: false });

    await pipeline.execute(buildPrCommentJob());

    const handledIncrement = metrics.calls.find(
      (c) => c.kind === "increment" && c.name === METRIC.prCommentHandled,
    );
    expect(handledIncrement).toBeDefined();
    expect((handledIncrement?.tags as Record<string, string>)["action"]).toBe("reply_only");
  });
});
