import { describe, test, expect, mock, beforeEach, afterEach, spyOn } from "bun:test";
import { mockLogger } from "../_helpers/mock-logger.js";
import { jsonResponse } from "../_helpers/fake-fetch.js";
import { FakeMetrics } from "../_helpers/fake-metrics.js";
import { METRIC } from "@/metrics/names.js";
import type { JiraJobPayload, RepoHandle } from "@/types/index.js";
import * as repoManagerMod from "@/repo/manager.js";
import * as rulesSyncMod from "@/repo/rules-sync.js";
import { FixPipeline } from "@/pipeline/fix-pipeline.js";
import { GitHubClientImpl } from "@/github/client.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 syncAllReposSpy: ReturnType<typeof spyOn>;
let syncRemoteRulesReposSpy: ReturnType<typeof spyOn>;

beforeEach(() => {
  syncAllReposSpy = spyOn(repoManagerMod as never, "syncAllRepos").mockResolvedValue([]);
  spyOn(repoManagerMod as never, "configureGitAuth").mockResolvedValue(undefined);
  syncRemoteRulesReposSpy = spyOn(rulesSyncMod as never, "syncRemoteRulesRepos").mockResolvedValue(
    undefined,
  );
});

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

// ── Shared fixtures ───────────────────────────────────────────────────────────

function buildJob(overrides: Partial<JiraJobPayload> = {}): JiraJobPayload {
  return {
    jobKind: "jira",
    issueId: "1",
    issueKey: "SQDP-1",
    summary: "Fix the login bug",
    description: "Users cannot log in",
    ...overrides,
  };
}

function makeIssue(overrides: Record<string, unknown> = {}) {
  return {
    id: "1",
    key: "SQDP-1",
    summary: "Fix the login bug",
    description: "Users cannot log in",
    issueType: "Bug",
    status: "In Progress",
    created: null,
    acceptanceCriteria: null,
    stacktrace: null,
    flow: null,
    assignee: null,
    parent: null,
    subtasks: [],
    children: [],
    attachments: [],
    comments: [],
    components: [],
    ...overrides,
  };
}

function makeMockManager(
  opts: {
    hasChanges?: boolean;
    validateBuildSuccess?: boolean;
    hasCommitsAhead?: boolean;
    /**
     * Sequence of fingerprints returned by successive workingTreeFingerprint()
     * calls. Defaults are derived from `hasChanges`:
     *  - hasChanges: false → ["clean", "clean"]   (snapshot before/after match)
     *  - hasChanges: true  → ["clean", "edited"]  (snapshot differs → repo entered changedHandles)
     */
    fingerprints?: string[];
  } = {},
) {
  const fingerprints =
    opts.fingerprints ?? (opts.hasChanges ? ["clean", "edited"] : ["clean", "clean"]);
  let fpIdx = 0;
  return {
    getLocalPath: mock(() => "/tmp/workdir/my-api"),
    getChangedFiles: mock(async () => (opts.hasChanges ? ["src/foo.ts"] : [])),
    hasChanges: mock(async () => opts.hasChanges ?? false),
    hasCommitsAhead: mock(async () => opts.hasCommitsAhead ?? false),
    resetToBase: mock(async () => {}),
    workingTreeFingerprint: mock(async () => {
      const value = fingerprints[fpIdx] ?? fingerprints[fingerprints.length - 1] ?? "clean";
      fpIdx++;
      return value;
    }),
    getDiffStats: mock(async () => ({
      passed: true,
      violations: [],
      totalLinesChanged: 10,
      filesChanged: 1,
      filesDeleted: 0,
    })),
    validateBuild: mock(async () => ({
      success: opts.validateBuildSuccess ?? true,
      testOutput: "",
      installOutput: "",
      checkOutputs: {},
      failedStep: undefined,
    })),
    commitAndPush: mock(async () => {}),
    createBranch: mock(async () => {}),
    cleanStaleBranches: mock(async () => {}),
  };
}

function makeRepoHandle(
  repoName = "my-api",
  managerOpts: Parameters<typeof makeMockManager>[0] = {},
) {
  const manager = makeMockManager(managerOpts);
  return {
    repo: {
      name: repoName,
      url: "https://github.com/org/my-api",
      defaultBranch: "main",
      description: "My API",
      keywords: ["api"],
    },
    manager,
  } as unknown as RepoHandle & { manager: ReturnType<typeof makeMockManager> };
}

