import { describe, test, expect, mock, beforeEach, afterEach, spyOn } from "bun:test";
import type { Logger } from "pino";
import { mockLogger } from "../_helpers/mock-logger.js";
import { FakeMetrics } from "../_helpers/fake-metrics.js";
import { createSlackMentionDeps, buildSlackMentionJob } from "../_helpers/pipeline-deps.js";
import { RepoManagerImpl } from "@/repo/manager.js";
import * as repoManagerMod from "@/repo/manager.js";
import { SlackMentionPipeline } from "@/pipeline/slack-mention-pipeline.js";
import { SLACK_MENTION } from "@/core/constants.js";
import { METRIC } from "@/metrics/names.js";
import { JobAbortedError } from "@/core/errors.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 ensureClonedSpy: ReturnType<typeof spyOn>;
let checkoutBaseSpy: ReturnType<typeof spyOn>;

beforeEach(() => {
  ensureClonedSpy = spyOn(RepoManagerImpl.prototype, "ensureCloned").mockResolvedValue(
    undefined as never,
  );
  checkoutBaseSpy = spyOn(RepoManagerImpl.prototype, "checkoutBase" as never).mockResolvedValue(
    undefined as never,
  );
  spyOn(repoManagerMod as never, "configureGitAuth").mockResolvedValue(undefined);
  spyOn(repoManagerMod as never, "syncAllRepos").mockResolvedValue([]);
});

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

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

interface ThreadContextOverrides {
  fetchThreadReplies?: ReturnType<typeof mock>;
  resolveUsers?: ReturnType<typeof mock>;
  logger?: Logger;
}

type MakePipelineOverrides = Parameters<typeof createSlackMentionDeps>[0] & ThreadContextOverrides;

