import { existsSync, readdirSync, renameSync, rmSync } from "node:fs";
import { join } from "node:path";
import type { Logger } from "pino";
import { ATTACHMENTS } from "@/core/constants.js";

export interface VideoFrameOptions {
  /** Absolute path to ffmpeg binary (or PATH-resolvable name). */
  ffmpegPath: string;
  /** Absolute path to ffprobe binary (or PATH-resolvable name). */
  ffprobePath: string;
  /** Hard cap on output frames. */
  maxFrames: number;
  /** Truncate input duration to this value (seconds). */
  maxDurationSec: number;
  /** Scene-cut threshold for ffmpeg `select=gt(scene,X)`. */
  sceneThreshold: number;
  /** Cap longest side of the output frame in pixels. */
  longestSidePx: number;
  /** ffmpeg `-qscale:v` value (lower = better; 4 ≈ JPEG q85). */
  jpegQscale: number;
  /** Per-call timeout in milliseconds (kills with SIGTERM→SIGKILL on overrun). */
  timeoutMs: number;
}

export interface ExtractedFrame {
  /** Filename inside the output directory, e.g. `frame_0003_at_3.2s.jpg`. */
  filename: string;
  /** Source-video timestamp in seconds. */
  timestampSec: number;
}

/**
 * A successfully processed video attachment, ready to be referenced in the
 * Agent prompt. `relativeDir` is the workdir-relative path the agent
 * will read (e.g. `<jobDir>/assets/videos/<slug>`).
 */
export interface ProcessedVideo {
  /** Per-attachment subdirectory name under `assets/videos/`. */
  slug: string;
  /** Original Jira filename (preserved for human-readable prompt context). */
  originalFilename: string;
  /** ffprobe-reported source duration in seconds. */
  durationSec: number;
  /** Extracted frames in chronological order. */
  frames: ExtractedFrame[];
  /** Workdir-relative directory containing `frames`, e.g. `<jobDir>/assets/videos/<slug>`. */
  relativeDir: string;
}

export interface ExtractVideoFramesResult {
  /** Frames written to `outDir`, in chronological order. */
  frames: ExtractedFrame[];
  /** Source video duration in seconds, as reported by ffprobe. */
  durationSec: number;
  /** Whether the fallback "first frame at t=0" path was used. */
  usedFallback: boolean;
}

export class VideoFrameError extends Error {
  constructor(
    message: string,
    readonly stage: "ffprobe" | "ffmpeg" | "rename",
    readonly stderrTail?: string,
  ) {
    super(message);
    this.name = "VideoFrameError";
  }
}

let cachedAvailability: { ffmpeg: boolean; ffprobe: boolean } | null = null;

/**
 * Test invocation only: clears the cached availability probe so the next
 * `isFfmpegAvailable` call re-runs the binary checks.
 */
export function resetFfmpegAvailabilityCache(): void {
  cachedAvailability = null;
}

/**
 * Probes ffmpeg + ffprobe by spawning each with `-version`. Cached after the
 * first call so we only pay the spawn cost once per process. Both binaries
 * must be reachable; otherwise video extraction is treated as unsupported.
 */
export async function isFfmpegAvailable(
  ffmpegPath: string,
  ffprobePath: string,
  log?: Logger,
): Promise<boolean> {
  if (cachedAvailability) return cachedAvailability.ffmpeg && cachedAvailability.ffprobe;

  const [ffmpegOk, ffprobeOk] = await Promise.all([
    probeBinary(ffmpegPath, log),
    probeBinary(ffprobePath, log),
  ]);
  cachedAvailability = { ffmpeg: ffmpegOk, ffprobe: ffprobeOk };

  if (!ffmpegOk || !ffprobeOk) {
    log?.warn(
      { ffmpegPath, ffprobePath, ffmpegOk, ffprobeOk },
      "ffmpeg_unavailable_videos_disabled",
    );
  } else {
    log?.info({ ffmpegPath, ffprobePath }, "ffmpeg_available");
  }

  return ffmpegOk && ffprobeOk;
}

async function probeBinary(bin: string, log?: Logger): Promise<boolean> {
  try {
    const proc = Bun.spawn([bin, "-version"], {
      stdin: "ignore",
      stdout: "pipe",
      stderr: "pipe",
    });
    const exitCode = await proc.exited;
    return exitCode === 0;
  } catch (err) {
    log?.debug(
      { bin, err: err instanceof Error ? err.message : String(err) },
      "ffmpeg_probe_error",
    );
    return false;
  }
}

/**
 * Extracts scene-change frames from a video into `outDir` with timestamp-named JPEGs.
 *
 * Pipeline:
 *  1. ffprobe → duration (skip-aware caller can decide; ffmpeg `-t` truncates anyway).
 *  2. ffmpeg select=scene + mpdecimate + scale + showinfo → frame_NNNN.jpg sequence + stderr `pts_time:` log.
 *  3. Pair sequential output files with their source timestamps; rename to `frame_NNNN_at_T.Ts.jpg`.
 *  4. If the chain produces zero frames (very short / static video), fall back to a single frame at t=0.
 */
