import { describe, expect, test, beforeAll, afterAll } from "bun:test";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";

import {
  parsePrTitle,
  parsePrBody,
  parsePrCommentReply,
  parseTriageOutput,
  parseCardReadinessOutput,
  buildFixPrompt,
  buildTriagePrompt,
  buildCardReadinessPrompt,
  buildFollowupPrompt,
  buildPrCommentPrompt,
  buildPrMentionPrompt,
  buildSlackMentionPrompt,
} from "@/agent/prompts.js";
import type { JiraIssue, RepoContext, RepoConfig, ThreadContextEntry } from "@/types/index.js";

// ---------------------------------------------------------------------------
// CWD setup — builders call loadGeneralRules() which reads rules/general.mdc
// from process.cwd(). Ensure we are at the worktree root.
// ---------------------------------------------------------------------------

const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");

let originalCwd: string;
beforeAll(() => {
  originalCwd = process.cwd();
  process.chdir(REPO_ROOT);
});
afterAll(() => {
  process.chdir(originalCwd);
});

// ---------------------------------------------------------------------------
// Fixture helpers
// ---------------------------------------------------------------------------

function makeIssue(overrides: Partial<JiraIssue> = {}): JiraIssue {
  return {
    id: "10001",
    key: "SQDP-1",
    summary: "Login button does not respond",
    description: "Clicking login does nothing on Safari.",
    issueType: "Bug",
    status: "To Do",
    created: "2025-01-15T10:00:00.000Z",
    updated: "2025-01-15T10:00:00.000Z",
    acceptanceCriteria: null,
    stacktrace: null,
    flow: null,
    assignee: null,
    parent: null,
    subtasks: [],
    children: [],
    attachments: [],
    comments: [],
    components: [],
    ...overrides,
  };
}

function makeRepoContext(overrides: Partial<RepoContext> = {}): RepoContext {
  return {
    name: "my-api",
    description: "Backend API service",
    keywords: ["api", "backend"],
    rules: null,
    checkScripts: [],
    ...overrides,
  };
}

function makeRepoConfig(overrides: Partial<RepoConfig> = {}): RepoConfig {
  return {
    name: "my-api",
    url: "git@github.com:org/my-api.git",
    description: "Backend API service",
    keywords: ["api", "backend"],
    ...overrides,
  };
}

// ---------------------------------------------------------------------------
// Parsers
// ---------------------------------------------------------------------------

describe("parsePrTitle", () => {
  // Test 1: happy path
  test("extracts title between markers", () => {
    const result = parsePrTitle("PR_TITLE_START\nFoo\nPR_TITLE_END");
    expect(result).toBe("Foo");
  });

  // Test 2: no markers → null
  test("returns null when markers are missing", () => {
    const result = parsePrTitle("text with no markers");
    expect(result).toBeNull();
  });

  // Test 3: trims whitespace and ignores leading/trailing newlines inside markers
  test("trims whitespace inside markers", () => {
    const result = parsePrTitle("PR_TITLE_START\n  My Title  \nPR_TITLE_END");
    expect(result).toBe("My Title");
  });

  // Test 4: multiple occurrences — extracts FIRST (lazy regex)
  test("extracts first occurrence when multiple markers exist", () => {
    const raw = [
      "PR_TITLE_START",
      "First Title",
      "PR_TITLE_END",
      "PR_TITLE_START",
      "Second Title",
      "PR_TITLE_END",
    ].join("\n");
    const result = parsePrTitle(raw);
    expect(result).toBe("First Title");
  });

  // Test 20a: only START marker, no END → null
  test("returns null when only START marker is present (no END)", () => {
    const result = parsePrTitle("PR_TITLE_START\nOrphaned title");
    expect(result).toBeNull();
  });
});

describe("parsePrBody", () => {
  // Test 5: happy path — preserves multiline content
  test("extracts body between markers preserving newlines", () => {
    const raw = "PR_BODY_START\n## Summary\n- did X\nPR_BODY_END";
    const result = parsePrBody(raw);
    expect(result).toBe("## Summary\n- did X");
  });

  // Test 6: no markers → null
  test("returns null when markers are missing", () => {
    const result = parsePrBody("just some random text");
    expect(result).toBeNull();
  });

  // Test 20b: only START marker, no END → null
  test("returns null when only START marker is present (no END)", () => {
    const result = parsePrBody("PR_BODY_START\nOrphaned body content");
    expect(result).toBeNull();
  });
});

