import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test";
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import {
  extractVideoFrames,
  isFfmpegAvailable,
  parseShowinfoTimestamps,
  resetFfmpegAvailabilityCache,
  VideoFrameError,
  buildDefaultVideoFrameOptions,
  type VideoFrameOptions,
} from "@/utils/video-frames.js";
import { mockLogger } from "../_helpers/mock-logger.js";

interface FakeSpawnResult {
  stdout?: string;
  stderr?: string;
  exitCode: number;
  /** Synchronous side effect run before returning the fake subprocess. */
  beforeExit?: () => void;
}

type FakeSpawnHandler = (cmd: readonly string[]) => FakeSpawnResult;

function fakeSpawn(handler: FakeSpawnHandler) {
  return spyOn(Bun, "spawn").mockImplementation(
    // Bun's spawn signature is overloaded; accept anything in tests.
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    ((args: unknown, _options?: unknown): any => {
      const cmd = Array.isArray(args) ? (args as readonly string[]) : [];
      const result = handler(cmd);
      result.beforeExit?.();
      return {
        stdout: createTextStream(result.stdout ?? ""),
        stderr: createTextStream(result.stderr ?? ""),
        exited: Promise.resolve(result.exitCode),
        pid: 12345,
        exitCode: result.exitCode,
        kill: mock(() => {}),
      };
    }) as unknown as typeof Bun.spawn,
  );
}

function createTextStream(content: string): ReadableStream<Uint8Array> {
  return new ReadableStream({
    start(controller) {
      controller.enqueue(new TextEncoder().encode(content));
      controller.close();
    },
  });
}

let workDir: string;
let outDir: string;
let opts: VideoFrameOptions;

beforeEach(() => {
  resetFfmpegAvailabilityCache();
  workDir = mkdtempSync(join(tmpdir(), "video-frames-"));
  outDir = join(workDir, "frames");
  // mkdir is normally done by the pipeline before calling extract; mirror that.
  // We use mkdtemp's dir as the parent and create outDir explicitly per test.
  opts = buildDefaultVideoFrameOptions("ffmpeg", "ffprobe", { maxFrames: 5 });
});

afterEach(() => {
  rmSync(workDir, { recursive: true, force: true });
});

describe("parseShowinfoTimestamps", () => {
  test("parses sequential ffmpeg showinfo lines", () => {
    const stderr = [
      "[Parsed_showinfo_2 @ 0x140] n:0 pts:0 pts_time:0 pos:0 fmt:rgb24 sar:0/1 s:320x240",
      "[Parsed_showinfo_2 @ 0x140] n:1 pts:30 pts_time:1 pos:1 fmt:rgb24 sar:0/1 s:320x240",
      "[Parsed_showinfo_2 @ 0x140] n:2 pts:96 pts_time:3.2 pos:2 fmt:rgb24 sar:0/1 s:320x240",
    ].join("\n");
    const result = parseShowinfoTimestamps(stderr);
    expect(result.get(0)).toBe(0);
    expect(result.get(1)).toBe(1);
    expect(result.get(2)).toBe(3.2);
  });

  test("ignores lines without n:/pts_time:", () => {
    const stderr = "Stream #0:0: Video: h264\nframe= 5 fps=2.0 size=...";
    const result = parseShowinfoTimestamps(stderr);
    expect(result.size).toBe(0);
  });

  test("returns empty map for empty input", () => {
    expect(parseShowinfoTimestamps("").size).toBe(0);
  });
});

describe("isFfmpegAvailable", () => {
  test("returns true when both binaries exit 0", async () => {
    const spawnSpy = fakeSpawn(() => ({ exitCode: 0 }));
    const log = mockLogger();
    const ok = await isFfmpegAvailable("ffmpeg", "ffprobe", log);
    expect(ok).toBe(true);
    expect(spawnSpy).toHaveBeenCalledTimes(2);
    spawnSpy.mockRestore();
  });

  test("returns false when ffmpeg exits non-zero", async () => {
    const spawnSpy = fakeSpawn((args) => ({
      exitCode: args[0] === "ffmpeg" ? 127 : 0,
    }));
    const ok = await isFfmpegAvailable("ffmpeg", "ffprobe", mockLogger());
    expect(ok).toBe(false);
    spawnSpy.mockRestore();
  });

  test("returns false when ffprobe exits non-zero", async () => {
    const spawnSpy = fakeSpawn((args) => ({
      exitCode: args[0] === "ffprobe" ? 127 : 0,
    }));
    const ok = await isFfmpegAvailable("ffmpeg", "ffprobe", mockLogger());
    expect(ok).toBe(false);
    spawnSpy.mockRestore();
  });

  test("caches the first result so subsequent calls do not respawn", async () => {
    const spawnSpy = fakeSpawn(() => ({ exitCode: 0 }));
    await isFfmpegAvailable("ffmpeg", "ffprobe");
    await isFfmpegAvailable("ffmpeg", "ffprobe");
    await isFfmpegAvailable("ffmpeg", "ffprobe");
    expect(spawnSpy).toHaveBeenCalledTimes(2);
    spawnSpy.mockRestore();
  });

  test("returns false when spawn throws (e.g. binary not found)", async () => {
    const spawnSpy = spyOn(Bun, "spawn").mockImplementation(() => {
      throw new Error("ENOENT");
    });
    const ok = await isFfmpegAvailable("missing-ffmpeg", "missing-ffprobe", mockLogger());
    expect(ok).toBe(false);
    spawnSpy.mockRestore();
  });
});

