import { join, resolve, dirname } from "node:path";
import { mkdirSync, writeFileSync, rmSync, existsSync, readFileSync } from "node:fs";
import type { Logger } from "pino";
import { ATTACHMENTS, AGENT, RULES_LOADER } from "@/core/constants.js";
import { JobAbortedError } from "@/core/errors.js";
import { METRIC } from "@/metrics/names.js";
import type { Metrics } from "@/metrics/types.js";
import {
  buildFixPrompt,
  buildFollowupPrompt,
  buildTriagePrompt,
  buildCardReadinessPrompt,
  parseTriageOutput,
  parseCardReadinessOutput,
  parsePrTitle,
  parsePrBody,
} from "@/agent/prompts.js";
import { runWithAuthRetry } from "@/github/auth.js";
import type { GitHubClientImpl } from "@/github/client.js";
import type { JiraClientImpl } from "@/jira/client.js";
import { resolveLabels } from "@/pipeline/label-resolver.js";
import { syncAllRepos, configureGitAuth } from "@/repo/manager.js";
import { syncRemoteRulesRepos } from "@/repo/rules-sync.js";
import type { RepoRegistry } from "@/repo/registry.js";
import type { SlackNotifier } from "@/slack/client.js";
import type {
  AgentRunner,
  JiraAttachment,
  JiraIssue,
  JiraJobPayload,
  JiraSubtask,
  PipelineResult,
  RepoContext,
  RepoHandle,
  RepoPrResult,
  RepoConfig,
  TriageResult,
} from "@/types/index.js";
import { buildBranchName } from "@/utils/slug.js";
import {
  buildVideoAttachmentSlug,
  ensureUniqueVideoSlug,
  sanitizeJiraAttachmentFilename,
  selectJiraIssueImageAttachments,
  selectJiraIssueVideoAttachments,
} from "@/utils/jira-attachments.js";
import {
  buildDefaultVideoFrameOptions,
  extractVideoFrames,
  isFfmpegAvailable,
  safeDeleteFile,
  type ProcessedVideo,
} from "@/utils/video-frames.js";

const HU_BOT_DIR = ".hu-bot";

function safetyReasonSlug(violation: string): string {
  const v = violation.toLowerCase();
  if (v.includes("files changed")) return "too_many_files";
  if (v.includes("lines")) return "too_many_lines";
  if (v.includes("deleted")) return "too_many_deletions";
  if (v.includes("forbidden")) return "forbidden_path";
  if (v.includes("lock")) return "lock_file";
  return "other";
}

function buildJobDir(issueKey: string): string {
  return `${HU_BOT_DIR}/${issueKey}-${Date.now()}`;
}

interface PipelineDeps {
  jiraClient: JiraClientImpl;
  agentCli: AgentRunner;
  repoRegistry: RepoRegistry;
  getGitHubToken: () => Promise<string>;
  workdirPath: string;
  jiraDomain: string;
  botEmail: string;
  slackNotifier?: SlackNotifier;
  getPendingCount: () => number;
  logger: Logger;
  /** Builds a GitHub client for a repo. Injected so the DI root decides real-vs-sandbox. */
  buildGitHubClient: (token: string, repoUrl: string, logger: Logger) => GitHubClientImpl;
  /** When true, download Jira video attachments and extract frames via ffmpeg. */
  videoAttachmentsEnabled: boolean;
  /** Path or PATH-resolvable name of the ffmpeg binary. */
  ffmpegPath: string;
  /** Path or PATH-resolvable name of the ffprobe binary. */
  ffprobePath: string;
  metrics: Metrics;
}

export class FixPipeline {
  private _botAccountId: string | null = null;

  constructor(private readonly deps: PipelineDeps) {}