describe("parsePrCommentReply", () => {
  // Test 7: happy path
  test("extracts reply between markers", () => {
    const result = parsePrCommentReply("REPLY_START\nHi\nREPLY_END");
    expect(result).toBe("Hi");
  });

  // Test 8: without markers → truncated raw text (4000 chars)
  test("falls back to truncated raw text when markers are missing", () => {
    const longText = "A".repeat(5000);
    const result = parsePrCommentReply(longText);
    expect(result).toHaveLength(4000);
    expect(result).toBe("A".repeat(4000));
  });

  // Test 8b: short text without markers returns full text trimmed
  test("returns full trimmed text when short and markers are missing", () => {
    const result = parsePrCommentReply("  just a response  ");
    expect(result).toBe("just a response");
  });

  // Test 20c: only REPLY_START without REPLY_END → fallback
  test("falls back to truncated raw text when only REPLY_START is present", () => {
    const result = parsePrCommentReply("REPLY_START\nOrphaned reply");
    expect(result).toBe("REPLY_START\nOrphaned reply");
  });
});

describe("parseTriageOutput", () => {
  // Test 9: PROCEED verdict
  test("parses VERDICT: PROCEED with full return shape", () => {
    const raw = "VERDICT: PROCEED";
    const result = parseTriageOutput(raw);
    expect(result.verdict).toBe("PROCEED");
    expect(result.title).toBeNull();
    // No SUMMARY: prefix → summary falls back to raw.trim()
    expect(result.summary).toBe("VERDICT: PROCEED");
    expect(result.questions).toBeNull();
    expect(result.raw).toBe(raw);
  });

  // Test 10: NEEDS_INFO with SUMMARY and QUESTIONS
  test("parses VERDICT: NEEDS_INFO with summary and questions", () => {
    const raw = [
      "VERDICT: NEEDS_INFO",
      "SUMMARY: Analyzed the codebase, found issue in auth module",
      "QUESTIONS:",
      "- What is the expected behavior?",
      "- Which environment?",
    ].join("\n");
    const result = parseTriageOutput(raw);
    expect(result.verdict).toBe("NEEDS_INFO");
    expect(result.title).toBeNull();
    expect(result.summary).toBe("Analyzed the codebase, found issue in auth module");
    expect(result.questions).toContain("What is the expected behavior?");
    expect(result.questions).toContain("Which environment?");
    expect(result.raw).toBe(raw);
  });

  // Test 11: without VERDICT marker → defaults to PROCEED
  test("defaults to PROCEED when VERDICT marker is absent", () => {
    const raw = "I explored the codebase and the bug seems clear.";
    const result = parseTriageOutput(raw);
    expect(result.verdict).toBe("PROCEED");
    expect(result.title).toBeNull();
    expect(result.summary).toBe(raw);
    expect(result.questions).toBeNull();
    expect(result.raw).toBe(raw);
  });

  // Test 12: unknown verdict string → defaults to PROCEED
  test("defaults to PROCEED for unknown verdict like SOMETHING_ELSE", () => {
    const raw = "VERDICT: SOMETHING_ELSE\nSUMMARY: unknown state";
    const result = parseTriageOutput(raw);
    // Regex only matches PROCEED|NEEDS_INFO, so SOMETHING_ELSE is not captured
    expect(result.verdict).toBe("PROCEED");
  });

  // Test 10b: case insensitive verdict matching
  test("handles case-insensitive verdict matching", () => {
    const raw = "verdict: needs_info\nSUMMARY: check\nQUESTIONS:\n- why?";
    const result = parseTriageOutput(raw);
    expect(result.verdict).toBe("NEEDS_INFO");
  });
});

describe("parseCardReadinessOutput", () => {
  test("defaults to NEEDS_INFO when VERDICT marker is absent (do not claim ready)", () => {
    const raw = "Some unstructured agent output without verdict blocks.";
    const result = parseCardReadinessOutput(raw);
    expect(result.verdict).toBe("NEEDS_INFO");
    expect(result.raw).toBe(raw);
  });

  test("parses PROCEED when explicitly present", () => {
    const raw = "VERDICT: PROCEED\nSUMMARY: All clear.";
    const result = parseCardReadinessOutput(raw);
    expect(result.verdict).toBe("PROCEED");
    expect(result.summary).toBe("All clear.");
  });

  test("unknown verdict token falls through to NEEDS_INFO default", () => {
    const raw = "VERDICT: SOMETHING_ELSE\nSUMMARY: x";
    const result = parseCardReadinessOutput(raw);
    expect(result.verdict).toBe("NEEDS_INFO");
  });
});