export async function extractVideoFrames(
  inputPath: string,
  outDir: string,
  opts: VideoFrameOptions,
  log: Logger,
): Promise<ExtractVideoFramesResult> {
  const startedAt = Date.now();
  const durationSec = await probeDuration(inputPath, opts, log);
  log.info({ video: inputPath, durationSec }, "ffmpeg_extracting");

  const filterChain = [
    `select='gt(scene,${opts.sceneThreshold})+eq(n,0)'`,
    "mpdecimate=hi=64*32:lo=32*16:frac=0.33",
    "showinfo",
    "setpts=N/FRAME_RATE/TB",
    `scale='min(${opts.longestSidePx},iw)':-2:flags=lanczos`,
  ].join(",");

  const ffmpegArgs = [
    "-nostdin",
    "-hide_banner",
    "-loglevel",
    "info",
    "-t",
    String(opts.maxDurationSec),
    "-i",
    inputPath,
    "-vf",
    filterChain,
    "-vsync",
    "vfr",
    "-qscale:v",
    String(opts.jpegQscale),
    "-frames:v",
    String(opts.maxFrames),
    "-f",
    "image2",
    join(outDir, "frame_%04d.jpg"),
  ];

  const { exitCode, stderr } = await runChildProcess(
    opts.ffmpegPath,
    ffmpegArgs,
    opts.timeoutMs,
    "ffmpeg",
  );
  if (exitCode !== 0) {
    throw new VideoFrameError(`ffmpeg exited with code ${exitCode}`, "ffmpeg", stderr.slice(-500));
  }

  const timestamps = parseShowinfoTimestamps(stderr);
  const generated = listGeneratedFrames(outDir);

  if (generated.length === 0) {
    log.info({ video: inputPath }, "video_no_scene_changes_detected_fallback_first_frame");
    const fallbackFrames = await runFallbackFirstFrame(inputPath, outDir, opts);
    return {
      frames: fallbackFrames,
      durationSec,
      usedFallback: true,
    };
  }

  const frames = renameFramesWithTimestamps(outDir, generated, timestamps);
  log.info(
    {
      video: inputPath,
      frames: frames.length,
      durationSec,
      durationMs: Date.now() - startedAt,
    },
    "video_frames_extracted",
  );
  return { frames, durationSec, usedFallback: false };
}

async function probeDuration(
  inputPath: string,
  opts: VideoFrameOptions,
  log: Logger,
): Promise<number> {
  const args = [
    "-v",
    "error",
    "-select_streams",
    "v:0",
    "-show_entries",
    "format=duration",
    "-of",
    "csv=p=0",
    inputPath,
  ];
  const { exitCode, stdout, stderr } = await runChildProcess(
    opts.ffprobePath,
    args,
    opts.timeoutMs,
    "ffprobe",
  );
  if (exitCode !== 0) {
    throw new VideoFrameError(
      `ffprobe exited with code ${exitCode}`,
      "ffprobe",
      stderr.slice(-500),
    );
  }
  const trimmed = stdout.trim();
  const parsed = Number.parseFloat(trimmed);
  if (!Number.isFinite(parsed) || parsed < 0) {
    log.warn({ stdout: trimmed }, "ffprobe_duration_unparseable");
    return 0;
  }
  return parsed;
}

async function runFallbackFirstFrame(
  inputPath: string,
  outDir: string,
  opts: VideoFrameOptions,
): Promise<ExtractedFrame[]> {
  const targetName = "frame_0001_at_0.0s.jpg";
  const args = [
    "-nostdin",
    "-hide_banner",
    "-loglevel",
    "error",
    "-i",
    inputPath,
    "-vframes",
    "1",
    "-qscale:v",
    String(opts.jpegQscale),
    "-vf",
    `scale='min(${opts.longestSidePx},iw)':-2:flags=lanczos`,
    join(outDir, targetName),
  ];
  const { exitCode, stderr } = await runChildProcess(
    opts.ffmpegPath,
    args,
    opts.timeoutMs,
    "ffmpeg",
  );
  if (exitCode !== 0) {
    throw new VideoFrameError(
      `ffmpeg fallback exited with code ${exitCode}`,
      "ffmpeg",
      stderr.slice(-500),
    );
  }
  if (!existsSync(join(outDir, targetName))) {
    throw new VideoFrameError("fallback frame file missing after ffmpeg succeeded", "ffmpeg");
  }
  return [{ filename: targetName, timestampSec: 0 }];
}