  async execute(job: JiraJobPayload, signal?: AbortSignal): Promise<PipelineResult> {
    const logger = this.deps.logger.child({ issueKey: job.issueKey, jobKey: job.issueKey });

    try {
      logger.info({ pendingJobs: this.deps.getPendingCount() }, "pipeline_started");

      const transitionAuthor = await this.deps.jiraClient
        .getLastStatusChangeAuthor(job.issueKey)
        .catch(() => null);

      try {
        await this.deps.jiraClient.transitionIssue(job.issueKey, "In Progress");
        logger.info("ticket_moved_to_in_progress");
      } catch (err) {
        logger.warn(
          { error: err instanceof Error ? err.message : String(err) },
          "transition_to_in_progress_failed",
        );
      }

      if (this.deps.slackNotifier) {
        try {
          await this.deps.slackNotifier.notifyTicketStarted(
            job.issueKey,
            job.summary,
            `https://${this.deps.jiraDomain}.atlassian.net/browse/${job.issueKey}`,
            transitionAuthor?.displayName,
          );
        } catch (err) {
          logger.warn(
            { error: err instanceof Error ? err.message : String(err) },
            "slack_ticket_started_failed",
          );
        }
      }

      const issue = await this.deps.jiraClient.getIssue(job.issueKey);

      const branchName = buildBranchName(job.issueKey, job.summary);
      const allRepos = this.deps.repoRegistry.getAll();

      const handles = await syncAllRepos(
        allRepos,
        this.deps.workdirPath,
        branchName,
        logger.child({ module: "repo" }),
        this.deps.getGitHubToken,
      );

      if (handles.length === 0) {
        this.deps.metrics.increment(METRIC.fixOutcome, { outcome: "sync_failed" });
        return {
          success: false,
          issueKey: job.issueKey,
          abortReason: "No repositories could be synced",
          outcome: "completed",
        };
      }

      await syncRemoteRulesRepos(
        allRepos,
        this.deps.workdirPath,
        logger.child({ module: "rules-sync" }),
      );

      const jobDirRel = buildJobDir(job.issueKey);
      const jobDirAbs = join(this.deps.workdirPath, jobDirRel);

      await this.saveImagesToDisk(
        logger,
        jobDirAbs,
        issue.attachments,
        issue.parent?.attachments ?? [],
        ATTACHMENTS.MAX_IMAGES,
      );

      const processedVideos = await this.saveVideoFramesToDisk(
        logger,
        jobDirAbs,
        jobDirRel,
        issue.attachments,
        issue.parent?.attachments ?? [],
        ATTACHMENTS.VIDEO.MAX_VIDEOS_PER_TICKET,
        ATTACHMENTS.VIDEO.MAX_FRAMES,
      );

      const repoContexts = this.loadAllRepoContexts(handles);

      // ── Phase 1: Triage ──────────────────────────────────
      const __tTriage = Date.now();
      const triageResult = await this.runTriage(
        issue,
        repoContexts,
        processedVideos,
        job.issueKey,
        jobDirRel,
        signal,
      );
      this.deps.metrics.increment(METRIC.agentInvocations, { phase: "triage" });
      this.deps.metrics.timing(METRIC.agentDuration, Date.now() - __tTriage, {
        phase: "triage",
      });
      logger.info(
        {
          verdict: triageResult.verdict,
          title: triageResult.title,
          summary: triageResult.summary,
        },
        "triage_completed",
      );
      this.deps.metrics.increment(METRIC.fixTriageVerdict, {
        verdict: triageResult.verdict.toLowerCase(),
      });

      if (triageResult.verdict === "NEEDS_INFO") {
        const commentParts = [
          "*hu-developer-bot triaged this ticket but needs more information before attempting a fix.*",
          "",
          "**What I analyzed:**",
          triageResult.summary.slice(0, 3000),
        ];
        if (triageResult.questions) {
          commentParts.push(
            "",
            "**Questions / Missing info:**",
            triageResult.questions.slice(0, 2000),
          );
        }

        try {
          await this.deps.jiraClient.addComment(job.issueKey, commentParts.join("\n\n"));
          logger.info("triage_needs_info_comment_posted");
        } catch (commentErr) {
          logger.warn(
            {
              error: commentErr instanceof Error ? commentErr.message : String(commentErr),
            },
            "triage_comment_failed",
          );
        }

        try {
          await this.deps.jiraClient.transitionIssue(job.issueKey, "To Do");
          logger.info("ticket_returned_to_todo_for_needs_info");
        } catch (err) {
          logger.warn(
            { error: err instanceof Error ? err.message : String(err) },
            "transition_back_to_todo_failed",
          );
        }

        if (this.deps.slackNotifier) {
          try {
            await this.deps.slackNotifier.notifyNeedsInfo(
              job.issueKey,
              `https://${this.deps.jiraDomain}.atlassian.net/browse/${job.issueKey}`,
              triageResult.summary,
              triageResult.questions ?? undefined,
            );
          } catch (err) {
            logger.warn(
              { error: err instanceof Error ? err.message : String(err) },
              "slack_needs_info_failed",
            );
          }
        }

        this.cleanupJobDir(jobDirAbs);
        this.deps.metrics.increment(METRIC.fixOutcome, { outcome: "needs_info" });
        return {
          success: false,
          issueKey: job.issueKey,
          abortReason: "triage_needs_info",
          outcome: "needs_info",
        };
      }

      const prSummary = job.summary;

      // ── Phase 2: Fix ─────────────────────────────────────
      const prompt = buildFixPrompt(
        issue,
        repoContexts,
        triageResult.summary,
        jobDirRel,
        processedVideos,
      );

      // Snapshot each repo's working tree before the agent runs so we can
      // identify which repos the agent actually touched. Without this,
      // `installDependencies` side effects from `syncAllRepos` make
      // untouched repos look "changed" and produce phantom subtask PRs.
      const preAgentFingerprints = new Map<string, string>();
      for (const handle of handles) {
        preAgentFingerprints.set(handle.repo.name, await handle.manager.workingTreeFingerprint());
      }

      const __tFix = Date.now();
      const cliResult = await this.deps.agentCli.runAgent(
        prompt,
        this.deps.workdirPath,
        AGENT.INITIAL_TIMEOUT_MS,
        { issueKey: job.issueKey, jobDir: jobDirRel, signal },
      );
      this.deps.metrics.increment(METRIC.agentInvocations, { phase: "fix" });
      this.deps.metrics.timing(METRIC.agentDuration, Date.now() - __tFix, {
        phase: "fix",
      });

      this.cleanupJobDir(jobDirAbs);

      // Reset branches where the agent only committed rules-sync artifacts
      // (e.g. .cursor/rules/ symlinks created by syncRemoteRulesRepos).
      for (const handle of handles) {
        const base = handle.repo.defaultBranch ?? "develop";
        const meaningful = await handle.manager.getChangedFiles(base);
        if (meaningful.length === 0 && (await handle.manager.hasCommitsAhead(base))) {
          await handle.manager.resetToBase(base);
          logger.info({ repo: handle.repo.name }, "branch_reset_only_sync_artifacts");
        }
      }

      if (!cliResult.success) {
        logger.warn({ exitCode: cliResult.exitCode }, "agent_cli_failed");

        const agentOutput = cliResult.output.trim();
        if (agentOutput) {
          try {
            const comment = [
              "*hu-developer-bot attempted to fix this ticket but the agent failed.*",
              "",
              "**Agent summary:**",
              agentOutput.slice(0, 3000),
            ].join("\n\n");
            await this.deps.jiraClient.addComment(job.issueKey, comment);
            logger.info("failure_comment_posted");
          } catch (commentErr) {
            logger.warn(
              {
                error: commentErr instanceof Error ? commentErr.message : String(commentErr),
              },
              "failure_comment_failed",
            );
          }
        }

        this.deps.metrics.increment(METRIC.fixOutcome, { outcome: "agent_failed" });
        return {
          success: false,
          issueKey: job.issueKey,
          abortReason: `Agent CLI exited with code ${cliResult.exitCode}: ${cliResult.output.slice(-500)}`,
          outcome: "completed",
        };
      }

      const changedHandles: RepoHandle[] = [];
      for (const handle of handles) {
        const before = preAgentFingerprints.get(handle.repo.name) ?? "unknown";
        const after = await handle.manager.workingTreeFingerprint();
        if (before !== after) {
          changedHandles.push(handle);
        } else {
          logger.debug({ repo: handle.repo.name, fingerprint: after }, "repo_unchanged_skipping");
        }
      }

      if (changedHandles.length === 0) {
        const agentOutput = cliResult.output.trim();
        if (agentOutput) {
          try {
            const comment = [
              "*hu-developer-bot investigated this ticket but could not produce a fix.*",
              "",
              "**Agent summary:**",
              agentOutput.slice(0, 3000),
            ].join("\n\n");
            await this.deps.jiraClient.addComment(job.issueKey, comment);
            logger.info("doubt_comment_posted");
          } catch (err) {
            logger.warn(
              { error: err instanceof Error ? err.message : String(err) },
              "doubt_comment_failed",
            );
          }
        }

        if (this.deps.slackNotifier) {
          try {
            await this.deps.slackNotifier.notifyNoChangesNeeded(
              job.issueKey,
              `https://${this.deps.jiraDomain}.atlassian.net/browse/${job.issueKey}`,
              job.summary,
              agentOutput || undefined,
            );
          } catch (err) {
            logger.warn(
              { error: err instanceof Error ? err.message : String(err) },
              "slack_no_changes_needed_failed",
            );
          }
        }

        this.deps.metrics.increment(METRIC.fixOutcome, { outcome: "no_changes" });
        return {
          success: false,
          issueKey: job.issueKey,
          abortReason: "Agent CLI finished but made no changes in any repository",
          outcome: "completed",
        };
      }

      logger.info({ changedRepos: changedHandles.map((h) => h.repo.name) }, "changes_detected");

      const parentKey = issue.parent?.key ?? null;
      const parentSummary = issue.parent?.summary ?? job.summary;
      const needsNewSubtasks = changedHandles.length > 1;
      const botAccountId = needsNewSubtasks ? await this.resolveBotAccountId() : null;

      let siblingSubtasks: JiraSubtask[] = [];
      if (needsNewSubtasks && parentKey) {
        try {
          const parentIssue = await this.deps.jiraClient.getIssue(parentKey);
          const byKey = new Map<string, JiraSubtask>();
          for (const s of [...parentIssue.subtasks, ...parentIssue.children]) {
            byKey.set(s.key, s);
          }
          siblingSubtasks = [...byKey.values()];
          logger.info(
            { parentKey, siblingCount: siblingSubtasks.length },
            "parent_siblings_fetched",
          );
        } catch (err) {
          logger.warn(
            {
              parentKey,
              error: err instanceof Error ? err.message : String(err),
            },
            "parent_siblings_fetch_failed",
          );
        }
      }

      const prs: RepoPrResult[] = [];

      for (let i = 0; i < changedHandles.length; i++) {
        const handle = changedHandles[i]!;
        const { repo, manager } = handle;
        const repoLogger = logger.child({ repo: repo.name });

        this.deps.metrics.increment(METRIC.repoSelected, { repo: repo.name });

        const diffStats = await manager.getDiffStats();
        repoLogger.info(
          { filesChanged: diffStats.filesChanged, linesChanged: diffStats.totalLinesChanged },
          "diff_stats",
        );
        if (!diffStats.passed) {
          for (const v of diffStats.violations) {
            this.deps.metrics.increment(METRIC.safetyViolation, {
              repo: repo.name,
              reason: safetyReasonSlug(v),
            });
          }
        }

        let subtaskKey: string | undefined;
        let effectiveKey = job.issueKey;
        let effectiveBranch = branchName;

        // The first repo always uses the original issue key.
        // Only additional repos (i > 0) get a new subtask created.
        if (i > 0 && needsNewSubtasks && parentKey && botAccountId) {
          // If the original issue IS already the subtask for this repo (its summary starts with
          // this repo's subtaskPrefix), reuse it instead of creating a redundant subtask.
          const issueAlreadyMappedToRepo =
            repo.subtaskPrefix &&
            issue.summary.toLowerCase().startsWith(`${repo.subtaskPrefix.toLowerCase()} |`);

          if (issueAlreadyMappedToRepo) {
            subtaskKey = job.issueKey;
            repoLogger.info(
              { subtaskKey, branch: effectiveBranch },
              "subtask_reused_original_issue",
            );
          } else {
            const prefix = repo.subtaskPrefix ?? repo.name;
            const existingSibling = siblingSubtasks.find((s) =>
              s.summary.toLowerCase().startsWith(`${prefix.toLowerCase()} |`),
            );

            if (existingSibling) {
              subtaskKey = existingSibling.key;
              effectiveKey = existingSibling.key;
              effectiveBranch = buildBranchName(existingSibling.key, parentSummary);
              try {
                await manager.createBranch(effectiveBranch);
              } catch {
                // Branch may already exist; createBranch can still throw in edge cases.
              }
              repoLogger.info(
                {
                  subtaskKey,
                  branch: effectiveBranch,
                  existingStatus: existingSibling.status,
                },
                "subtask_reused_existing_sibling",
              );
            } else {
              try {
                const subtaskSummary = `${prefix} | ${parentSummary}`;
                const componentIds = issue.components.length
                  ? issue.components
                  : await this.deps.jiraClient.getIssueComponents(parentKey);
                const created = await this.deps.jiraClient.createSubtask(
                  parentKey,
                  subtaskSummary,
                  botAccountId,
                  componentIds.length ? componentIds : undefined,
                );
                subtaskKey = created.key;
                effectiveKey = created.key;
                effectiveBranch = buildBranchName(created.key, parentSummary);
                await manager.createBranch(effectiveBranch);
                repoLogger.info({ subtaskKey, branch: effectiveBranch }, "subtask_branch_created");

                try {
                  await this.deps.jiraClient.transitionIssue(created.key, "In Progress");
                  repoLogger.info({ subtaskKey: created.key }, "subtask_moved_to_in_progress");
                } catch (transitionErr) {
                  repoLogger.warn(
                    {
                      subtaskKey: created.key,
                      error:
                        transitionErr instanceof Error
                          ? transitionErr.message
                          : String(transitionErr),
                    },
                    "subtask_transition_failed",
                  );
                }
              } catch (err) {
                repoLogger.warn(
                  { error: err instanceof Error ? err.message : String(err) },
                  "subtask_creation_failed_using_original",
                );
              }
            }
          }
        }

        const baseBranch = repo.defaultBranch ?? "develop";
        let buildResult = await manager.validateBuild(repo.checkScripts, baseBranch);

        if (!buildResult.success) {
          repoLogger.warn(
            { failedStep: buildResult.failedStep, failedCheck: buildResult.failedCheck },
            "build_failed",
          );

          const checkOutputSnippets = buildResult.checkOutputs
            ? Object.entries(buildResult.checkOutputs)
                .map(([script, out]) => `--- ${script} ---\n${out}`)
                .join("\n\n")
            : "";
          const errorOutput = [buildResult.installOutput, checkOutputSnippets]
            .filter(Boolean)
            .join("\n\n");

          const __tFollowup = Date.now();
          const retryResult = await this.deps.agentCli.runAgent(
            buildFollowupPrompt(repo.name, errorOutput),
            this.deps.workdirPath,
            AGENT.FOLLOWUP_TIMEOUT_MS,
            { issueKey: job.issueKey, signal },
          );
          this.deps.metrics.increment(METRIC.agentInvocations, {
            phase: "followup",
          });
          this.deps.metrics.timing(METRIC.agentDuration, Date.now() - __tFollowup, {
            phase: "followup",
          });

          if (retryResult.success) {
            buildResult = await manager.validateBuild(repo.checkScripts, baseBranch);
          }

          if (!buildResult.success) {
            repoLogger.error({ failedStep: buildResult.failedStep }, "build_failed_after_retry");
            this.deps.metrics.increment(METRIC.fixBuildValidation, {
              repo: repo.name,
              result: "fail",
            });
            continue;
          }
        }
        this.deps.metrics.increment(METRIC.fixBuildValidation, { repo: repo.name, result: "pass" });

        if (!(await manager.hasChanges(baseBranch))) {
          repoLogger.warn("build_passed_but_no_meaningful_changes_detected");
          continue;
        }

        const commitMessage = `fix(${effectiveKey}): ${prSummary}`;

        // Re-configure git auth immediately before pushing. The agent runs with
        // --yolo and may modify git global config or change remote URLs during its run.
        // Refreshing here guarantees the insteadOf rule has a valid token regardless.
        await configureGitAuth(this.deps.getGitHubToken, repoLogger);

        repoLogger.info(
          { branch: effectiveBranch, message: commitMessage },
          "commit_push_starting",
        );
        await manager.commitAndPush(effectiveBranch, commitMessage, baseBranch);
        repoLogger.info({ branch: effectiveBranch }, "commit_push_completed");

        repoLogger.info({ branch: effectiveBranch }, "github_token_refresh_started");
        const buildGhClient = async (): Promise<GitHubClientImpl> =>
          this.deps.buildGitHubClient(
            await this.deps.getGitHubToken(),
            repo.url,
            repoLogger.child({ module: "github" }),
          );
        const githubClient = await buildGhClient();
        repoLogger.info({ branch: effectiveBranch }, "github_token_refreshed");

        const agentPrTitle = parsePrTitle(cliResult.output);
        const prTitle =
          agentPrTitle ??
          githubClient.buildPrTitle(
            effectiveKey,
            prSummary,
            issue?.flow ?? null,
            repo.prTitleTemplate,
          );
        repoLogger.info(
          { titleSource: agentPrTitle ? "agent" : "fallback", title: prTitle },
          "pr_title_resolved",
        );

        const agentOutput = subtaskKey
          ? cliResult.output.replaceAll(job.issueKey, effectiveKey)
          : cliResult.output;
        const agentPrBody = parsePrBody(agentOutput);
        const prBody =
          agentPrBody ??
          githubClient.buildPrBody(
            effectiveKey,
            agentOutput.slice(0, 2000),
            true,
            buildResult.testOutput !== undefined,
            issue.flow ?? undefined,
          );

        repoLogger.info(
          {
            branch: effectiveBranch,
            base: baseBranch,
            title: prTitle,
            titleSource: agentPrTitle ? "agent" : "fallback",
            bodySource: agentPrBody ? "agent" : "fallback",
          },
          "pr_create_started",
        );

        // Brief delay so GitHub has the branch visible after push (avoids 422 on create PR)
        await new Promise((r) => setTimeout(r, 2000));
        repoLogger.info({ branch: effectiveBranch, base: baseBranch }, "pr_create_calling_github");
        const pr = await runWithAuthRetry(
          buildGhClient,
          (client) => client.createPr(effectiveBranch, prTitle, prBody, baseBranch),
          repoLogger,
        );

        repoLogger.info({ prUrl: pr.url, prNumber: pr.number }, "pr_create_completed");
        this.deps.metrics.increment(METRIC.prOpened, { repo: repo.name });

        const resolvedLabels = await resolveLabels(
          githubClient,
          repo,
          issue.flow ?? null,
          issue.key,
          repoLogger,
        );
        repoLogger.info({ resolvedLabels, prNumber: pr.number }, "pr_labels_resolved");
        if (resolvedLabels.length > 0) {
          await runWithAuthRetry(
            buildGhClient,
            (client) => client.addLabels(pr.number, resolvedLabels),
            repoLogger,
          );
          repoLogger.info({ labels: resolvedLabels, prNumber: pr.number }, "pr_labels_added");
        }

        prs.push({ repoName: repo.name, prUrl: pr.url, subtaskKey });

        repoLogger.info({ prUrl: pr.url, subtaskKey }, "pr_created");
        if (this.deps.slackNotifier) {
          try {
            await this.deps.slackNotifier.notifyPrCreated(
              effectiveKey,
              repo.name,
              pr.url,
              prTitle,
              pr.number,
            );
          } catch (err) {
            repoLogger.warn(
              { prUrl: pr.url, error: err instanceof Error ? err.message : String(err) },
              "slack_pr_created_failed",
            );
          }
        }
      }

      if (prs.length === 0) {
        logger.warn(
          { changedRepos: changedHandles.map((h) => h.repo.name) },
          "no_prs_created_all_repos_skipped",
        );
        this.deps.metrics.increment(METRIC.fixOutcome, { outcome: "checks_failed" });
        return {
          success: false,
          issueKey: job.issueKey,
          abortReason: "Changes detected but all repos failed safety or build checks",
          outcome: "completed",
        };
      }

      logger.info({ prs, pendingJobs: this.deps.getPendingCount() }, "pipeline_completed");

      this.deps.metrics.increment(METRIC.fixOutcome, { outcome: "pr_created" });
      return {
        success: true,
        issueKey: job.issueKey,
        repos: prs,
        outcome: "completed",
      };
    } catch (err: unknown) {
      if (err instanceof JobAbortedError || signal?.aborted) {
        try {
          await this.deps.jiraClient.transitionIssue(job.issueKey, "To Do");
          logger.info("ticket_reverted_to_todo_on_abort");
        } catch (revertErr) {
          logger.warn(
            { error: revertErr instanceof Error ? revertErr.message : String(revertErr) },
            "abort_revert_to_todo_failed",
          );
        }
        return {
          success: false,
          issueKey: job.issueKey,
          outcome: "completed",
          abortReason: "aborted_for_shutdown",
        };
      }

      const message = err instanceof Error ? err.message : String(err);
      const cause =
        err instanceof Error && err.cause instanceof Error ? err.cause.message : undefined;
      const stack =
        err instanceof Error ? err.stack?.split("\n").slice(0, 5).join("\n") : undefined;
      logger.error({ error: message, cause, stack }, "job_aborted");

      const jiraUrl = `https://${this.deps.jiraDomain}.atlassian.net/browse/${job.issueKey}`;

      try {
        const comment = [
          "*hu-developer-bot attempted to process this ticket but encountered an unexpected error.*",
          "",
          `**Error:** ${message.slice(0, 1000)}`,
        ].join("\n\n");
        await this.deps.jiraClient.addComment(job.issueKey, comment);
      } catch (commentErr) {
        logger.warn(
          {
            error: commentErr instanceof Error ? commentErr.message : String(commentErr),
          },
          "abort_comment_failed",
        );
      }

      if (this.deps.slackNotifier) {
        try {
          await this.deps.slackNotifier.notifyJobFailed(job.issueKey, jiraUrl, message);
        } catch (slackErr) {
          logger.warn(
            {
              error: slackErr instanceof Error ? slackErr.message : String(slackErr),
            },
            "slack_job_failed_notification_failed",
          );
        }
      }

      this.deps.metrics.increment(METRIC.fixOutcome, { outcome: "error" });
      return {
        success: false,
        issueKey: job.issueKey,
        abortReason: message,
        outcome: "completed",
      };
    }
  }