// ---------------------------------------------------------------------------
// Builders
// ---------------------------------------------------------------------------

describe("buildFixPrompt", () => {
  // Test 13: contains key sections
  test("output contains Bug Report section, issue key/summary, rules, and repo list", () => {
    const issue = makeIssue();
    const repos = [makeRepoContext()];
    const output = buildFixPrompt(issue, repos);

    expect(output).toContain("## Bug Report");
    expect(output).toContain("**Key:** SQDP-1");
    expect(output).toContain("**Summary:** Login button does not respond");
    expect(output).toContain("## Available Repositories");
    expect(output).toContain("my-api/");
    // Should contain rules from general.mdc
    expect(output).toContain("# General Agent Rules");
  });

  // Test 14: with imageDir set
  test("references imageDir in attachments section when images are present", () => {
    const issue = makeIssue({
      attachments: [
        {
          id: "att1",
          filename: "screenshot.png",
          mimeType: "image/png",
          size: 102400,
          content: "https://example.com/screenshot.png",
          thumbnail: null,
          created: "2025-01-15T10:00:00.000Z",
        },
      ],
    });
    const repos = [makeRepoContext()];
    const output = buildFixPrompt(issue, repos, undefined, "/tmp/images");

    expect(output).toContain("/tmp/images/assets");
    expect(output).toContain("screenshot.png");
  });

  // Test 13b: with triageSummary
  test("includes prior triage analysis section when triageSummary is provided", () => {
    const issue = makeIssue();
    const repos = [makeRepoContext()];
    const output = buildFixPrompt(issue, repos, "The bug is in auth.ts line 42");

    expect(output).toContain("## Prior Triage Analysis");
    expect(output).toContain("The bug is in auth.ts line 42");
  });
});

describe("buildTriagePrompt", () => {
  // Test 15: contains triage-specific prompt content
  test("output contains triage-specific sections from TRIAGE_PROMPT constant", () => {
    const issue = makeIssue();
    const repos = [makeRepoContext()];
    const output = buildTriagePrompt(issue, repos);

    expect(output).toContain("# Triage Assessment");
    expect(output).toContain("VERDICT: PROCEED");
    expect(output).toContain("VERDICT: NEEDS_INFO");
    expect(output).toContain("## Evaluation Criteria");
    expect(output).toContain("## Bug Report");
    expect(output).toContain("**Key:** SQDP-1");
  });
});

describe("buildFollowupPrompt", () => {
  // Test 16: contains repo name and build output verbatim
  test("includes repo name and error output verbatim", () => {
    const output = buildFollowupPrompt("my-api", "error: ts5023 missing module");

    expect(output).toContain("my-api/");
    expect(output).toContain("error: ts5023 missing module");
    expect(output).toContain("## Error Output");
  });

  // Test 16b: truncates build output exceeding 8000 chars
  test("truncates build output at 8000 characters", () => {
    const longOutput = "X".repeat(10000);
    const output = buildFollowupPrompt("my-api", longOutput);

    expect(output).toContain("X".repeat(8000));
    expect(output).not.toContain("X".repeat(8001));
  });
});