/**
 * Parses ffmpeg `showinfo` stderr lines into `[outputIndex → sourceTimestamp]`.
 * Output indices are sequential (n:0, n:1, ...) for the frames the filter
 * graph emits, after `select` + `mpdecimate` have run.
 */
export function parseShowinfoTimestamps(stderr: string): Map<number, number> {
  const re = /n:\s*(\d+)\s+pts:\s*\S+\s+pts_time:\s*([\d.]+)/g;
  const out = new Map<number, number>();
  let match: RegExpExecArray | null;
  while ((match = re.exec(stderr)) !== null) {
    const idx = Number.parseInt(match[1]!, 10);
    const ts = Number.parseFloat(match[2]!);
    if (!Number.isFinite(idx) || !Number.isFinite(ts)) continue;
    out.set(idx, ts);
  }
  return out;
}

function listGeneratedFrames(outDir: string): string[] {
  if (!existsSync(outDir)) return [];
  return readdirSync(outDir)
    .filter((f) => /^frame_\d{4}\.jpg$/.test(f))
    .sort();
}

function renameFramesWithTimestamps(
  outDir: string,
  generated: string[],
  timestamps: Map<number, number>,
): ExtractedFrame[] {
  const frames: ExtractedFrame[] = [];
  for (let i = 0; i < generated.length; i++) {
    const original = generated[i]!;
    const seq = i + 1;
    const ts = timestamps.get(i) ?? 0;
    const tsLabel = ts.toFixed(1);
    const newName = `frame_${String(seq).padStart(4, "0")}_at_${tsLabel}s.jpg`;
    try {
      renameSync(join(outDir, original), join(outDir, newName));
      frames.push({ filename: newName, timestampSec: ts });
    } catch {
      // Best-effort: keep the original name if rename fails so the asset
      // is still on disk and the agent can still read it.
      frames.push({ filename: original, timestampSec: ts });
    }
  }
  return frames;
}

interface ChildProcessResult {
  exitCode: number | null;
  stdout: string;
  stderr: string;
}

/**
 * Spawns `bin args` with timeout + SIGTERM→SIGKILL escalation, returning
 * captured stdout/stderr and the exit code. Mirrors the agent CLI clients'
 * subprocess pattern (see `src/agent/`). The `stage` argument is used to tag
 * timeout errors so callers know which subprocess hung, regardless of the
 * binary path (which may be a custom name like `ffprobe-static`).
 */
async function runChildProcess(
  bin: string,
  args: string[],
  timeoutMs: number,
  stage: "ffmpeg" | "ffprobe",
): Promise<ChildProcessResult> {
  const proc = Bun.spawn([bin, ...args], {
    stdin: "ignore",
    stdout: "pipe",
    stderr: "pipe",
  });

  let timedOut = false;
  const timer = setTimeout(async () => {
    timedOut = true;
    try {
      proc.kill("SIGTERM");
      const exited = await Promise.race([
        proc.exited.then(() => true),
        new Promise<false>((r) => setTimeout(() => r(false), 5_000)),
      ]);
      if (!exited) proc.kill("SIGKILL");
    } catch {
      /* best-effort kill */
    }
  }, timeoutMs);

  const [stdoutText, stderrText] = await Promise.all([
    new Response(proc.stdout).text(),
    new Response(proc.stderr).text(),
  ]);
  const exitCode = await proc.exited;
  clearTimeout(timer);

  if (timedOut) {
    throw new VideoFrameError(
      `child process timed out after ${timeoutMs}ms (${bin})`,
      stage,
      stderrText.slice(-500),
    );
  }

  return { exitCode, stdout: stdoutText, stderr: stderrText };
}

/**
 * Cleans up a video file from disk. Used in `finally` after frame extraction
 * so the raw video does not linger in `.hu-bot/`.
 */
export function safeDeleteFile(path: string): void {
  try {
    if (existsSync(path)) rmSync(path, { force: true });
  } catch {
    /* best-effort */
  }
}

/** Default options derived from {@link ATTACHMENTS.VIDEO}. Callers override `maxFrames` per pipeline. */
export function buildDefaultVideoFrameOptions(
  ffmpegPath: string,
  ffprobePath: string,
  overrides?: Partial<VideoFrameOptions>,
): VideoFrameOptions {
  return {
    ffmpegPath,
    ffprobePath,
    maxFrames: ATTACHMENTS.VIDEO.MAX_FRAMES,
    maxDurationSec: ATTACHMENTS.VIDEO.MAX_DURATION_S,
    sceneThreshold: ATTACHMENTS.VIDEO.SCENE_THRESHOLD,
    longestSidePx: ATTACHMENTS.VIDEO.LONGEST_SIDE_PX,
    jpegQscale: ATTACHMENTS.VIDEO.JPEG_QSCALE,
    timeoutMs: ATTACHMENTS.VIDEO.FFMPEG_TIMEOUT_MS,
    ...overrides,
  };
}
