import { execFile, spawn } from 'node:child_process';
import { promisify } from 'node:util';
import { mkdtempSync, writeFileSync, readFileSync, existsSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, dirname } from 'node:path';
import { playwrightToNetscape } from './cookies.js';
import type { StorageState } from './cookies.js';

const exec = promisify(execFile);
const BIG_BUFFER = 1 << 26; // 64 MiB

/**
 * Run a tool, turning a non-zero exit into a *readable* error. execFile's default
 * message is just "Command failed: <cmd>" with the real reason hidden on .stderr —
 * surface that, and translate the common Instagram "no video" (image post) case.
 */
/** Error carrying the full, untruncated tool output for the debug log. */
export interface ToolError extends Error {
  /** Full command + exit code + signal + stdout/stderr — for the Cmd/Ctrl+Shift+D log. */
  detail?: string;
}

async function run(file: string, args: string[]): Promise<void> {
  try {
    await exec(file, args, { maxBuffer: BIG_BUFFER });
  } catch (e) {
    const err = e as { stderr?: string; stdout?: string; message?: string; code?: number | string; signal?: string };
    const stderr = (err.stderr ?? '').toString();
    if (/No video formats found/i.test(stderr) || /No video formats found/i.test(err.message ?? '')) {
      throw new Error('이 게시물엔 영상이 없어요 — 이미지 게시물은 대본을 추출할 수 없습니다. 릴스(영상)에서 시도해 주세요.');
    }
    // ffmpeg couldn't produce the audio WAV because the source has no audio track
    // (a truly silent reel — after the audio-guaranteeing download selector, this is the
    // only remaining cause). Say so plainly instead of dumping the raw ffmpeg error.
    if (/does not contain any stream/i.test(stderr)) {
      throw new Error('이 영상엔 소리(오디오)가 없어서 대본을 만들 수 없어요.');
    }
    const tool = file.split(/[\\/]/).pop() ?? file;
    const short = stderr.trim().split('\n').slice(-3).join(' ').slice(0, 400) || (err.message ?? 'unknown error');
    const error: ToolError = new Error(`${tool}: ${short}`);
    // Full detail for the debug log. A crashing whisper-cli prints a backtrace to stderr and
    // the signal (SIGABRT = its own abort/Metal assert, SIGILL = CPU instruction the chip
    // lacks, SIGSEGV = segfault) tells us the real cause — none of which survives the short
    // 3-line toast message. The caller logs error.detail via console.error.
    error.detail = `$ ${tool} ${args.join(' ')}\nexit=${err.code ?? ''} signal=${err.signal ?? ''}\n--- stderr ---\n${stderr}\n--- stdout ---\n${(err.stdout ?? '').toString()}`;
    throw error;
  }
}

export interface TranscribeOptions {
  /** Path to the whisper.cpp ggml model (.bin). */
  modelPath: string;
  ytDlpPath?: string;
  ffmpegPath?: string;
  whisperPath?: string;
  /** Whisper language hint (default 'ko'). */
  language?: string;
  /** Transcription progress 0-100 (whisper-cli). Fires during the slow whisper step so
   *  the UI can show it's working, not frozen. */
  onProgress?: (pct: number) => void;
  /** Playwright storageState file; cookies are passed to yt-dlp for auth'd reels. */
  sessionStatePath?: string;
  /** Where temp files go (default OS tmp). */
  workDir?: string;
}

/**
 * Download a reel, extract its audio, and transcribe it with whisper.cpp.
 * Returns the spoken transcript text. Shells out to yt-dlp / ffmpeg / whisper-cli.
 */
export async function extractScript(url: string, opts: TranscribeOptions): Promise<string> {
  const work = mkdtempSync(join(opts.workDir ?? tmpdir(), 'im-stt-'));
  try {
    const videoPath = join(work, 'video.mp4');
    await downloadVideo(url, videoPath, opts);
    const wavPath = join(work, 'audio.wav');
    await extractAudio(videoPath, wavPath, opts);
    return await runWhisper(wavPath, opts);
  } finally {
    rmSync(work, { recursive: true, force: true });
  }
}

/**
 * Build yt-dlp args that GUARANTEE an audio track. Instagram reels are usually DASH
 * (separate video-only + audio-only streams), so the old `-f mp4/best` grabbed the best
 * *mp4* — a SILENT video-only stream — which made ffmpeg's audio extraction fail with
 * "Output file does not contain any stream" (and saved reels had no sound). Prefer a single
 * combined format that already has both codecs; else merge best video + best audio (yt-dlp
 * needs ffmpeg for the merge and only finds our bundled binary via --ffmpeg-location).
 * Exported for the unit test.
 */
export function buildYtDlpArgs(outPath: string, opts: { ffmpegPath?: string; cookiesFile?: string }): string[] {
  const args = [
    '--no-playlist',
    '--no-warnings',
    '-f',
    'b*[vcodec!=none][acodec!=none]/bv*+ba/b',
    '--merge-output-format',
    'mp4',
    '-o',
    outPath,
  ];
  if (opts.ffmpegPath) args.push('--ffmpeg-location', dirname(opts.ffmpegPath));
  if (opts.cookiesFile) args.push('--cookies', opts.cookiesFile);
  return args;
}