describe("buildPrCommentPrompt", () => {
  // Test 17: includes comment text and prior context
  test("output includes comment text and conversation history", () => {
    const output = buildPrCommentPrompt(
      "Please add null checks",
      "my-api",
      "fix/SQDP-1-login-bug",
      "diff --git a/src/auth.ts",
      "## Repo Rules\n- follow style guide",
      "Previous: User asked about the login flow",
    );

    expect(output).toContain("Please add null checks");
    expect(output).toContain("## Previous conversation on this PR");
    expect(output).toContain("Previous: User asked about the login flow");
    expect(output).toContain("**Repository:** my-api");
    expect(output).toContain("**Branch:** fix/SQDP-1-login-bug");
    expect(output).toContain("## PR diff (for context)");
    expect(output).toContain("## Repository Rules");
  });

  // Test 18: empty prior context handles gracefully
  test("handles null/empty conversation history gracefully", () => {
    const output = buildPrCommentPrompt(
      "Looks good but fix the typo",
      "my-api",
      "fix/SQDP-2",
      undefined,
      null,
      null,
    );

    expect(output).toContain("Looks good but fix the typo");
    expect(output).toContain("**Repository:** my-api");
    // Should NOT include the conversation section when null
    expect(output).not.toContain("## Previous conversation on this PR");
    // Should NOT include PR diff section when undefined
    expect(output).not.toContain("## PR diff (for context)");
    // Should NOT include repo rules section when null
    expect(output).not.toContain("## Repository Rules");
    // Should contain general rules
    expect(output).toContain("# General Agent Rules");
  });

  test("renders parent review thread as primary context when provided", () => {
    const output = buildPrCommentPrompt(
      "do it",
      "my-api",
      "feat/x",
      undefined,
      null,
      null,
      undefined,
      {
        author: "reviewer",
        body: "Please extract this into utils and styles",
        path: "src/foo.ts",
        line: 42,
        createdAt: "2026-04-22T10:00:00Z",
      },
    );

    expect(output).toContain("## Thread you are replying to (primary context)");
    expect(output).toContain("**Author:** reviewer");
    expect(output).toContain("**Location:** src/foo.ts:42");
    expect(output).toContain("Please extract this into utils and styles");
  });

  test("omits parent thread block when parentThreadComment is null", () => {
    const output = buildPrCommentPrompt(
      "fix the typo",
      "my-api",
      "feat/x",
      undefined,
      null,
      null,
      undefined,
      null,
    );

    expect(output).not.toContain("## Thread you are replying to");
  });
});