function makePipeline(
  deps: Partial<{
    jiraClient: Record<string, ReturnType<typeof mock>>;
    agentCli: Record<string, ReturnType<typeof mock>>;
    repoRegistry: Record<string, ReturnType<typeof mock>>;
    slackNotifier: Record<string, ReturnType<typeof mock>>;
    metrics: FakeMetrics;
  }> = {},
  config: Partial<{
    videoAttachmentsEnabled: boolean;
    ffmpegPath: string;
    ffprobePath: string;
  }> = {},
) {
  const defaultJiraClient = {
    getIssue: mock(async () => makeIssue()),
    addComment: mock(async () => {}),
    transitionIssue: mock(async () => {}),
    createSubtask: mock(async () => ({ id: "2", key: "SQDP-2" })),
    getLastStatusChangeAuthor: mock(async () => null),
    resolveAccountIdForBotEmail: mock(async () => null),
    downloadAttachment: mock(async () => Buffer.from("")),
    getIssueComponents: mock(async () => []),
  };

  const defaultAgentCli = {
    runAgent: mock(async () => ({
      output: "VERDICT:PROCEED\nSUMMARY_START\nFixed the bug.\nSUMMARY_END",
      exitCode: 0,
      success: true,
    })),
  };

  const defaultRepoRegistry = {
    getAll: mock(() => [
      {
        name: "my-api",
        url: "https://github.com/org/my-api",
        defaultBranch: "main",
        description: "",
        keywords: [],
      },
    ]),
    getByName: mock(() => undefined),
  };

  const metrics = deps.metrics ?? new FakeMetrics();

  const pipeline = new FixPipeline({
    jiraClient: (deps.jiraClient ?? defaultJiraClient) as unknown as ConstructorParameters<
      typeof FixPipeline
    >[0]["jiraClient"],
    agentCli: (deps.agentCli ?? defaultAgentCli) as unknown as ConstructorParameters<
      typeof FixPipeline
    >[0]["agentCli"],
    repoRegistry: (deps.repoRegistry ?? defaultRepoRegistry) as unknown as ConstructorParameters<
      typeof FixPipeline
    >[0]["repoRegistry"],
    slackNotifier: deps.slackNotifier as unknown as ConstructorParameters<
      typeof FixPipeline
    >[0]["slackNotifier"],
    getGitHubToken: async () => "ghs_token",
    workdirPath: "/tmp/workdir",
    jiraDomain: "test-company",
    botEmail: "bot@example.com",
    getPendingCount: () => 0,
    logger: mockLogger(),
    buildGitHubClient: (token, repoUrl, ghLogger) =>
      new GitHubClientImpl(token, repoUrl, "test-company", ghLogger),
    videoAttachmentsEnabled: config.videoAttachmentsEnabled ?? false,
    ffmpegPath: config.ffmpegPath ?? "ffmpeg",
    ffprobePath: config.ffprobePath ?? "ffprobe",
    metrics,
  });

  return {
    pipeline,
    jiraClient: deps.jiraClient ?? defaultJiraClient,
    agentCli: deps.agentCli ?? defaultAgentCli,
    metrics,
  };
}