/** Download a reel/post video (with audio) to `outPath` via yt-dlp, using session cookies if present. */
export async function downloadVideo(
  url: string,
  outPath: string,
  opts: Pick<TranscribeOptions, 'ytDlpPath' | 'ffmpegPath' | 'sessionStatePath'>,
): Promise<void> {
  let cookieDir: string | null = null;
  let cookiesFile: string | undefined;
  if (opts.sessionStatePath && existsSync(opts.sessionStatePath)) {
    const state = JSON.parse(readFileSync(opts.sessionStatePath, 'utf8')) as StorageState;
    // The cookie file carries the IG `sessionid` — write it to a PRIVATE temp dir
    // (never next to the user's chosen download folder) and delete it right after.
    cookieDir = mkdtempSync(join(tmpdir(), 'im-cookies-'));
    cookiesFile = join(cookieDir, 'cookies.txt');
    writeFileSync(cookiesFile, playwrightToNetscape(state));
  }
  const args = [...buildYtDlpArgs(outPath, { ffmpegPath: opts.ffmpegPath, cookiesFile }), url];
  try {
    await run(opts.ytDlpPath ?? 'yt-dlp', args);
  } finally {
    if (cookieDir) rmSync(cookieDir, { recursive: true, force: true });
  }
}

async function extractAudio(videoPath: string, wavPath: string, opts: TranscribeOptions): Promise<void> {
  await run(opts.ffmpegPath ?? 'ffmpeg', ['-y', '-i', videoPath, '-ar', '16000', '-ac', '1', '-c:a', 'pcm_s16le', wavPath]);
}

async function runWhisper(wavPath: string, opts: TranscribeOptions): Promise<string> {
  const outBase = wavPath.replace(/\.wav$/, '');
  // -ng (CPU only): on Apple Silicon whisper.cpp defaults to the Metal backend, which
  // compiles ~54 compute kernels at init. Some kernels (flash-attn etc.) fail to compile
  // on older GPUs / older macOS, crashing in whisper_init_from_file_with_params — a
  // "works on my mac, crashes on the user's" bug. Disable GPU everywhere for portability.
  // (Harmless on Windows, whose build is already CPU-only.) -pp streams progress to
  // stderr so the UI shows it's working (small/medium can take minutes, not seconds).
  await runWhisperStreaming(
    opts.whisperPath ?? 'whisper-cli',
    ['-ng', '-pp', '-m', opts.modelPath, '-f', wavPath, '-l', opts.language ?? 'ko', '-otxt', '-of', outBase, '-nt'],
    opts.onProgress,
  );
  const txt = `${outBase}.txt`;
  return existsSync(txt) ? readFileSync(txt, 'utf8').trim() : '';
}

/**
 * Parse the LAST whisper.cpp `-pp` progress percent from a stderr chunk, clamped to 100.
 * whisper.cpp prints `whisper_print_progress_callback: progress =  NN%` (note the padding
 * spaces, and it can overshoot 100% — e.g. `progress = 159%` — so we clamp). Returns null
 * when the chunk has no progress line. Exported for the regex unit test.
 */
export function parseProgress(chunk: string): number | null {
  const m = [...chunk.matchAll(/progress\s*=\s*(\d+)\s*%/g)].pop();
  return m ? Math.min(100, Number(m[1])) : null;
}

/**
 * Run whisper-cli, streaming its `-pp` progress ("progress = NN%") to onProgress so the
 * UI reflects the slow transcription instead of looking frozen. Same readable-error +
 * .detail contract as run() on a non-zero exit / crash.
 */
function runWhisperStreaming(file: string, args: string[], onProgress?: (pct: number) => void): Promise<void> {
  return new Promise((resolve, reject) => {
    const child = spawn(file, args);
    let stderr = '';
    let stdout = '';
    let buf = ''; // hold a trailing partial line so a progress line split across chunks isn't missed
    let last = -1;
    child.stdout.on('data', (d: Buffer) => (stdout += d.toString()));
    child.stderr.on('data', (d: Buffer) => {
      const s = d.toString();
      stderr += s;
      buf += s;
      const nl = buf.lastIndexOf('\n'); // parse only whole lines; keep the remainder for the next chunk
      if (nl === -1) return;
      const pct = parseProgress(buf.slice(0, nl));
      buf = buf.slice(nl + 1);
      if (pct !== null && pct !== last) {
        last = pct;
        onProgress?.(pct);
      }
    });
    child.on('error', (e) => reject(e));
    child.on('close', (code, signal) => {
      if (code === 0) {
        onProgress?.(100);
        resolve();
        return;
      }
      const tool = file.split(/[\\/]/).pop() ?? file;
      const short = stderr.trim().split('\n').slice(-3).join(' ').slice(0, 400) || 'unknown error';
      const error: ToolError = new Error(`${tool}: ${short}`);
      error.detail = `$ ${tool} ${args.join(' ')}\nexit=${code ?? ''} signal=${signal ?? ''}\n--- stderr ---\n${stderr}\n--- stdout ---\n${stdout}`;
      reject(error);
    });
  });
}