function makePipeline(overrides: MakePipelineOverrides = {}) {
  const deps = createSlackMentionDeps(overrides);
  const slackNotifier = deps.slackNotifier as unknown as Record<string, ReturnType<typeof mock>>;
  // Attach the thread-context Slack methods locally to the test (do not pollute the shared helper).
  slackNotifier.fetchThreadReplies =
    overrides.fetchThreadReplies ??
    mock(
      async (
        _channelId: string,
        _threadTs: string,
        _opts?: { includeBots?: boolean },
      ): Promise<Array<{ ts: string; user?: string; text?: string; bot_id?: string }>> => [],
    );
  slackNotifier.resolveUsers =
    overrides.resolveUsers ??
    mock(async (_userIds: readonly string[]): Promise<Map<string, string>> => new Map());

  const logger = overrides.logger ?? mockLogger();
  const metrics = new FakeMetrics();

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

/** Resolve the prompt string the cursor agent was called with. */
function getAgentPrompt(deps: { agentCli: { runAgent: ReturnType<typeof mock> } }): string {
  const calls = (deps.agentCli.runAgent as ReturnType<typeof mock>).mock.calls;
  expect(calls.length).toBeGreaterThan(0);
  return calls[0]?.[0] as string;
}

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

describe("SlackMentionPipeline.execute()", () => {
  test("happy path: returns completed and replies in thread", async () => {
    const { pipeline, deps } = makePipeline();
    const job = buildSlackMentionJob();

    const result = await pipeline.execute(job);

    expect(result.success).toBe(true);
    expect(result.outcome).toBe("completed");
    expect(result.issueKey).toBe(`slack:${job.channelId}:${job.messageTs}`);
    expect((deps.slackNotifier.replyInThread as ReturnType<typeof mock>).mock.calls.length).toBe(1);
  });

  test("cursor CLI error: replies with error message and returns completed", async () => {
    const { pipeline, deps } = makePipeline({
      agentError: new Error("cursor CLI timed out"),
    });

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

    expect(result.success).toBe(false);
    expect(result.outcome).toBe("completed");
    expect(result.abortReason).toBe("cursor CLI timed out");
    // should still attempt a fallback error reply
    expect((deps.slackNotifier.replyInThread as ReturnType<typeof mock>).mock.calls.length).toBe(1);
  });

  test("does not reply when aborted for shutdown", async () => {
    const { pipeline, deps } = makePipeline({
      agentError: new JobAbortedError("aborted for shutdown"),
    });

    const ac = new AbortController();
    ac.abort();
    const result = await pipeline.execute(buildSlackMentionJob(), ac.signal);

    expect(result.abortReason).toBe("aborted_for_shutdown");
    expect((deps.slackNotifier.replyInThread as ReturnType<typeof mock>).mock.calls.length).toBe(0);
  });

  test("adds white_check_mark and removes eyes after a successful reply", async () => {
    const { pipeline, deps } = makePipeline();
    const job = buildSlackMentionJob();

    await pipeline.execute(job);

    expect(deps.slackNotifier.addReaction as ReturnType<typeof mock>).toHaveBeenCalledWith(
      job.channelId,
      job.messageTs,
      SLACK_MENTION.REACTION_DONE,
    );
    expect(deps.slackNotifier.removeReaction as ReturnType<typeof mock>).toHaveBeenCalledWith(
      job.channelId,
      job.messageTs,
      SLACK_MENTION.REACTION_CLAIMED,
    );
  });

  test("adds x when the agent genuinely fails", async () => {
    const { pipeline, deps } = makePipeline({ agentError: new Error("cursor CLI timed out") });
    const job = buildSlackMentionJob();

    await pipeline.execute(job);

    expect(deps.slackNotifier.addReaction as ReturnType<typeof mock>).toHaveBeenCalledWith(
      job.channelId,
      job.messageTs,
      SLACK_MENTION.REACTION_FAILED,
    );
  });

  test("does not add x when aborted for shutdown", async () => {
    const { pipeline, deps } = makePipeline({
      agentError: new JobAbortedError("aborted for shutdown"),
    });
    const ac = new AbortController();
    ac.abort();

    await pipeline.execute(buildSlackMentionJob(), ac.signal);

    const addReactionCalls = (deps.slackNotifier.addReaction as ReturnType<typeof mock>).mock.calls;
    const addedFailed = addReactionCalls.some((c) => c[2] === SLACK_MENTION.REACTION_FAILED);
    expect(addedFailed).toBe(false);
  });

  test("cursor CLI error with Spanish language: replies in Spanish", async () => {
    const { pipeline, deps } = makePipeline({
      agentError: new Error("timeout"),
    });

    await pipeline.execute(buildSlackMentionJob({ language: "es" }));

    const replyArg = (deps.slackNotifier.replyInThread as ReturnType<typeof mock>).mock
      .calls[0]?.[2] as string;
    expect(replyArg).toContain("Lo siento");
  });

  test("cursor CLI error with English language: replies in English", async () => {
    const { pipeline, deps } = makePipeline({
      agentError: new Error("timeout"),
    });

    await pipeline.execute(buildSlackMentionJob({ language: "en" }));

    const replyArg = (deps.slackNotifier.replyInThread as ReturnType<typeof mock>).mock
      .calls[0]?.[2] as string;
    expect(replyArg).toContain("Sorry");
  });

  test("with repos: calls ensureCloned and checkoutBase for each repo", async () => {
    const { pipeline } = makePipeline({
      repos: [
        { name: "my-api", url: "https://github.com/org/my-api", defaultBranch: "main" },
        {
          name: "my-frontend",
          url: "https://github.com/org/my-frontend",
          defaultBranch: "develop",
        },
      ],
    });

    await pipeline.execute(buildSlackMentionJob());

    expect(ensureClonedSpy.mock.calls.length).toBe(2);
    expect(checkoutBaseSpy.mock.calls.length).toBe(2);
  });

  test("message with Jira ticket key: fetches the Jira issue", async () => {
    const { pipeline, deps } = makePipeline();

    await pipeline.execute(buildSlackMentionJob({ text: "What is the status of SQDP-123?" }));

    expect((deps.jiraClient.getIssue as ReturnType<typeof mock>).mock.calls.length).toBe(1);
    const calledWith = (deps.jiraClient.getIssue as ReturnType<typeof mock>).mock
      .calls[0]?.[0] as string;
    expect(calledWith).toBe("SQDP-123");
  });

  test("message with no Jira ticket: does not fetch Jira issues", async () => {
    const { pipeline, deps } = makePipeline();

    await pipeline.execute(buildSlackMentionJob({ text: "Hey @hu-agent how do I deploy?" }));

    expect((deps.jiraClient.getIssue as ReturnType<typeof mock>).mock.calls.length).toBe(0);
  });

  test("reply error is swallowed and outcome is still completed", async () => {
    const { pipeline } = makePipeline({
      agentError: new Error("agent error"),
      replyError: new Error("Slack API down"),
    });

    // Should not throw even when the fallback reply also fails
    const result = await pipeline.execute(buildSlackMentionJob());
    expect(result.success).toBe(false);
    expect(result.outcome).toBe("completed");
  });

  // ── Thread context (sub-plan 4 / D) ────────────────────────────────────────

  test("top-level mention (messageTs === threadTs) does not call fetchThreadReplies for context", async () => {
    const { pipeline, deps } = makePipeline();
    const sameTs = "1700000000.000500";
    const job = buildSlackMentionJob({ threadTs: sameTs, messageTs: sameTs });

    await pipeline.execute(job);

    const slack = deps.slackNotifier as unknown as { fetchThreadReplies: ReturnType<typeof mock> };
    expect(slack.fetchThreadReplies.mock.calls.length).toBe(0);

    const prompt = getAgentPrompt(deps);
    expect(prompt).not.toContain("## Thread Context");
  });

  test("reply mention loads thread context with includeBots=true", async () => {
    const fetchThreadReplies = mock(
      async (
        _channelId: string,
        _threadTs: string,
        _opts?: { includeBots?: boolean },
      ): Promise<Array<{ ts: string; user?: string; text?: string; bot_id?: string }>> => [
        { ts: "1700000000.000100", user: "U001", text: "What is X?" },
        { ts: "1700000000.000150", user: "U002", text: "I think X is Y" },
        { ts: "1700000000.000180", text: "Bot already replied earlier", bot_id: "B777" },
      ],
    );
    const resolveUsers = mock(
      async (_ids: readonly string[]): Promise<Map<string, string>> =>
        new Map([
          ["U001", "alice"],
          ["U002", "sebas"],
        ]),
    );
    const { pipeline, deps } = makePipeline({ fetchThreadReplies, resolveUsers });
    const job = buildSlackMentionJob({
      threadTs: "1700000000.000100",
      messageTs: "1700000000.000999",
    });

    await pipeline.execute(job);

    expect(fetchThreadReplies.mock.calls.length).toBe(1);
    const firstCall = fetchThreadReplies.mock.calls[0] as [
      string,
      string,
      { includeBots?: boolean } | undefined,
    ];
    expect(firstCall[0]).toBe(job.channelId);
    expect(firstCall[1]).toBe(job.threadTs);
    expect(firstCall[2]).toEqual({ includeBots: true });

    const prompt = getAgentPrompt(deps);
    expect(prompt).toContain("## Thread Context");
    expect(prompt).toContain("alice");
    expect(prompt).toContain("sebas");
    expect(prompt).toContain("[hu-agent]");
    // Thread Context block must appear before ## Question.
    expect(prompt.indexOf("## Thread Context")).toBeLessThan(prompt.indexOf("\n## Question\n"));
  });

  test("excludes the triggering message from the context", async () => {
    const triggerTs = "1700000000.000999";
    const fetchThreadReplies = mock(
      async (): Promise<Array<{ ts: string; user?: string; text?: string; bot_id?: string }>> => [
        { ts: "1700000000.000100", user: "U001", text: "Original question" },
        { ts: "1700000000.000200", user: "U002", text: "Earlier follow-up" },
        { ts: triggerTs, user: "U001", text: "TRIGGERING_MENTION_TEXT_SHOULD_NOT_APPEAR" },
      ],
    );
    const { pipeline, deps } = makePipeline({ fetchThreadReplies });
    const job = buildSlackMentionJob({
      threadTs: "1700000000.000100",
      messageTs: triggerTs,
      text: "TRIGGERING_MENTION_TEXT_SHOULD_NOT_APPEAR",
    });

    await pipeline.execute(job);

    const prompt = getAgentPrompt(deps);
    const ctxStart = prompt.indexOf("## Thread Context");
    const questionStart = prompt.indexOf("\n## Question\n");
    expect(ctxStart).toBeGreaterThan(-1);
    const contextSlice = prompt.slice(ctxStart, questionStart);
    // The triggering text should NOT appear inside the Thread Context block;
    // it appears later inside the ## Question block.
    expect(contextSlice).not.toContain("TRIGGERING_MENTION_TEXT_SHOULD_NOT_APPEAR");
    expect(contextSlice).toContain("Original question");
    expect(contextSlice).toContain("Earlier follow-up");
  });

  test("bot replies render with author '[hu-agent]'", async () => {
    const fetchThreadReplies = mock(
      async (): Promise<Array<{ ts: string; user?: string; text?: string; bot_id?: string }>> => [
        { ts: "1700000000.000100", user: "U001", text: "Hi bot" },
        { ts: "1700000000.000200", text: "I am the bot replying", bot_id: "B999" },
      ],
    );
    const resolveUsers = mock(
      async (): Promise<Map<string, string>> => new Map([["U001", "human"]]),
    );
    const { pipeline, deps } = makePipeline({ fetchThreadReplies, resolveUsers });
    const job = buildSlackMentionJob({
      threadTs: "1700000000.000100",
      messageTs: "1700000000.000999",
    });

    await pipeline.execute(job);

    const prompt = getAgentPrompt(deps);
    expect(prompt).toContain("[hu-agent]");
    expect(prompt).toContain("I am the bot replying");
  });

  test("non-bot users get their resolved display name", async () => {
    const fetchThreadReplies = mock(
      async (): Promise<Array<{ ts: string; user?: string; text?: string; bot_id?: string }>> => [
        { ts: "1700000000.000100", user: "U001", text: "Hello world" },
      ],
    );
    const resolveUsers = mock(
      async (): Promise<Map<string, string>> => new Map([["U001", "sebas"]]),
    );
    const { pipeline, deps } = makePipeline({ fetchThreadReplies, resolveUsers });
    const job = buildSlackMentionJob({
      threadTs: "1700000000.000100",
      messageTs: "1700000000.000999",
    });

    await pipeline.execute(job);

    const prompt = getAgentPrompt(deps);
    const ctx = prompt.slice(
      prompt.indexOf("## Thread Context"),
      prompt.indexOf("\n## Question\n"),
    );
    expect(ctx).toContain("sebas");
    expect(ctx).toContain("Hello world");
  });

  test("falls back to the raw user id when resolveUsers does not have a name", async () => {
    const fetchThreadReplies = mock(
      async (): Promise<Array<{ ts: string; user?: string; text?: string; bot_id?: string }>> => [
        { ts: "1700000000.000100", user: "U777", text: "Anonymous reply" },
      ],
    );
    const resolveUsers = mock(async (): Promise<Map<string, string>> => new Map());
    const { pipeline, deps } = makePipeline({ fetchThreadReplies, resolveUsers });
    const job = buildSlackMentionJob({
      threadTs: "1700000000.000100",
      messageTs: "1700000000.000999",
    });

    await pipeline.execute(job);

    const prompt = getAgentPrompt(deps);
    const ctx = prompt.slice(
      prompt.indexOf("## Thread Context"),
      prompt.indexOf("\n## Question\n"),
    );
    expect(ctx).toContain("U777");
    expect(ctx).toContain("Anonymous reply");
  });

  test("graceful fallback: when fetchThreadReplies throws, pipeline still completes", async () => {
    const fetchThreadReplies = mock(async (): Promise<never> => {
      throw new Error("Slack rate-limited");
    });
    const logger = mockLogger();
    const { pipeline, deps } = makePipeline({ fetchThreadReplies, logger });
    const job = buildSlackMentionJob({
      threadTs: "1700000000.000100",
      messageTs: "1700000000.000999",
    });

    const result = await pipeline.execute(job);

    expect(result.success).toBe(true);
    expect(result.outcome).toBe("completed");
    // Cursor agent still ran and reply was sent.
    expect((deps.agentCli.runAgent as ReturnType<typeof mock>).mock.calls.length).toBe(1);
    expect((deps.slackNotifier.replyInThread as ReturnType<typeof mock>).mock.calls.length).toBe(1);

    // Prompt should NOT contain the Thread Context block (graceful fallback returns undefined).
    const prompt = getAgentPrompt(deps);
    expect(prompt).not.toContain("## Thread Context");

    // A warn log was emitted with the dedicated tag. The pipeline uses the child logger,
    // and our mockLogger.child() returns the same instance, so warn lands on `logger.warn`.
    const warnMock = logger.warn as unknown as ReturnType<typeof mock>;
    const warnCalls = warnMock.mock.calls.map((c) => c[1]);
    expect(warnCalls).toContain("slack_mention_thread_context_failed");
  });

  // ── Metrics ────────────────────────────────────────────────────────────────

  test("emits agent.invocations{phase:slack_mention} and slack_mention.handled on success", async () => {
    const { pipeline, metrics } = makePipeline();

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

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

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

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

    // slack_mention.handled increment — no tags (cardinality guardrail)
    const handledIncrements = metrics.calls.filter(
      (c) => c.kind === "increment" && c.name === METRIC.slackMentionHandled,
    );
    expect(handledIncrements.length).toBe(1);
    expect(handledIncrements[0]?.tags).toBeUndefined();

    // slack_mention.duration timing — no tags (separate name to avoid DD type conflict)
    const handledTimings = metrics.calls.filter(
      (c) => c.kind === "timing" && c.name === METRIC.slackMentionDuration,
    );
    expect(handledTimings.length).toBe(1);
    expect(handledTimings[0]?.tags).toBeUndefined();
  });

  test("does NOT emit slack_mention.handled when agent throws", async () => {
    const { pipeline, metrics } = makePipeline({
      agentError: new Error("cursor CLI timed out"),
    });

    await pipeline.execute(buildSlackMentionJob());

    const handledCalls = metrics.calls.filter((c) => c.name === METRIC.slackMentionHandled);
    expect(handledCalls.length).toBe(0);
  });

  test("truncates oldest non-parent entries when over THREAD_CONTEXT_MAX_CHARS", async () => {
    // Build a parent + many filler middle messages whose combined size blows past the cap,
    // plus a clearly-marked recent message we expect to keep.
    const filler = "x".repeat(1000);
    const parentText = "PARENT_MESSAGE_KEEPME";
    const recentText = "RECENT_MESSAGE_KEEPME";
    const messages: Array<{ ts: string; user?: string; text?: string; bot_id?: string }> = [
      { ts: "1700000000.000001", user: "U001", text: parentText },
    ];
    // Add enough filler entries to exceed THREAD_CONTEXT_MAX_CHARS (6000).
    for (let i = 0; i < 10; i++) {
      messages.push({
        ts: `1700000000.0001${String(i).padStart(2, "0")}`,
        user: "U002",
        text: `${filler}_${i}`,
      });
    }
    messages.push({ ts: "1700000000.000900", user: "U003", text: recentText });

    const fetchThreadReplies = mock(async () => messages);
    const resolveUsers = mock(
      async (): Promise<Map<string, string>> =>
        new Map([
          ["U001", "alice"],
          ["U002", "bob"],
          ["U003", "carol"],
        ]),
    );
    const { pipeline, deps } = makePipeline({ fetchThreadReplies, resolveUsers });
    const job = buildSlackMentionJob({
      threadTs: "1700000000.000001",
      messageTs: "1700000000.000999",
    });

    await pipeline.execute(job);

    const prompt = getAgentPrompt(deps);
    const ctxStart = prompt.indexOf("## Thread Context");
    const questionStart = prompt.indexOf("\n## Question\n");
    expect(ctxStart).toBeGreaterThan(-1);
    const ctx = prompt.slice(ctxStart, questionStart);

    // Parent is preserved.
    expect(ctx).toContain(parentText);
    // Recent message is preserved.
    expect(ctx).toContain(recentText);
    // Some of the oldest fillers are dropped — at least one should be missing.
    const fillerCount = (ctx.match(new RegExp(filler, "g")) ?? []).length;
    expect(fillerCount).toBeLessThan(10);
    // Sanity: rendered context byte size stays within the configured cap (allow a small
    // wrapper overhead — we check the entries portion is bounded).
    expect(ctx.length).toBeLessThan(SLACK_MENTION.THREAD_CONTEXT_MAX_CHARS + 2000);
  });
});