describe("buildSlackMentionPrompt", () => {
  // Test 19: includes the mention message text
  test("output includes the user message and repo list", () => {
    const repos: RepoConfig[] = [makeRepoConfig()];
    const output = buildSlackMentionPrompt("How does the auth flow work?", repos);

    expect(output).toContain("How does the auth flow work?");
    expect(output).toContain("# Slack mention");
    expect(output).toContain("**my-api**: Backend API service");
    expect(output).toContain("REPLY_START");
    expect(output).toContain("REPLY_END");
    // Default language is English
    expect(output).toContain("English");
  });

  // Test 19b: Spanish language
  test("requests Spanish when language is 'es'", () => {
    const repos: RepoConfig[] = [makeRepoConfig()];
    const output = buildSlackMentionPrompt("Como funciona el login?", repos, "es");

    expect(output).toContain("Spanish");
  });

  // Test 19c: with Jira issues
  test("includes referenced Jira tickets when provided", () => {
    const repos: RepoConfig[] = [makeRepoConfig()];
    const issues = [makeIssue({ key: "SQDP-99", summary: "Login broken" })];
    const output = buildSlackMentionPrompt("What about SQDP-99?", repos, "en", issues);

    expect(output).toContain("## Referenced Jira Tickets");
    expect(output).toContain("**Key:** SQDP-99");
    expect(output).toContain("Login broken");
  });

  // Test 19d: with Datadog logs
  test("includes Datadog logs section when datadogLogsByTicket is provided", () => {
    const repos: RepoConfig[] = [makeRepoConfig()];
    const logs = { "SQDP-42": ["2025-01-15 ERROR NullPointerException", "2025-01-15 INFO retry"] };
    const output = buildSlackMentionPrompt("What happened?", repos, "en", undefined, logs);

    expect(output).toContain("## Datadog Logs (last 24h)");
    expect(output).toContain("### Logs for SQDP-42");
    expect(output).toContain("ERROR NullPointerException");
  });

  // Test 19e: Datadog logs truncated beyond MAX_LOG_LINES (200)
  test("truncates Datadog logs exceeding 200 lines", () => {
    const repos: RepoConfig[] = [makeRepoConfig()];
    const longLogs = Array.from({ length: 250 }, (_, i) => `line-${i}`);
    const output = buildSlackMentionPrompt("details?", repos, "en", undefined, {
      "SQDP-1": longLogs,
    });

    expect(output).toContain("line-199");
    expect(output).not.toContain("line-200");
    expect(output).toContain("50 more lines truncated");
  });

  // Thread context tests
  test("omits Thread Context block when threadContext is undefined", () => {
    const repos: RepoConfig[] = [makeRepoConfig()];
    const output = buildSlackMentionPrompt("How does X work?", repos, "en");

    expect(output.includes("## Thread Context")).toBe(false);
  });

  test("omits Thread Context block when threadContext is an empty array", () => {
    const repos: RepoConfig[] = [makeRepoConfig()];
    const output = buildSlackMentionPrompt(
      "How does X work?",
      repos,
      "en",
      undefined,
      undefined,
      [],
    );

    expect(output).not.toContain("## Thread Context");
  });

  test("renders Thread Context block before the Question block", () => {
    const repos: RepoConfig[] = [makeRepoConfig()];
    const entries: ThreadContextEntry[] = [
      { author: "sebas", text: "first question", ts: "1714935600.000000", isBot: false },
      { author: "[hu-agent]", text: "previous reply", ts: "1714935700.000000", isBot: true },
    ];
    const output = buildSlackMentionPrompt(
      "follow up?",
      repos,
      "en",
      undefined,
      undefined,
      entries,
    );

    expect(output).toContain("## Thread Context");
    expect(output).toContain("## Question");
    expect(output.indexOf("## Thread Context")).toBeLessThan(output.indexOf("## Question"));
  });

  test("renders bot entries with [hu-agent] author", () => {
    const repos: RepoConfig[] = [makeRepoConfig()];
    const entries: ThreadContextEntry[] = [
      { author: "[hu-agent]", text: "earlier reply", ts: "1714935600.000000", isBot: true },
    ];
    const output = buildSlackMentionPrompt("next?", repos, "en", undefined, undefined, entries);

    expect(output).toContain("**[hu-agent]**");
    expect(output).toContain("earlier reply");
  });

  test("renders human entries with their resolved author name", () => {
    const repos: RepoConfig[] = [makeRepoConfig()];
    const entries: ThreadContextEntry[] = [
      { author: "sebas", text: "hi", ts: "1714935600.000000", isBot: false },
    ];
    const output = buildSlackMentionPrompt(
      "follow up?",
      repos,
      "en",
      undefined,
      undefined,
      entries,
    );

    expect(output).toContain("**sebas**");
    expect(output).toContain(": hi");
  });

  test("collapses newlines inside entry text to single spaces", () => {
    const repos: RepoConfig[] = [makeRepoConfig()];
    const entries: ThreadContextEntry[] = [
      { author: "sebas", text: "line1\n\nline2", ts: "1714935600.000000", isBot: false },
    ];
    const output = buildSlackMentionPrompt(
      "follow up?",
      repos,
      "en",
      undefined,
      undefined,
      entries,
    );

    const bulletLine = output.split("\n").find((l) => l.startsWith("- **sebas**"));
    expect(bulletLine).toBeDefined();
    expect(bulletLine).not.toContain("\n");
    expect(bulletLine).toContain("line1 line2");
  });

  test("truncates entry text longer than 800 chars with an ellipsis", () => {
    const repos: RepoConfig[] = [makeRepoConfig()];
    const longText = "a".repeat(900);
    const entries: ThreadContextEntry[] = [
      { author: "sebas", text: longText, ts: "1714935600.000000", isBot: false },
    ];
    const output = buildSlackMentionPrompt(
      "follow up?",
      repos,
      "en",
      undefined,
      undefined,
      entries,
    );

    const bulletLine = output.split("\n").find((l) => l.startsWith("- **sebas**"));
    expect(bulletLine).toBeDefined();
    expect(bulletLine).toContain("…");
    expect((bulletLine ?? "").length).toBeLessThan(longText.length);
  });

  test("renders timestamp in HH:MM UTC when ts is parseable", () => {
    const repos: RepoConfig[] = [makeRepoConfig()];
    // 1714935600 → 2024-05-05T19:00:00Z
    const entries: ThreadContextEntry[] = [
      { author: "sebas", text: "hi", ts: "1714935600.000000", isBot: false },
    ];
    const output = buildSlackMentionPrompt(
      "follow up?",
      repos,
      "en",
      undefined,
      undefined,
      entries,
    );

    const bulletLine = output.split("\n").find((l) => l.startsWith("- **sebas**"));
    expect(bulletLine).toBeDefined();
    expect(bulletLine).toContain("(19:00)");
  });

  test("omits timestamp parenthetical when ts is unparseable", () => {
    const repos: RepoConfig[] = [makeRepoConfig()];
    const entries: ThreadContextEntry[] = [
      { author: "sebas", text: "hi", ts: "not-a-number", isBot: false },
    ];
    const output = buildSlackMentionPrompt(
      "follow up?",
      repos,
      "en",
      undefined,
      undefined,
      entries,
    );

    const bulletLine = output.split("\n").find((l) => l.startsWith("- **sebas**"));
    expect(bulletLine).toBeDefined();
    // Author is followed directly by ":", no " (" parenthesis between them.
    expect(bulletLine).toContain("**sebas**: hi");
    expect(bulletLine).not.toContain("(");
  });
});