describe("extractVideoFrames", () => {
  test("happy path: probes duration, runs ffmpeg, renames to timestamp filenames", async () => {
    const inputPath = join(workDir, "video.mp4");
    writeFileSync(inputPath, "fake-mp4");
    const calls: string[][] = [];

    const spawnSpy = fakeSpawn((cmd) => {
      calls.push([...cmd]);
      const isProbe = cmd[0] === "ffprobe";
      if (isProbe) {
        return { stdout: "5.5\n", exitCode: 0 };
      }
      // ffmpeg call: write two fake frames into outDir before resolving
      return {
        stderr: [
          "[Parsed_showinfo_3 @ 0x10] n:0 pts:0 pts_time:0",
          "[Parsed_showinfo_3 @ 0x10] n:1 pts:96 pts_time:3.2",
        ].join("\n"),
        exitCode: 0,
        beforeExit: () => {
          mkdirSync(outDir, { recursive: true });
          writeFileSync(join(outDir, "frame_0001.jpg"), "jpeg-bytes-1");
          writeFileSync(join(outDir, "frame_0002.jpg"), "jpeg-bytes-2");
        },
      };
    });

    const result = await extractVideoFrames(inputPath, outDir, opts, mockLogger());

    expect(result.durationSec).toBe(5.5);
    expect(result.usedFallback).toBe(false);
    expect(result.frames).toHaveLength(2);
    expect(result.frames[0]!.filename).toBe("frame_0001_at_0.0s.jpg");
    expect(result.frames[0]!.timestampSec).toBe(0);
    expect(result.frames[1]!.filename).toBe("frame_0002_at_3.2s.jpg");
    expect(result.frames[1]!.timestampSec).toBe(3.2);

    // Files were renamed on disk
    expect(existsSync(join(outDir, "frame_0001_at_0.0s.jpg"))).toBe(true);
    expect(existsSync(join(outDir, "frame_0002_at_3.2s.jpg"))).toBe(true);
    expect(existsSync(join(outDir, "frame_0001.jpg"))).toBe(false);

    // ffprobe + ffmpeg = 2 spawns
    expect(spawnSpy).toHaveBeenCalledTimes(2);
    expect(calls[0]?.[0]).toBe("ffprobe");
    expect(calls[1]?.[0]).toBe("ffmpeg");
    spawnSpy.mockRestore();
  });

  test("throws VideoFrameError(ffprobe) when ffprobe exits non-zero", async () => {
    const inputPath = join(workDir, "video.mp4");
    writeFileSync(inputPath, "fake-mp4");
    const spawnSpy = fakeSpawn(() => ({
      stderr: "moov atom not found",
      exitCode: 1,
    }));

    await expect(extractVideoFrames(inputPath, outDir, opts, mockLogger())).rejects.toThrow(
      VideoFrameError,
    );
    spawnSpy.mockRestore();
  });

  test("throws VideoFrameError(ffmpeg) when ffmpeg exits non-zero", async () => {
    const inputPath = join(workDir, "video.mp4");
    writeFileSync(inputPath, "fake-mp4");
    const spawnSpy = fakeSpawn((cmd) => {
      if (cmd[0] === "ffprobe") return { stdout: "5.0\n", exitCode: 0 };
      return { stderr: "Encoder error", exitCode: 1 };
    });

    let caught: unknown;
    try {
      await extractVideoFrames(inputPath, outDir, opts, mockLogger());
    } catch (err) {
      caught = err;
    }
    expect(caught).toBeInstanceOf(VideoFrameError);
    expect((caught as VideoFrameError).stage).toBe("ffmpeg");
    spawnSpy.mockRestore();
  });

  test("falls back to single first-frame when scene detection produced zero frames", async () => {
    const inputPath = join(workDir, "video.mp4");
    writeFileSync(inputPath, "fake-mp4");
    const calls: string[][] = [];

    const spawnSpy = fakeSpawn((cmd) => {
      calls.push([...cmd]);
      if (cmd[0] === "ffprobe") return { stdout: "1.0\n", exitCode: 0 };

      // First ffmpeg call: scene detection. Don't write anything (zero frames).
      // Second ffmpeg call: fallback writes frame_0001_at_0.0s.jpg directly.
      const isFallback = cmd.includes("-vframes");
      if (isFallback) {
        return {
          exitCode: 0,
          beforeExit: () => {
            mkdirSync(outDir, { recursive: true });
            writeFileSync(join(outDir, "frame_0001_at_0.0s.jpg"), "jpeg-fallback");
          },
        };
      }
      // Scene-detection call writes nothing
      return { exitCode: 0, stderr: "" };
    });

    const result = await extractVideoFrames(inputPath, outDir, opts, mockLogger());

    expect(result.usedFallback).toBe(true);
    expect(result.frames).toHaveLength(1);
    expect(result.frames[0]!.filename).toBe("frame_0001_at_0.0s.jpg");
    expect(result.frames[0]!.timestampSec).toBe(0);
    // ffprobe + ffmpeg(scene) + ffmpeg(fallback) = 3 spawns
    expect(spawnSpy).toHaveBeenCalledTimes(3);
    spawnSpy.mockRestore();
  });

  test("uses the requested ffmpeg/ffprobe paths from opts", async () => {
    const inputPath = join(workDir, "video.mp4");
    writeFileSync(inputPath, "fake-mp4");
    const calls: string[][] = [];

    const spawnSpy = fakeSpawn((cmd) => {
      calls.push([...cmd]);
      if (cmd[0] === "/usr/local/bin/myffprobe") return { stdout: "2\n", exitCode: 0 };
      return {
        stderr: "[Parsed_showinfo @ 0x] n:0 pts:0 pts_time:0",
        exitCode: 0,
        beforeExit: () => {
          mkdirSync(outDir, { recursive: true });
          writeFileSync(join(outDir, "frame_0001.jpg"), "x");
        },
      };
    });

    const customOpts = buildDefaultVideoFrameOptions(
      "/usr/local/bin/myffmpeg",
      "/usr/local/bin/myffprobe",
      { maxFrames: 5 },
    );
    await extractVideoFrames(inputPath, outDir, customOpts, mockLogger());

    expect(calls[0]?.[0]).toBe("/usr/local/bin/myffprobe");
    expect(calls[1]?.[0]).toBe("/usr/local/bin/myffmpeg");
    spawnSpy.mockRestore();
  });

  test("includes scene threshold and frame cap in the ffmpeg command", async () => {
    const inputPath = join(workDir, "video.mp4");
    writeFileSync(inputPath, "fake-mp4");
    let ffmpegCmd: string[] = [];

    const spawnSpy = fakeSpawn((cmd) => {
      if (cmd[0] === "ffprobe") return { stdout: "10\n", exitCode: 0 };
      ffmpegCmd = [...cmd];
      return {
        stderr: "[Parsed_showinfo @ 0x] n:0 pts:0 pts_time:0",
        exitCode: 0,
        beforeExit: () => {
          mkdirSync(outDir, { recursive: true });
          writeFileSync(join(outDir, "frame_0001.jpg"), "x");
        },
      };
    });

    const customOpts = buildDefaultVideoFrameOptions("ffmpeg", "ffprobe", {
      maxFrames: 17,
      sceneThreshold: 0.25,
      maxDurationSec: 42,
    });
    await extractVideoFrames(inputPath, outDir, customOpts, mockLogger());

    const filterIdx = ffmpegCmd.indexOf("-vf");
    expect(filterIdx).toBeGreaterThan(-1);
    expect(ffmpegCmd[filterIdx + 1]).toContain("scene,0.25");
    const framesIdx = ffmpegCmd.indexOf("-frames:v");
    expect(ffmpegCmd[framesIdx + 1]).toBe("17");
    const durationIdx = ffmpegCmd.indexOf("-t");
    expect(ffmpegCmd[durationIdx + 1]).toBe("42");
    spawnSpy.mockRestore();
  });
});