  private loadAllRepoContexts(handles: RepoHandle[]): RepoContext[] {
    return handles.map(({ repo, manager }) => this.loadRepoContext(repo, manager.getLocalPath()));
  }

  private loadRepoContext(repo: RepoConfig, localRepoPath: string): RepoContext {
    const parts: string[] = [];

    const repoRules = this.loadRepoIndexRules(
      localRepoPath,
      repo.rulesIndexFile ?? ".cursor/rules/hu-agent.mdc",
    );
    if (repoRules) parts.push(repoRules);

    if (repo.rulesFile) {
      const rulesPath = resolve(process.cwd(), repo.rulesFile);
      const botRules = this.loadIndexFile(rulesPath);
      if (botRules) parts.push(botRules);
    }

    return {
      name: repo.name,
      description: repo.description,
      keywords: repo.keywords,
      rules: parts.length > 0 ? parts.join("\n\n") : null,
      checkScripts: repo.checkScripts ?? [],
    };
  }

  private loadRepoIndexRules(localRepoPath: string, indexFilePath: string): string | null {
    return this.loadIndexFile(join(localRepoPath, indexFilePath));
  }

  /**
   * Reads an index file and concatenates the content of all relative markdown links
   * ([label](./path)) found inside it. Links are resolved relative to basePath.
   * Works for both repo-side (.cursor/hu-developer-bot.md) and bot-side (rules/*.mdc) indexes.
   */
  private loadIndexFile(indexPath: string): string | null {
    if (!existsSync(indexPath)) return null;

    let indexContent: string;
    try {
      indexContent = readFileSync(indexPath, "utf-8");
    } catch {
      return null;
    }

    const indexDir = dirname(indexPath);
    const LINK_RE = /\[([^\]]*)\]\(([^)]+)\)/g;
    const sections: string[] = [indexContent];
    let totalSize = indexContent.length;
    const { SIZE_LIMIT, PER_FILE_LIMIT } = RULES_LOADER;

    let match: RegExpExecArray | null;
    while ((match = LINK_RE.exec(indexContent)) !== null) {
      const [, label, href] = match;
      if (!href || /^https?:\/\//i.test(href)) continue;

      const filePath = resolve(indexDir, href);
      try {
        if (!existsSync(filePath)) continue;
        const raw = readFileSync(filePath, "utf-8");
        const remaining = SIZE_LIMIT - totalSize;
        if (remaining <= 0) break;
        const chunk = raw.slice(0, Math.min(remaining, PER_FILE_LIMIT));
        sections.push(`\n## ${label || href}\n${chunk}`);
        totalSize += chunk.length;
      } catch {
        this.deps.logger.debug({ href }, "rules_index_ref_read_error");
      }
    }

    return sections.join("\n");
  }

  private async saveImagesToDisk(
    log: Logger,
    jobDirAbs: string,
    issueAttachments: JiraAttachment[],
    parentAttachments: JiraAttachment[],
    maxImages: number,
  ): Promise<string[]> {
    const allImages = selectJiraIssueImageAttachments(
      issueAttachments,
      parentAttachments,
      maxImages,
    );

    if (allImages.length === 0) return [];

    const assetsDir = join(jobDirAbs, "assets");
    mkdirSync(assetsDir, { recursive: true });

    log.info(
      { count: allImages.length, filenames: allImages.map((a) => a.filename) },
      "downloading_attachments",
    );

    const savedPaths: string[] = [];
    for (const att of allImages) {
      try {
        const buffer = await this.deps.jiraClient.downloadAttachment(att.content);
        const safeName = sanitizeJiraAttachmentFilename(att.filename);
        const filePath = join(assetsDir, safeName);
        writeFileSync(filePath, buffer);
        savedPaths.push(filePath);
      } catch (err) {
        log.warn(
          { filename: att.filename, error: err instanceof Error ? err.message : String(err) },
          "Failed to download attachment, skipping",
        );
      }
    }

    log.info({ saved: savedPaths.length }, "attachments_saved_to_disk");
    return savedPaths;
  }

  private async saveVideoFramesToDisk(
    log: Logger,
    jobDirAbs: string,
    jobDirRel: string,
    issueAttachments: JiraAttachment[],
    parentAttachments: JiraAttachment[],
    maxVideos: number,
    maxFramesPerVideo: number,
  ): Promise<ProcessedVideo[]> {
    if (!this.deps.videoAttachmentsEnabled) return [];

    const videos = selectJiraIssueVideoAttachments(issueAttachments, parentAttachments, maxVideos);
    if (videos.length === 0) return [];

    const ffmpegOk = await isFfmpegAvailable(this.deps.ffmpegPath, this.deps.ffprobePath, log);
    if (!ffmpegOk) {
      log.warn({ count: videos.length }, "ffmpeg_unavailable_skipping_video_attachments");
      return [];
    }

    const totalSize = videos.reduce((acc, v) => acc + v.size, 0);
    log.info(
      { count: videos.length, filenames: videos.map((v) => v.filename), totalSize },
      "downloading_videos",
    );

    const videosDirAbs = join(jobDirAbs, "assets", "videos");
    mkdirSync(videosDirAbs, { recursive: true });

    const processed: ProcessedVideo[] = [];
    for (const video of videos) {
      let slug: string;
      let slugDirAbs: string;
      try {
        slug = ensureUniqueVideoSlug(videosDirAbs, buildVideoAttachmentSlug(video.filename));
        slugDirAbs = join(videosDirAbs, slug);
        mkdirSync(slugDirAbs, { recursive: true });
      } catch (err) {
        log.warn(
          {
            filename: video.filename,
            error: err instanceof Error ? err.message : String(err),
          },
          "Failed to prepare video output dir, skipping",
        );
        continue;
      }

      const safeName = sanitizeJiraAttachmentFilename(video.filename);
      const rawPath = join(slugDirAbs, `__source__${safeName}`);
      let downloaded = false;
      try {
        const buffer = await this.deps.jiraClient.downloadAttachment(video.content);
        writeFileSync(rawPath, buffer);
        downloaded = true;
      } catch (err) {
        log.warn(
          {
            filename: video.filename,
            error: err instanceof Error ? err.message : String(err),
          },
          "Failed to download video, skipping",
        );
        continue;
      }

      try {
        const opts = buildDefaultVideoFrameOptions(this.deps.ffmpegPath, this.deps.ffprobePath, {
          maxFrames: maxFramesPerVideo,
        });
        const result = await extractVideoFrames(rawPath, slugDirAbs, opts, log);
        processed.push({
          slug,
          originalFilename: video.filename,
          durationSec: result.durationSec,
          frames: result.frames,
          relativeDir: `${jobDirRel}/assets/videos/${slug}`,
        });
      } catch (err) {
        log.warn(
          {
            filename: video.filename,
            error: err instanceof Error ? err.message : String(err),
          },
          "Failed to extract video frames, skipping",
        );
      } finally {
        if (downloaded) safeDeleteFile(rawPath);
      }
    }

    log.info(
      { videos: processed.length, totalFrames: processed.reduce((a, p) => a + p.frames.length, 0) },
      "videos_saved_to_disk",
    );
    return processed;
  }

  private async resolveBotAccountId(): Promise<string | null> {
    if (this._botAccountId) return this._botAccountId;

    if (!this.deps.botEmail) return null;

    try {
      const accountId = await this.deps.jiraClient.resolveAccountIdForBotEmail(this.deps.botEmail);
      if (accountId) {
        this._botAccountId = accountId;
        this.deps.logger.info({ accountId }, "bot_account_resolved_for_subtasks");
      }
      return this._botAccountId;
    } catch {
      return null;
    }
  }

  private cleanupJobDir(dirAbs: string): void {
    if (existsSync(dirAbs)) {
      rmSync(dirAbs, { recursive: true, force: true });
    }
  }

  private async runTriage(
    issue: JiraIssue,
    repoContexts: RepoContext[],
    processedVideos: ProcessedVideo[],
    issueKey?: string,
    jobDirRel?: string,
    signal?: AbortSignal,
  ): Promise<TriageResult> {
    const log =
      issueKey !== undefined
        ? this.deps.logger.child({ issueKey, jobKey: issueKey })
        : this.deps.logger;
    const prompt = buildTriagePrompt(issue, repoContexts, jobDirRel, processedVideos);

    const cliResult = await this.deps.agentCli.runAgent(
      prompt,
      this.deps.workdirPath,
      AGENT.TRIAGE_TIMEOUT_MS,
      issueKey ? { issueKey, jobDir: jobDirRel, signal } : undefined,
    );

    if (!cliResult.success) {
      log.warn({ exitCode: cliResult.exitCode }, "triage_cli_failed_defaulting_to_proceed");
      return {
        verdict: "PROCEED",
        title: null,
        summary: "Triage agent failed; proceeding with fix attempt.",
        questions: null,
        raw: cliResult.output,
      };
    }

    return parseTriageOutput(cliResult.output);
  }

  private async runCardReadiness(
    issue: JiraIssue,
    repoContexts: RepoContext[],
    processedVideos: ProcessedVideo[],
    issueKey?: string,
    jobDirRel?: string,
  ): Promise<TriageResult> {
    const log =
      issueKey !== undefined
        ? this.deps.logger.child({ issueKey, jobKey: issueKey })
        : this.deps.logger;
    const prompt = buildCardReadinessPrompt(issue, repoContexts, jobDirRel, processedVideos);

    // No abort signal here: card-readiness runs only on the debug/admin path, not
    // from the queue. If this ever becomes queue-driven, thread `signal` through
    // like the triage/fix/followup runAgent calls so shutdown can abort it.
    const cliResult = await this.deps.agentCli.runAgent(
      prompt,
      this.deps.workdirPath,
      AGENT.TRIAGE_TIMEOUT_MS,
      issueKey ? { issueKey, jobDir: jobDirRel } : undefined,
    );

    if (!cliResult.success) {
      log.warn({ exitCode: cliResult.exitCode }, "card_readiness_cli_failed");
      return {
        verdict: "RUN_FAILED",
        title: null,
        summary: `Readiness agent did not complete successfully (exit code ${cliResult.exitCode ?? "unknown"}).`,
        questions: null,
        raw: cliResult.output,
      };
    }

    const parsed = parseCardReadinessOutput(cliResult.output);
    log.info(
      {
        verdict: parsed.verdict,
        title: parsed.title,
        summary: parsed.summary,
      },
      "card_readiness_agent_completed",
    );
    return parsed;
  }

  /**
   * Shared path for {@link triage} and {@link evaluateCardReadiness}: fetch issue, sync repos,
   * save images, load repo contexts, run the given agent, then clean up the job dir.
   * Does not transition issues or mutate Jira — only reads issue data via `getIssue`.
   *
   * @param onProgress — Optional hook while long steps run (repo sync, agent).
   */
  private async executeReadOnlyIssueAssessment(
    issueKey: string,
    params: {
      onProgress?: (message: string) => void;
      maxIssueImages: number;
      maxIssueVideos: number;
      maxFramesPerVideo: number;
      runningAgentMessage: string;
      runAgent: (
        issue: JiraIssue,
        repoContexts: RepoContext[],
        processedVideos: ProcessedVideo[],
        key: string,
        jobDirRel: string,
      ) => Promise<TriageResult>;
    },
  ): Promise<TriageResult> {
    const { logger } = this.deps;
    const issueLogger = logger.child({ issueKey, jobKey: issueKey });
    const progress = params.onProgress;

    progress?.("Fetching issue from Jira…");
    const issue = await this.deps.jiraClient.getIssue(issueKey);
    const allRepos = this.deps.repoRegistry.getAll();
    const branchName = buildBranchName(issueKey, issue.summary);

    progress?.(
      `Syncing ${allRepos.length} registered repo(s) (clone/pull, branch, install — often several minutes)…`,
    );
    const handles = await syncAllRepos(
      allRepos,
      this.deps.workdirPath,
      branchName,
      issueLogger.child({ module: "repo" }),
      this.deps.getGitHubToken,
    );

    const jobDirRel = buildJobDir(issueKey);
    const jobDirAbs = join(this.deps.workdirPath, jobDirRel);

    progress?.("Preparing images from attachments…");
    await this.saveImagesToDisk(
      issueLogger,
      jobDirAbs,
      issue.attachments,
      issue.parent?.attachments ?? [],
      params.maxIssueImages,
    );

    progress?.("Preparing video frames from attachments…");
    const processedVideos = await this.saveVideoFramesToDisk(
      issueLogger,
      jobDirAbs,
      jobDirRel,
      issue.attachments,
      issue.parent?.attachments ?? [],
      params.maxIssueVideos,
      params.maxFramesPerVideo,
    );

    const repoContexts = this.loadAllRepoContexts(handles);
    progress?.(params.runningAgentMessage);
    const result = await params.runAgent(issue, repoContexts, processedVideos, issueKey, jobDirRel);

    this.cleanupJobDir(jobDirAbs);
    return result;
  }

  /**
   * Run only the **bug triage** phase for a given issue key (e.g. debug `POST /api/pipeline/triage`).
   * Does not transition issues or mutate Jira — only reads issue data via `getIssue`.
   */
  async triage(
    issueKey: string,
    options?: { onProgress?: (message: string) => void },
  ): Promise<TriageResult> {
    return this.executeReadOnlyIssueAssessment(issueKey, {
      onProgress: options?.onProgress,
      maxIssueImages: ATTACHMENTS.MAX_IMAGES,
      maxIssueVideos: ATTACHMENTS.VIDEO.MAX_VIDEOS_PER_TICKET,
      maxFramesPerVideo: ATTACHMENTS.VIDEO.MAX_FRAMES,
      runningAgentMessage: "Running triage agent (read-only prompt; timeout up to ~16 minutes)…",
      runAgent: (issue, repoContexts, processedVideos, key, jobDirRel) =>
        this.runTriage(issue, repoContexts, processedVideos, key, jobDirRel),
    });
  }

  /**
   * Implementation readiness (ticket-review pipeline). Same setup as {@link triage}
   * but runs {@link runCardReadiness} and allows more Jira image attachments.
   */
  async evaluateCardReadiness(
    issueKey: string,
    options?: { onProgress?: (message: string) => void },
  ): Promise<TriageResult> {
    return this.executeReadOnlyIssueAssessment(issueKey, {
      onProgress: options?.onProgress,
      maxIssueImages: ATTACHMENTS.MAX_JIRA_ISSUE_IMAGES,
      maxIssueVideos: ATTACHMENTS.VIDEO.MAX_VIDEOS_READINESS,
      maxFramesPerVideo: ATTACHMENTS.VIDEO.MAX_FRAMES_READINESS,
      runningAgentMessage:
        "Running implementation-readiness agent (read-only; timeout up to ~16 minutes)…",
      runAgent: (issue, repoContexts, processedVideos, key, jobDirRel) =>
        this.runCardReadiness(issue, repoContexts, processedVideos, key, jobDirRel),
    });
  }
}