describe("buildPrMentionPrompt", () => {
  test("output includes mention metadata and stripped @hu-agent tag", () => {
    const output = buildPrMentionPrompt(
      "@hu-agent please fix the typo in utils.ts",
      "my-api",
      "feat/new-feature",
      "dev-user",
    );

    expect(output).toContain("# @hu-agent mention");
    expect(output).toContain("**Repository:** my-api");
    expect(output).toContain("**Branch:** feat/new-feature");
    expect(output).toContain("**Requested by:** dev-user");
    // @hu-agent tag should be stripped from the request body
    expect(output).toContain("please fix the typo in utils.ts");
    expect(output).toContain("REPLY_START");
    expect(output).toContain("REPLY_END");
    expect(output).toContain("# General Agent Rules");
  });

  test("includes PR diff, repo rules, and conversation history when provided", () => {
    const output = buildPrMentionPrompt(
      "@hu-agent can you add error handling?",
      "my-api",
      "feat/auth",
      "reviewer",
      "diff --git a/src/auth.ts",
      "## Rules\n- use try/catch",
      "Previous: discussed the approach",
    );

    expect(output).toContain("## PR diff (for context)");
    expect(output).toContain("diff --git a/src/auth.ts");
    expect(output).toContain("## Repository Rules");
    expect(output).toContain("## Previous conversation on this PR");
    expect(output).toContain("discussed the approach");
  });

  test("includes saved images section when provided", () => {
    const output = buildPrMentionPrompt(
      "@hu-agent see screenshot",
      "my-api",
      "fix/bug",
      "user",
      undefined,
      null,
      null,
      [".hu-bot/screenshot.png"],
    );

    expect(output).toContain("## Attachments (saved in .hu-bot/ directory)");
    expect(output).toContain(".hu-bot/screenshot.png");
  });

  test("omits optional sections when not provided", () => {
    const output = buildPrMentionPrompt("help me refactor this", "my-api", "feat/refactor", "dev");

    expect(output).not.toContain("## PR diff");
    expect(output).not.toContain("## Repository Rules");
    expect(output).not.toContain("## Previous conversation");
    expect(output).not.toContain("## Attachments");
  });

  test("instructs reply-only behavior when the request is a question or is ambiguous", () => {
    const output = buildPrMentionPrompt("@hu-agent any comment", "my-api", "fix/SQDP-3", "dev");

    expect(output).toContain(
      "is too vague/ambiguous to act on safely **and** there is no parent review thread to resolve the ambiguity, do **not** modify any files",
    );
  });

  test("scopes the agent strictly to the request and forbids acting on prior reviewer comments", () => {
    const output = buildPrMentionPrompt(
      "@hu-agent take look",
      "my-api",
      "feat/auth",
      "author",
      undefined,
      null,
      "### Review comment (2026-04-22)\n**hu-reviewer**\n> Hardcoded locale...",
    );

    // History is labelled as context only, not as a task list
    expect(output).toContain("CONTEXT ONLY — NOT A TASK LIST");
    expect(output).toContain(
      "Do **not** address, fix, or respond to any of them unless the current request (below) explicitly names them",
    );
    // The request section is clearly marked as the single source of truth for scope
    expect(output).toContain("## Request (the ONLY thing you are asked to do)");
    expect(output).toContain(
      "Do **only** what the request above literally asks. The request is the single source of truth for scope.",
    );
    // Explicitly forbids drive-by fixes
    expect(output).toContain("No drive-by fixes");
  });

  test("renders parent review thread as primary context when provided", () => {
    const output = buildPrMentionPrompt(
      "@hu-agent follow gprinc feedback",
      "my-api",
      "feat/loans",
      "agudeluca",
      undefined,
      null,
      "### Review comment (2026-04-22)\n**gprinc**\n> Extract this into its corresponding files (utils and styles)",
      undefined,
      {
        author: "gprinc",
        body: "Extract this into its corresponding files (utils and styles)",
        path: "app/shared/components/_custom/CurrencyAmount/index.tsx",
        line: 17,
        createdAt: "2026-04-22T10:00:00Z",
      },
    );

    // Dedicated primary-context section precedes the general history
    expect(output).toContain("## Thread you are replying to (primary context)");
    expect(output).toContain("**Author:** gprinc");
    expect(output).toContain(
      "**Location:** app/shared/components/_custom/CurrencyAmount/index.tsx:17",
    );
    expect(output).toContain("Extract this into its corresponding files (utils and styles)");
    // Rule #2 now permits acting on the parent thread when the request is a reply in-thread
    expect(output).toContain(
      'If the mention is a reply inside a review thread (see "Thread you are replying to" above, when present), treat that thread\'s parent comment as the primary target',
    );
    // The "thread" block appears before the general "previous conversation" block
    const threadIdx = output.indexOf("## Thread you are replying to");
    const prevIdx = output.indexOf("## Previous conversation on this PR");
    expect(threadIdx).toBeGreaterThan(-1);
    expect(prevIdx).toBeGreaterThan(threadIdx);
  });

  test("omits parent review thread block when parentThreadComment is null", () => {
    const output = buildPrMentionPrompt(
      "@hu-agent fix this",
      "my-api",
      "feat/x",
      "user",
      undefined,
      null,
      null,
      undefined,
      null,
    );

    expect(output).not.toContain("## Thread you are replying to");
  });
});