/** Default fetch spy for GitHub API calls made by GitHubClientImpl */
function makeFetchSpy() {
  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";

    // POST /repos/.../pulls → createPr
    if (url.match(/\/repos\/[^/]+\/[^/]+\/pulls$/) && method === "POST") {
      return jsonResponse({ html_url: "https://github.com/org/my-api/pull/1", number: 1 }, 201);
    }

    // GET /repos/.../pulls → listOpenPrs (used by getPrForBranch)
    if (url.match(/\/repos\/[^/]+\/[^/]+\/pulls(\?|$)/)) {
      return jsonResponse([]);
    }

    // GET /repos/.../labels → listRepoLabels
    if (url.match(/\/repos\/[^/]+\/[^/]+\/labels/)) {
      return jsonResponse([]);
    }

    // POST /repos/.../issues/{n}/labels → addLabels
    if (url.match(/\/issues\/\d+\/labels$/) && method === "POST") {
      return jsonResponse([]);
    }

    // GET /repos/{owner}/{repo} → repo info for GitHubClientImpl constructor
    if (url.match(/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);
}

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

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

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

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

  // ── No repos synced ────────────────────────────────────────────────────────

  test("returns completed with abort reason when no repos could be synced", async () => {
    syncAllReposSpy.mockImplementation(async () => []);
    const { pipeline } = makePipeline();

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

    expect(result.success).toBe(false);
    expect(result.outcome).toBe("completed");
    expect(result.abortReason).toMatch(/No repositories could be synced/);
  });

  // ── Triage: PROCEED ─────────────────────────────────────────────────────────

  test("triage PROCEED: transitions to in-progress and posts Slack notification on start", async () => {
    const handle = makeRepoHandle("my-api", { hasChanges: true });
    syncAllReposSpy.mockImplementation(async () => [handle]);

    const slackNotifier = {
      notifyTicketStarted: mock(async () => {}),
      notifyPrCreated: mock(async () => {}),
      notifyNeedsInfo: mock(async () => {}),
      notifyJobFailed: mock(async () => {}),
    };

    const agentCli = {
      runAgent: mock(async (_prompt: string) => ({
        output:
          "VERDICT:PROCEED\nSUMMARY_START\nGood ticket.\nSUMMARY_END\nPR_TITLE_START\nFix login\nPR_TITLE_END",
        exitCode: 0,
        success: true,
      })),
    };

    const jiraClient = {
      getIssue: mock(async () => makeIssue()),
      addComment: mock(async () => {}),
      transitionIssue: mock(async () => {}),
      createSubtask: mock(async () => ({ id: "2", key: "SQDP-2" })),
      getLastStatusChangeAuthor: mock(async () => null),
      resolveAccountIdForBotEmail: mock(async () => null),
      downloadAttachment: mock(async () => Buffer.from("")),
      getIssueComponents: mock(async () => []),
    };

    const result = await makePipeline({ jiraClient, agentCli, slackNotifier }).pipeline.execute(
      buildJob(),
    );

    expect(jiraClient.transitionIssue.mock.calls.length).toBeGreaterThanOrEqual(1);
    const firstTransition = (
      jiraClient.transitionIssue.mock.calls[0] as unknown as unknown[]
    )?.[1] as string;
    expect(firstTransition).toBe("In Progress");
    void result;
  });

  // ── Triage: NEEDS_INFO ──────────────────────────────────────────────────────

  test("triage NEEDS_INFO: posts Jira comment, reverts to To Do, returns needs_info outcome", async () => {
    const handle = makeRepoHandle("my-api");
    syncAllReposSpy.mockImplementation(async () => [handle]);

    const agentCli = {
      runAgent: mock(async () => ({
        output:
          "VERDICT:NEEDS_INFO\nSUMMARY_START\nMissing repro steps.\nSUMMARY_END\nQUESTIONS_START\nHow to reproduce?\nQUESTIONS_END",
        exitCode: 0,
        success: true,
      })),
    };

    const jiraClient = {
      getIssue: mock(async () => makeIssue()),
      addComment: mock(async () => {}),
      transitionIssue: mock(async () => {}),
      createSubtask: mock(async () => ({ id: "2", key: "SQDP-2" })),
      getLastStatusChangeAuthor: mock(async () => null),
      resolveAccountIdForBotEmail: mock(async () => null),
      downloadAttachment: mock(async () => Buffer.from("")),
      getIssueComponents: mock(async () => []),
    };

    const result = await makePipeline({ agentCli, jiraClient }).pipeline.execute(buildJob());

    expect(result.success).toBe(false);
    expect(result.outcome).toBe("needs_info");
    expect(result.abortReason).toBe("triage_needs_info");

    // Jira comment posted
    expect(jiraClient.addComment.mock.calls.length).toBeGreaterThanOrEqual(1);

    // Ticket reverted to To Do
    const transitionArgs = jiraClient.transitionIssue.mock.calls.map(
      (c) => (c as unknown as [unknown, string])[1],
    );
    expect(transitionArgs).toContain("To Do");
  });

  // ── Cursor CLI fails ───────────────────────────────────────────────────────

  test("cursor CLI failure: returns completed with abort reason", async () => {
    const handle = makeRepoHandle("my-api");
    syncAllReposSpy.mockImplementation(async () => [handle]);

    const agentCli = {
      runAgent: mock(async () => ({
        output: "Agent crashed",
        exitCode: 1,
        success: false,
      })),
    };

    const jiraClient = {
      getIssue: mock(async () => makeIssue()),
      addComment: mock(async () => {}),
      transitionIssue: mock(async () => {}),
      createSubtask: mock(async () => ({ id: "2", key: "SQDP-2" })),
      getLastStatusChangeAuthor: mock(async () => null),
      resolveAccountIdForBotEmail: mock(async () => null),
      downloadAttachment: mock(async () => Buffer.from("")),
      getIssueComponents: mock(async () => []),
    };

    const result = await makePipeline({ agentCli, jiraClient }).pipeline.execute(buildJob());

    expect(result.success).toBe(false);
    expect(result.outcome).toBe("completed");
    expect(result.abortReason).toMatch(/Agent CLI exited with code 1/);
  });

  // ── No changes after fix ───────────────────────────────────────────────────

  test("repos with stable working-tree fingerprints are skipped — only agent-touched repos enter changedHandles", async () => {
    // Reproduces the phantom-subtask bug: when `syncAllRepos` runs
    // `installDependencies` against repos the agent never touches, the working
    // tree can end up dirty (failed/partial install side effects). Before this
    // fix, those repos were treated as "changed" and produced phantom PRs and
    // subtasks. With pre/post fingerprints, only repos the agent actually
    // edited (fingerprint differs) enter changedHandles.
    const dirtyButUnchanged = makeRepoHandle("aws-lambda-cerberus-migrator", {
      hasChanges: true,
      // Fingerprint identical before and after the agent runs — install side
      // effects were already there and the agent did not touch the repo.
      fingerprints: ["dirty-from-install", "dirty-from-install"],
    });
    const agentTouched = makeRepoHandle("humand-web", {
      hasChanges: true,
      fingerprints: ["clean", "agent-edited"],
    });
    syncAllReposSpy.mockImplementation(async () => [dirtyButUnchanged, agentTouched]);

    const agentCli = {
      runAgent: mock(async () => ({
        output: "VERDICT:PROCEED\nSUMMARY_START\nFixed humand-web.\nSUMMARY_END",
        exitCode: 0,
        success: true,
      })),
    };

    const jiraClient = {
      getIssue: mock(async () => makeIssue()),
      addComment: mock(async () => {}),
      transitionIssue: mock(async () => {}),
      createSubtask: mock(async () => ({ id: "2", key: "SQDP-2" })),
      getLastStatusChangeAuthor: mock(async () => null),
      resolveAccountIdForBotEmail: mock(async () => null),
      downloadAttachment: mock(async () => Buffer.from("")),
      getIssueComponents: mock(async () => []),
    };

    const result = await makePipeline({ agentCli, jiraClient }).pipeline.execute(buildJob());

    expect(result.success).toBe(true);
    expect(result.repos).toHaveLength(1);
    expect(result.repos?.[0]?.repoName).toBe("humand-web");
    // The dirty-but-unchanged repo must never reach validateBuild or commitAndPush.
    expect(dirtyButUnchanged.manager.validateBuild.mock.calls.length).toBe(0);
    expect(dirtyButUnchanged.manager.commitAndPush.mock.calls.length).toBe(0);
    expect(agentTouched.manager.validateBuild.mock.calls.length).toBeGreaterThanOrEqual(1);
  });

  test("no changes after fix: returns completed with abort reason", async () => {
    const handle = makeRepoHandle("my-api", { hasChanges: false });
    syncAllReposSpy.mockImplementation(async () => [handle]);

    const agentCli = {
      runAgent: mock(async () => ({
        output: "VERDICT:PROCEED\nSUMMARY_START\nDone.\nSUMMARY_END",
        exitCode: 0,
        success: true,
      })),
    };

    const jiraClient = {
      getIssue: mock(async () => makeIssue()),
      addComment: mock(async () => {}),
      transitionIssue: mock(async () => {}),
      createSubtask: mock(async () => ({ id: "2", key: "SQDP-2" })),
      getLastStatusChangeAuthor: mock(async () => null),
      resolveAccountIdForBotEmail: mock(async () => null),
      downloadAttachment: mock(async () => Buffer.from("")),
      getIssueComponents: mock(async () => []),
    };

    const result = await makePipeline({ agentCli, jiraClient }).pipeline.execute(buildJob());

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

  // ── Single repo PR created ─────────────────────────────────────────────────

  test("single repo with changes: creates one PR and returns success", async () => {
    const handle = makeRepoHandle("my-api", { hasChanges: true, validateBuildSuccess: true });
    syncAllReposSpy.mockImplementation(async () => [handle]);

    const agentCli = {
      runAgent: mock(async () => ({
        output: "VERDICT:PROCEED\nSUMMARY_START\nFixed.\nSUMMARY_END",
        exitCode: 0,
        success: true,
      })),
    };

    const jiraClient = {
      getIssue: mock(async () => makeIssue()),
      addComment: mock(async () => {}),
      transitionIssue: mock(async () => {}),
      createSubtask: mock(async () => ({ id: "2", key: "SQDP-2" })),
      getLastStatusChangeAuthor: mock(async () => null),
      resolveAccountIdForBotEmail: mock(async () => null),
      downloadAttachment: mock(async () => Buffer.from("")),
      getIssueComponents: mock(async () => []),
    };

    const result = await makePipeline({ agentCli, jiraClient }).pipeline.execute(buildJob());

    expect(result.success).toBe(true);
    expect(result.outcome).toBe("completed");
    expect(result.repos).toHaveLength(1);
    expect(result.repos?.[0]?.repoName).toBe("my-api");
    expect(result.repos?.[0]?.prUrl).toBe("https://github.com/org/my-api/pull/1");
  });

  // ── Build failure → followup agent → build passes ─────────────────────────

  test("build fails then followup agent fixes it: creates PR", async () => {
    let runCount = 0;
    const handle = makeRepoHandle("my-api", { hasChanges: true });
    // First validateBuild fails, second succeeds (after followup)
    handle.manager.validateBuild = mock(async () => {
      runCount++;
      if (runCount === 1) {
        return { success: false, failedStep: "test" as const, testOutput: "test failed" };
      }
      return { success: true, testOutput: "" };
    }) as typeof handle.manager.validateBuild;
    syncAllReposSpy.mockImplementation(async () => [handle]);

    const agentCli = {
      runAgent: mock(async () => ({
        output: "VERDICT:PROCEED\nSUMMARY_START\nFixed.\nSUMMARY_END",
        exitCode: 0,
        success: true,
      })),
    };

    const jiraClient = {
      getIssue: mock(async () => makeIssue()),
      addComment: mock(async () => {}),
      transitionIssue: mock(async () => {}),
      createSubtask: mock(async () => ({ id: "2", key: "SQDP-2" })),
      getLastStatusChangeAuthor: mock(async () => null),
      resolveAccountIdForBotEmail: mock(async () => null),
      downloadAttachment: mock(async () => Buffer.from("")),
      getIssueComponents: mock(async () => []),
    };

    const result = await makePipeline({ agentCli, jiraClient }).pipeline.execute(buildJob());

    expect(result.success).toBe(true);
    expect(agentCli.runAgent.mock.calls.length).toBe(3); // triage + fix + followup
  });

  // ── Build fails after followup ─────────────────────────────────────────────

  test("build fails after followup: skips repo and returns no PRs", async () => {
    const handle = makeRepoHandle("my-api", { hasChanges: true });
    handle.manager.validateBuild = mock(async () => ({
      success: false,
      failedStep: "test" as const,
      testOutput: "still failing",
    })) as unknown as typeof handle.manager.validateBuild;
    syncAllReposSpy.mockImplementation(async () => [handle]);

    const agentCli = {
      runAgent: mock(async () => ({
        output: "VERDICT:PROCEED\nSUMMARY_START\nFixed.\nSUMMARY_END",
        exitCode: 0,
        success: true,
      })),
    };

    const jiraClient = {
      getIssue: mock(async () => makeIssue()),
      addComment: mock(async () => {}),
      transitionIssue: mock(async () => {}),
      createSubtask: mock(async () => ({ id: "2", key: "SQDP-2" })),
      getLastStatusChangeAuthor: mock(async () => null),
      resolveAccountIdForBotEmail: mock(async () => null),
      downloadAttachment: mock(async () => Buffer.from("")),
      getIssueComponents: mock(async () => []),
    };

    const result = await makePipeline({ agentCli, jiraClient }).pipeline.execute(buildJob());

    expect(result.success).toBe(false);
    expect(result.abortReason).toMatch(/failed safety or build checks/i);
  });

  // ── Transition to In Progress fails gracefully ─────────────────────────────

  test("transition to In Progress failure is non-blocking", async () => {
    const handle = makeRepoHandle("my-api", { hasChanges: true });
    syncAllReposSpy.mockImplementation(async () => [handle]);

    const jiraClient = {
      getIssue: mock(async () => makeIssue()),
      addComment: mock(async () => {}),
      transitionIssue: mock(async () => {
        throw new Error("transition failed");
      }),
      createSubtask: mock(async () => ({ id: "2", key: "SQDP-2" })),
      getLastStatusChangeAuthor: mock(async () => null),
      resolveAccountIdForBotEmail: mock(async () => null),
      downloadAttachment: mock(async () => Buffer.from("")),
      getIssueComponents: mock(async () => []),
    };

    const slackNotifier = {
      notifyTicketStarted: mock(async () => {}),
      notifyPrCreated: mock(async () => {}),
      notifyNeedsInfo: mock(async () => {}),
      notifyJobFailed: mock(async () => {}),
    };

    const agentCli = {
      runAgent: mock(async () => ({
        output: "VERDICT:PROCEED\nSUMMARY_START\nFixed.\nSUMMARY_END",
        exitCode: 0,
        success: true,
      })),
    };

    // Should complete successfully despite transition failure
    const result = await makePipeline({ agentCli, jiraClient, slackNotifier }).pipeline.execute(
      buildJob(),
    );

    // Pipeline should proceed despite transition failure
    // (exact success depends on build; with hasChanges:true it creates a PR)
    expect(result.outcome).toBe("completed");

    // Slack ticket-started notification must fire even when the Jira transition fails
    expect(slackNotifier.notifyTicketStarted.mock.calls.length).toBe(1);
  });

  // ── Unexpected error: adds Jira comment and returns failed outcome ─────────

  test("unexpected error: adds Jira comment with error details", async () => {
    syncAllReposSpy.mockImplementation(async () => {
      throw new Error("network timeout");
    });

    const jiraClient = {
      getIssue: mock(async () => makeIssue()),
      addComment: mock(async () => {}),
      transitionIssue: mock(async () => {}),
      createSubtask: mock(async () => ({ id: "2", key: "SQDP-2" })),
      getLastStatusChangeAuthor: mock(async () => null),
      resolveAccountIdForBotEmail: mock(async () => null),
      downloadAttachment: mock(async () => Buffer.from("")),
      getIssueComponents: mock(async () => []),
    };

    const result = await makePipeline({ jiraClient }).pipeline.execute(buildJob());

    expect(result.success).toBe(false);
    expect(result.abortReason).toBe("network timeout");
    expect(jiraClient.addComment.mock.calls.length).toBeGreaterThanOrEqual(1);
  });

  // ── syncRemoteRulesRepos is called ─────────────────────────────────────────

  test("calls syncRemoteRulesRepos after syncing repos", async () => {
    const handle = makeRepoHandle("my-api");
    syncAllReposSpy.mockImplementation(async () => [handle]);

    const jiraClient = {
      getIssue: mock(async () => makeIssue()),
      addComment: mock(async () => {}),
      transitionIssue: mock(async () => {}),
      createSubtask: mock(async () => ({ id: "2", key: "SQDP-2" })),
      getLastStatusChangeAuthor: mock(async () => null),
      resolveAccountIdForBotEmail: mock(async () => null),
      downloadAttachment: mock(async () => Buffer.from("")),
      getIssueComponents: mock(async () => []),
    };

    const agentCli = {
      runAgent: mock(async () => ({
        output: "VERDICT:NEEDS_INFO\nSUMMARY_START\nMissing info.\nSUMMARY_END",
        exitCode: 0,
        success: true,
      })),
    };

    await makePipeline({ agentCli, jiraClient }).pipeline.execute(buildJob());

    expect(syncRemoteRulesReposSpy.mock.calls.length).toBe(1);
  });

  // ── Metrics: successful run ────────────────────────────────────────────────

  test("metrics: successful run emits repo.selected, agent.invocations{fix}, and pr.opened", async () => {
    const handle = makeRepoHandle("my-api", { hasChanges: true, validateBuildSuccess: true });
    syncAllReposSpy.mockImplementation(async () => [handle]);

    const agentCli = {
      runAgent: mock(async () => ({
        output: "VERDICT:PROCEED\nSUMMARY_START\nFixed.\nSUMMARY_END",
        exitCode: 0,
        success: true,
      })),
    };

    const jiraClient = {
      getIssue: mock(async () => makeIssue()),
      addComment: mock(async () => {}),
      transitionIssue: mock(async () => {}),
      createSubtask: mock(async () => ({ id: "2", key: "SQDP-2" })),
      getLastStatusChangeAuthor: mock(async () => null),
      resolveAccountIdForBotEmail: mock(async () => null),
      downloadAttachment: mock(async () => Buffer.from("")),
      getIssueComponents: mock(async () => []),
    };

    const metrics = new FakeMetrics();
    await makePipeline({ agentCli, jiraClient, metrics }).pipeline.execute(buildJob());

    // repo.selected emitted for the repo in changedHandles
    expect(metrics.countOf(METRIC.repoSelected)).toBeGreaterThanOrEqual(1);
    expect(metrics.last(METRIC.repoSelected)?.tags).toEqual({ repo: "my-api" });

    // agent.invocations emitted for triage and fix phases
    const invocationTags = metrics.tagsOf(METRIC.agentInvocations);
    expect(invocationTags.some((t) => (t as Record<string, string>)?.phase === "fix")).toBe(true);

    // pr.opened emitted once
    expect(metrics.countOf(METRIC.prOpened)).toBe(1);
    expect(metrics.last(METRIC.prOpened)?.tags).toEqual({ repo: "my-api" });

    // terminal fix.outcome reflects the PR-created path
    expect(metrics.countOf(METRIC.fixOutcome)).toBe(1);
    expect(metrics.last(METRIC.fixOutcome)?.tags).toEqual({ outcome: "pr_created" });
  });

  // ── Metrics: NEEDS_INFO triage ─────────────────────────────────────────────

  test("metrics: NEEDS_INFO triage emits fix.triage_verdict and a terminal fix.outcome", async () => {
    const handle = makeRepoHandle("my-api");
    syncAllReposSpy.mockImplementation(async () => [handle]);

    const agentCli = {
      runAgent: mock(async () => ({
        output:
          "VERDICT:NEEDS_INFO\nSUMMARY_START\nMissing repro steps.\nSUMMARY_END\nQUESTIONS_START\nHow to reproduce?\nQUESTIONS_END",
        exitCode: 0,
        success: true,
      })),
    };

    const jiraClient = {
      getIssue: mock(async () => makeIssue()),
      addComment: mock(async () => {}),
      transitionIssue: mock(async () => {}),
      createSubtask: mock(async () => ({ id: "2", key: "SQDP-2" })),
      getLastStatusChangeAuthor: mock(async () => null),
      resolveAccountIdForBotEmail: mock(async () => null),
      downloadAttachment: mock(async () => Buffer.from("")),
      getIssueComponents: mock(async () => []),
    };

    const metrics = new FakeMetrics();
    await makePipeline({ agentCli, jiraClient, metrics }).pipeline.execute(buildJob());

    expect(metrics.countOf(METRIC.fixTriageVerdict)).toBe(1);
    expect(metrics.last(METRIC.fixTriageVerdict)?.tags).toEqual({ verdict: "needs_info" });
    expect(metrics.countOf(METRIC.fixOutcome)).toBe(1);
    expect(metrics.last(METRIC.fixOutcome)?.tags).toEqual({ outcome: "needs_info" });
  });

  // ── Metrics: failing build ─────────────────────────────────────────────────

  test("metrics: failing build (after followup) emits fix.build_validation with {result:'fail'}", async () => {
    const handle = makeRepoHandle("my-api", { hasChanges: true });
    handle.manager.validateBuild = mock(async () => ({
      success: false,
      failedStep: "test" as const,
      testOutput: "still failing",
    })) as unknown as typeof handle.manager.validateBuild;
    syncAllReposSpy.mockImplementation(async () => [handle]);

    const agentCli = {
      runAgent: mock(async () => ({
        output: "VERDICT:PROCEED\nSUMMARY_START\nFixed.\nSUMMARY_END",
        exitCode: 0,
        success: true,
      })),
    };

    const jiraClient = {
      getIssue: mock(async () => makeIssue()),
      addComment: mock(async () => {}),
      transitionIssue: mock(async () => {}),
      createSubtask: mock(async () => ({ id: "2", key: "SQDP-2" })),
      getLastStatusChangeAuthor: mock(async () => null),
      resolveAccountIdForBotEmail: mock(async () => null),
      downloadAttachment: mock(async () => Buffer.from("")),
      getIssueComponents: mock(async () => []),
    };

    const metrics = new FakeMetrics();
    await makePipeline({ agentCli, jiraClient, metrics }).pipeline.execute(buildJob());

    expect(metrics.countOf(METRIC.fixBuildValidation)).toBeGreaterThanOrEqual(1);
    const buildValidationTags = metrics.tagsOf(METRIC.fixBuildValidation);
    expect(buildValidationTags.some((t) => (t as Record<string, string>)?.result === "fail")).toBe(
      true,
    );
  });

  // ── Aborted mid-run: revert to To Do, no PR ────────────────────────────────

  test("reverts the issue to To Do when aborted mid-run and creates no PR", async () => {
    const handle = makeRepoHandle("my-api", { hasChanges: true });
    syncAllReposSpy.mockImplementation(async () => [handle]);

    const agentCli = {
      runAgent: mock(async () => {
        throw new JobAbortedError("aborted for shutdown");
      }),
    };

    const jiraClient = {
      getIssue: mock(async () => makeIssue()),
      addComment: mock(async () => {}),
      transitionIssue: mock(async () => {}),
      createSubtask: mock(async () => ({ id: "2", key: "SQDP-2" })),
      getLastStatusChangeAuthor: mock(async () => null),
      resolveAccountIdForBotEmail: mock(async () => null),
      downloadAttachment: mock(async () => Buffer.from("")),
      getIssueComponents: mock(async () => []),
    };

    const ac = new AbortController();
    ac.abort();
    const result = await makePipeline({ agentCli, jiraClient }).pipeline.execute(
      buildJob(),
      ac.signal,
    );

    expect(result.abortReason).toBe("aborted_for_shutdown");
    const transitionArgs = jiraClient.transitionIssue.mock.calls.map(
      (c) => (c as unknown as [unknown, string])[1],
    );
    expect(transitionArgs).toContain("To Do");
    // No PR was created — the createPr POST must never have fired.
    expect(handle.manager.commitAndPush.mock.calls.length).toBe(0);
  });

  // ── Slack notifyPrCreated called ───────────────────────────────────────────

  test("notifies Slack when a PR is created", async () => {
    const handle = makeRepoHandle("my-api", { hasChanges: true });
    syncAllReposSpy.mockImplementation(async () => [handle]);

    const slackNotifier = {
      notifyTicketStarted: mock(async () => {}),
      notifyPrCreated: mock(async () => {}),
      notifyNeedsInfo: mock(async () => {}),
      notifyJobFailed: mock(async () => {}),
    };

    const agentCli = {
      runAgent: mock(async () => ({
        output: "VERDICT:PROCEED\nSUMMARY_START\nFixed.\nSUMMARY_END",
        exitCode: 0,
        success: true,
      })),
    };

    const jiraClient = {
      getIssue: mock(async () => makeIssue()),
      addComment: mock(async () => {}),
      transitionIssue: mock(async () => {}),
      createSubtask: mock(async () => ({ id: "2", key: "SQDP-2" })),
      getLastStatusChangeAuthor: mock(async () => null),
      resolveAccountIdForBotEmail: mock(async () => null),
      downloadAttachment: mock(async () => Buffer.from("")),
      getIssueComponents: mock(async () => []),
    };

    await makePipeline({ agentCli, jiraClient, slackNotifier }).pipeline.execute(buildJob());

    expect(slackNotifier.notifyPrCreated.mock.calls.length).toBe(1);
  });
});

// ── Video attachments ─────────────────────────────────────────────────────────

describe("FixPipeline.execute() — video attachments", () => {
  beforeEach(async () => {
    const mod = await import("@/utils/video-frames.js");
    mod.resetFfmpegAvailabilityCache();
  });

  function makeIssueWithVideo() {
    return makeIssue({
      attachments: [
        {
          id: "v1",
          filename: "screen recording.mov",
          mimeType: "video/quicktime",
          size: 2_000_000,
          content: "https://jira.example.com/secure/attachment/v1/video.mov",
          thumbnail: null,
          created: "2026-04-01T10:00:00Z",
        },
      ],
    });
  }

  test("flag off: video is not downloaded and prompt has no Video Attachments block", async () => {
    syncAllReposSpy.mockImplementation(async () => [
      makeRepoHandle("my-api", { hasChanges: false }),
    ]);
    const issue = makeIssueWithVideo();
    const triagePromptSink: string[] = [];

    const agentCli = {
      runAgent: mock(async (prompt: string) => {
        triagePromptSink.push(prompt);
        return {
          output: "VERDICT: NEEDS_INFO\nSUMMARY: missing repro\nQUESTIONS:\n- which env?",
          exitCode: 0,
          success: true,
        };
      }),
    };
    const jiraClient = {
      getIssue: mock(async () => issue),
      addComment: mock(async () => {}),
      transitionIssue: mock(async () => {}),
      createSubtask: mock(async () => ({ id: "2", key: "SQDP-2" })),
      getLastStatusChangeAuthor: mock(async () => null),
      resolveAccountIdForBotEmail: mock(async () => null),
      downloadAttachment: mock(async () => Buffer.from("video-bytes")),
      getIssueComponents: mock(async () => []),
    };

    await makePipeline(
      { agentCli, jiraClient },
      { videoAttachmentsEnabled: false },
    ).pipeline.execute(buildJob());

    expect(jiraClient.downloadAttachment).not.toHaveBeenCalled();
    expect(triagePromptSink[0]).toBeDefined();
    expect(triagePromptSink[0]).not.toContain("## Video Attachments");
  });

  test("flag on but ffmpeg unavailable: warn + no download + no Video Attachments in prompt", async () => {
    syncAllReposSpy.mockImplementation(async () => [
      makeRepoHandle("my-api", { hasChanges: false }),
    ]);
    const issue = makeIssueWithVideo();
    const triagePromptSink: string[] = [];

    const agentCli = {
      runAgent: mock(async (prompt: string) => {
        triagePromptSink.push(prompt);
        return {
          output: "VERDICT: NEEDS_INFO\nSUMMARY: still vague\nQUESTIONS:\n- repro steps?",
          exitCode: 0,
          success: true,
        };
      }),
    };
    const jiraClient = {
      getIssue: mock(async () => issue),
      addComment: mock(async () => {}),
      transitionIssue: mock(async () => {}),
      createSubtask: mock(async () => ({ id: "2", key: "SQDP-2" })),
      getLastStatusChangeAuthor: mock(async () => null),
      resolveAccountIdForBotEmail: mock(async () => null),
      downloadAttachment: mock(async () => Buffer.from("video")),
      getIssueComponents: mock(async () => []),
    };

    // Spawn returns exit 127 for any binary -version probe → isFfmpegAvailable cache=false
    const spawnSpy = spyOn(Bun, "spawn").mockImplementation(
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
      ((_args: unknown, _opts?: unknown): any => ({
        stdout: new ReadableStream({
          start(c) {
            c.close();
          },
        }),
        stderr: new ReadableStream({
          start(c) {
            c.close();
          },
        }),
        exited: Promise.resolve(127),
        pid: 1,
        exitCode: 127,
        kill: () => {},
      })) as unknown as typeof Bun.spawn,
    );

    await makePipeline(
      { agentCli, jiraClient },
      { videoAttachmentsEnabled: true },
    ).pipeline.execute(buildJob());

    expect(jiraClient.downloadAttachment).not.toHaveBeenCalled();
    expect(triagePromptSink[0]).not.toContain("## Video Attachments");
    spawnSpy.mockRestore();
  });

  test("flag on, download fails: skips video without aborting the pipeline", async () => {
    syncAllReposSpy.mockImplementation(async () => [
      makeRepoHandle("my-api", { hasChanges: false }),
    ]);
    const issue = makeIssueWithVideo();
    const triagePromptSink: string[] = [];

    const agentCli = {
      runAgent: mock(async (prompt: string) => {
        triagePromptSink.push(prompt);
        return {
          output: "VERDICT: NEEDS_INFO\nSUMMARY: skipped video\nQUESTIONS:\n- ?",
          exitCode: 0,
          success: true,
        };
      }),
    };
    const jiraClient = {
      getIssue: mock(async () => issue),
      addComment: mock(async () => {}),
      transitionIssue: mock(async () => {}),
      createSubtask: mock(async () => ({ id: "2", key: "SQDP-2" })),
      getLastStatusChangeAuthor: mock(async () => null),
      resolveAccountIdForBotEmail: mock(async () => null),
      downloadAttachment: mock(async () => {
        throw new Error("Jira 502 Bad Gateway");
      }),
      getIssueComponents: mock(async () => []),
    };

    // ffmpeg/ffprobe -version both succeed so isFfmpegAvailable returns true
    const spawnSpy = spyOn(Bun, "spawn").mockImplementation(
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
      ((_args: unknown, _opts?: unknown): any => ({
        stdout: new ReadableStream({
          start(c) {
            c.close();
          },
        }),
        stderr: new ReadableStream({
          start(c) {
            c.close();
          },
        }),
        exited: Promise.resolve(0),
        pid: 1,
        exitCode: 0,
        kill: () => {},
      })) as unknown as typeof Bun.spawn,
    );

    const result = await makePipeline(
      { agentCli, jiraClient },
      { videoAttachmentsEnabled: true },
    ).pipeline.execute(buildJob());

    // Pipeline reaches triage → returns needs_info. Critically: it does not abort.
    expect(result.outcome).toBe("needs_info");
    expect(jiraClient.downloadAttachment).toHaveBeenCalledTimes(1);
    expect(triagePromptSink[0]).not.toContain("## Video Attachments");
    spawnSpy.mockRestore();
  });
});