describe("buildFixPrompt — optional ticket fields", () => {
  test("includes parent ticket section when issue has a parent", () => {
    const issue = makeIssue({
      parent: {
        id: "20001",
        key: "SQDP-100",
        summary: "Auth epic",
        issueType: "Story",
        status: "In Progress",
        description: "Parent description here",
        attachments: [],
      },
    });
    const output = buildFixPrompt(issue, [makeRepoContext()]);

    expect(output).toContain("## Parent Ticket");
    expect(output).toContain("**Key:** SQDP-100");
    expect(output).toContain("**Summary:** Auth epic");
    expect(output).toContain("**Type:** Story");
    expect(output).toContain("Parent description here");
  });

  test("includes flow, acceptanceCriteria, and stacktrace when present", () => {
    const issue = makeIssue({
      flow: "bugfix",
      acceptanceCriteria: "User should see a success message",
      stacktrace: "TypeError: cannot read property 'id' of null",
    });
    const output = buildFixPrompt(issue, [makeRepoContext()]);

    expect(output).toContain("**Flow:** Bugfix");
    expect(output).toContain("## Acceptance Criteria");
    expect(output).toContain("User should see a success message");
    expect(output).toContain("## Stacktrace");
    expect(output).toContain("TypeError: cannot read property 'id' of null");
  });

  test("includes comments section when issue has comments", () => {
    const issue = makeIssue({
      comments: [
        {
          id: "c1",
          author: { displayName: "Alice", accountId: "a1" },
          body: "I can reproduce this on staging",
          created: "2025-01-16T10:00:00.000Z",
        },
      ],
    });
    const output = buildFixPrompt(issue, [makeRepoContext()]);

    expect(output).toContain("## Comments");
    expect(output).toContain("**Alice**");
    expect(output).toContain("I can reproduce this on staging");
    expect(output).toContain("2025-01-16");
  });

  test("includes checkScripts and rules in repo section", () => {
    const repos = [
      makeRepoContext({
        checkScripts: ["lint", "test"],
        rules: "Always use strict mode",
      }),
    ];
    const output = buildFixPrompt(makeIssue(), repos);

    expect(output).toContain("Pre-commit checks:");
    expect(output).toContain("`npm run lint`");
    expect(output).toContain("`npm run test`");
    expect(output).toContain("**Rules:**");
    expect(output).toContain("Always use strict mode");
  });
});

describe("buildPrCommentPrompt — saved images", () => {
  test("includes attachments section when savedImages are provided", () => {
    const output = buildPrCommentPrompt(
      "See the screenshot",
      "my-api",
      "fix/SQDP-1",
      undefined,
      null,
      null,
      [".hu-bot/error.png", ".hu-bot/ui.jpg"],
    );

    expect(output).toContain("## Attachments (saved in .hu-bot/ directory)");
    expect(output).toContain(".hu-bot/error.png");
    expect(output).toContain(".hu-bot/ui.jpg");
  });
});

describe("Video Attachments block", () => {
  const sampleVideos = [
    {
      slug: "screen_recording",
      originalFilename: "screen recording.mov",
      durationSec: 12.4,
      relativeDir: ".hu-bot/SQDP-1-1700000000/assets/videos/screen_recording",
      frames: [
        { filename: "frame_0001_at_0.0s.jpg", timestampSec: 0 },
        { filename: "frame_0002_at_3.2s.jpg", timestampSec: 3.2 },
        { filename: "frame_0003_at_8.1s.jpg", timestampSec: 8.1 },
      ],
    },
  ];

  test("buildFixPrompt emits the Video Attachments block and lists frame filenames", () => {
    const output = buildFixPrompt(
      makeIssue(),
      [makeRepoContext()],
      undefined,
      undefined,
      sampleVideos,
    );

    expect(output).toContain("## Video Attachments (frames pre-extracted to disk)");
    expect(output).toContain(".hu-bot/SQDP-1-1700000000/assets/videos/screen_recording/");
    expect(output).toContain('originally "screen recording.mov", 12.4s, 3 frames');
    expect(output).toContain("frame_0001_at_0.0s.jpg");
    expect(output).toContain("frame_0003_at_8.1s.jpg");
    expect(output).toContain(
      "You **must** open these frames before commenting on what the user did or saw in the recording.",
    );
  });

  test("buildTriagePrompt forwards processedVideos to ticket context", () => {
    const output = buildTriagePrompt(makeIssue(), [makeRepoContext()], undefined, sampleVideos);
    expect(output).toContain("## Video Attachments (frames pre-extracted to disk)");
    expect(output).toContain('originally "screen recording.mov"');
  });

  test("buildCardReadinessPrompt forwards processedVideos to ticket context", () => {
    const output = buildCardReadinessPrompt(
      makeIssue(),
      [makeRepoContext()],
      undefined,
      sampleVideos,
    );
    expect(output).toContain("## Video Attachments (frames pre-extracted to disk)");
    expect(output).toContain('originally "screen recording.mov"');
    expect(output).toContain("frame_0001_at_0.0s.jpg");
  });

  test("omits the block entirely when no processed videos are provided", () => {
    const output = buildFixPrompt(makeIssue(), [makeRepoContext()]);
    expect(output).not.toContain("## Video Attachments");
  });

  test("renders both image and video blocks when both are present", () => {
    const issue = makeIssue({
      attachments: [
        {
          id: "img-1",
          filename: "screenshot.png",
          mimeType: "image/png",
          size: 200,
          content: "https://example.com/screenshot.png",
          thumbnail: null,
          created: "2025-01-15T10:00:00Z",
        },
      ],
    });
    const output = buildFixPrompt(issue, [makeRepoContext()], undefined, "/tmp/job", sampleVideos);

    expect(output).toContain("## Attachments (saved in /tmp/job/assets/ directory)");
    expect(output).toContain("## Video Attachments (frames pre-extracted to disk)");

    const imgIdx = output.indexOf("## Attachments");
    const vidIdx = output.indexOf("## Video Attachments");
    expect(imgIdx).toBeGreaterThan(-1);
    expect(vidIdx).toBeGreaterThan(imgIdx);
  });

  test("truncates frame preview list with an ellipsis when there are many frames", () => {
    const manyFrames = Array.from({ length: 12 }, (_, i) => ({
      filename: `frame_${String(i + 1).padStart(4, "0")}_at_${i.toFixed(1)}s.jpg`,
      timestampSec: i,
    }));
    const output = buildFixPrompt(makeIssue(), [makeRepoContext()], undefined, undefined, [
      {
        slug: "long",
        originalFilename: "long.mp4",
        durationSec: 60,
        relativeDir: ".hu-bot/job/assets/videos/long",
        frames: manyFrames,
      },
    ]);

    expect(output).toContain("frame_0001_at_0.0s.jpg");
    expect(output).toContain("frame_0006_at_5.0s.jpg");
    expect(output).toContain(", …");
    expect(output).not.toContain("frame_0007_at_6.0s.jpg");
  });
});
