import { spawn } from "node:child_process";
import { stat } from "node:fs/promises";
import { homedir } from "node:os";
import { join } from "node:path";
import { FFMPEG_BIN, FFPROBE_BIN } from "../server/ffmpeg-paths.js";
// ── Helpers ────────────────────────────────────────────────────────────────
/**
 * Run an external command, collecting stdout+stderr.
 * Rejects on spawn error or timeout.
 */
function runCmd(cmd, args, timeoutMs = 30_000) {
    return new Promise((resolve, reject) => {
        const child = spawn(cmd, args);
        const chunks = [];
        child.stdout.on("data", (d) => chunks.push(d));
        child.stderr.on("data", (d) => chunks.push(d));
        const timer = setTimeout(() => {
            child.kill("SIGKILL");
            reject(new Error(`Command timed out after ${timeoutMs}ms: ${cmd} ${args.join(" ")}`));
        }, timeoutMs);
        child.on("error", (err) => {
            clearTimeout(timer);
            reject(err);
        });
        child.on("close", () => {
            clearTimeout(timer);
            resolve(Buffer.concat(chunks).toString("utf-8"));
        });
    });
}
// ── Main analysis ──────────────────────────────────────────────────────────
const NO_AUDIO = {
    hasAudio: false,
    hasMeaningfulAudio: false,
    avgVolume: -999,
    peakVolume: -999,
    silenceRatio: 1.0,
};
/**
 * Analyse audio properties of a media file using ffprobe + ffmpeg.
 *
 * Three-step detection:
 *  1. Stream detection — does the file contain an audio stream?
 *  2. Volume detection — mean & peak volume via volumedetect filter
 *  3. Silence detection — ratio of silence (< -40 dB, min 0.3 s) to total duration
 */
export async function analyzeAudio(filePath) {
    // ── Step 1: Stream detection ────────────────────────────────────────────
    const probeStreams = await runCmd(FFPROBE_BIN, [
        "-v", "error",
        "-show_entries", "stream=codec_type",
        "-of", "csv=p=0",
        filePath,
    ]);
    if (!probeStreams.includes("audio")) {
        return { ...NO_AUDIO };
    }
    // ── Step 2: Volume detection ────────────────────────────────────────────
    const volOutput = await runCmd(FFMPEG_BIN, [
        "-i", filePath,
        "-af", "volumedetect",
        "-f", "null",
        "-",
    ]);
    const meanMatch = volOutput.match(/mean_volume:\s*([-\d.]+)\s*dB/);
    const maxMatch = volOutput.match(/max_volume:\s*([-\d.]+)\s*dB/);
    const avgVolume = meanMatch ? parseFloat(meanMatch[1]) : -999;
    const peakVolume = maxMatch ? parseFloat(maxMatch[1]) : -999;
    // ── Step 3: Silence detection ───────────────────────────────────────────
    const durationOutput = await runCmd(FFPROBE_BIN, [
        "-v", "error",
        "-show_entries", "format=duration",
        "-of", "csv=p=0",
        filePath,
    ]);
    const totalDuration = parseFloat(durationOutput.trim()) || 0;
    let silenceRatio = 1.0;
    if (totalDuration > 0) {
        const silenceOutput = await runCmd(FFMPEG_BIN, [
            "-i", filePath,
            "-af", "silencedetect=noise=-40dB:d=0.3",
            "-f", "null",
            "-",
        ]);
        // Sum all silence_duration values
        const silenceRegex = /silence_duration:\s*([\d.]+)/g;
        let totalSilence = 0;
        let match;
        while ((match = silenceRegex.exec(silenceOutput)) !== null) {
            totalSilence += parseFloat(match[1]);
        }
        silenceRatio = Math.min(totalSilence / totalDuration, 1.0);
    }
    return {
        hasAudio: true,
        hasMeaningfulAudio: avgVolume > -40,
        avgVolume,
        peakVolume,
        silenceRatio,
    };
}
// ── Multi-track audio mixing ──────────────────────────────────────────────
/**
 * Mix multiple audio tracks with volume, delay, fade, and sidechain ducking,
 * then mux with the original video (copy video codec, encode audio as AAC).
 */
export async function mixAudioTracks(opts) {
    const { videoPath, tracks, outputPath } = opts;
    if (tracks.length === 0) {
        throw new Error("mixAudioTracks: at least one track is required");
    }
    // ── Step 1: Get video duration via ffprobe ──────────────────────────────
    const durationOut = await runCmd(FFPROBE_BIN, [
        "-v", "error",
        "-show_entries", "format=duration",
        "-of", "csv=p=0",
        videoPath,
    ]);
    const totalDur = parseFloat(durationOut.trim());
    if (!totalDur || totalDur <= 0) {
        throw new Error(`mixAudioTracks: could not determine video duration for ${videoPath}`);
    }
    // ── Step 2: Build FFmpeg inputs ─────────────────────────────────────────
    // Input 0 = video file, inputs 1..N = audio tracks
    const inputs = ["-i", videoPath];
    for (const track of tracks) {
        inputs.push("-i", track.source);
    }
    // ── Step 3: Build per-track filter chains ───────────────────────────────
    // Each track gets a label [tN] (or [tN_pre] if it will be ducked later)
    const filterParts = [];
    const duckingJobs = [];
    // First pass: figure out which tracks have ducking so we can name labels
    const hasDucking = tracks.map((t) => !!t.ducking);
    // Map from track type to the index (first match) — used for ducking trigger lookup
    const typeToIdx = new Map();
    tracks.forEach((t, i) => {
        if (!typeToIdx.has(t.type)) {
            typeToIdx.set(t.type, i);
        }
    });
    // Build the per-track filter chain
    for (let i = 0; i < tracks.length; i++) {
        const track = tracks[i];
        const inputRef = `[${i + 1}:a]`; // input 0 is video, tracks start at 1
        const filters = [];
        // Volume — always present
        filters.push(`volume=${track.volume}`);
        // Delay
        if (track.delay && track.delay > 0) {
            const delayMs = Math.round(track.delay * 1000);
            filters.push(`adelay=${delayMs}|${delayMs}`);
        }
        // Fade in
        if (track.fadeIn && track.fadeIn > 0) {
            filters.push(`afade=t=in:st=0:d=${track.fadeIn}`);
        }
        // Fade out
        if (track.fadeOut && track.fadeOut > 0) {
            const fadeStart = Math.max(0, totalDur - track.fadeOut);
            filters.push(`afade=t=out:st=${fadeStart}:d=${track.fadeOut}`);
        }
        // Determine label: if this track will be ducked, use _pre suffix
        const label = hasDucking[i] ? `t${i}_pre` : `t${i}`;
        filterParts.push(`${inputRef}${filters.join(",")}[${label}]`);
        // Register ducking job if needed
        if (track.ducking) {
            const triggerIdx = typeToIdx.get(track.ducking.trigger);
            if (triggerIdx === undefined) {
                throw new Error(`mixAudioTracks: ducking trigger type "${track.ducking.trigger}" not found among tracks`);
            }
            // The trigger track's label is its pre-duck label (which is also its
            // final label if the trigger itself isn't ducked — but we reference the
            // pre-duck label regardless since sidechaincompress needs the original signal)
            const triggerPreLabel = hasDucking[triggerIdx] ? `t${triggerIdx}_pre` : `t${triggerIdx}`;
            duckingJobs.push({
                trackIdx: i,
                preLabel: `t${i}_pre`,
                finalLabel: `t${i}`,
                triggerLabel: triggerPreLabel,
                ratio: track.ducking.ratio,
                threshold: track.ducking.threshold ?? 0.02,
            });
        }
    }
    // ── Step 4: Apply sidechain ducking ─────────────────────────────────────
    for (const job of duckingJobs) {
        filterParts.push(`[${job.preLabel}][${job.triggerLabel}]sidechaincompress=threshold=${job.threshold}:ratio=${job.ratio}:attack=200:release=1000[${job.finalLabel}]`);
    }
    // ── Step 5: Final amix ──────────────────────────────────────────────────
    // Final label is `t${i}` regardless of ducking — non-ducked tracks emit
    // directly with that label (see line ~236), and ducked tracks' sidechain
    // output is also labeled `t${i}` (see line ~265). The hasDucking branch
    // is preserved here in comments for documentation only.
    const finalLabels = tracks.map((_, i) => `t${i}`);
    const amixInputs = finalLabels.map((l) => `[${l}]`).join("");
    filterParts.push(`${amixInputs}amix=inputs=${tracks.length}:duration=first[out]`);
    const filterComplex = filterParts.join(";\n");
    // ── Step 6: Run FFmpeg ──────────────────────────────────────────────────
    const ffmpegArgs = [
        ...inputs,
        "-filter_complex", filterComplex,
        "-map", "0:v",
        "-map", "[out]",
        "-c:v", "copy",
        "-c:a", "aac",
        "-y",
        outputPath,
    ];
    await runCmd(FFMPEG_BIN, ffmpegArgs, 5 * 60 * 1000); // 5-minute timeout
    // ── Step 7: Verify output has audio ─────────────────────────────────────
    const verifyOut = await runCmd(FFPROBE_BIN, [
        "-v", "error",
        "-show_entries", "stream=codec_type",
        "-of", "csv=p=0",
        outputPath,
    ]);
    if (!verifyOut.includes("audio")) {
        throw new Error("mixAudioTracks: output file has no audio stream — mixing may have failed");
    }
    // Also verify the file exists and has non-zero size
    const outStat = await stat(outputPath);
    if (outStat.size === 0) {
        throw new Error("mixAudioTracks: output file is empty — mixing failed");
    }
}
/**
 * Pure helper: extract the loudnorm JSON block from ffmpeg's stderr.
 * Returns null when no loudnorm-shaped JSON block is found.
 *
 * The regex looks for a curly block containing the canonical "input_i"
 * key, which uniquely identifies a loudnorm measurement (no other ffmpeg
 * filter emits this key).
 */
export function parseLoudnormJson(stderr) {
    const m = stderr.match(/\{[^{}]*?"input_i"[^{}]*?\}/);
    if (!m)
        return null;
    try {
        return JSON.parse(m[0]);
    }
    catch {
        return null;
    }
}
/**
 * Two-pass EBU R128 loudness normalization.
 *
 * Pass 1: ffmpeg measures input_i / input_tp / input_lra / input_thresh
 *         via the loudnorm filter with print_format=json to stderr.
 * Pass 2: ffmpeg applies the actual normalization with measured values
 *         pinned, ensuring the output hits the target without dynamic-range
 *         pumping. Two-pass is required for ±0.5 LU accuracy on speech.
 */
export async function normalizeLufs(inputPath, outputPath, opts = { target: -14, truePeak: -1.5, lra: 11 }) {
    const pass1Filter = `loudnorm=I=${opts.target}:LRA=${opts.lra}:tp=${opts.truePeak}:print_format=json`;
    const pass1Stderr = await runCmd(FFMPEG_BIN, ["-i", inputPath, "-af", pass1Filter, "-f", "null", "-"], 60_000);
    const measured = parseLoudnormJson(pass1Stderr);
    if (!measured) {
        throw new Error(`normalizeLufs pass-1 failed: no loudnorm JSON in stderr. ` +
            `First 500 chars: ${pass1Stderr.slice(0, 500)}`);
    }
    const pass2Filter = [
        `loudnorm=I=${opts.target}`,
        `LRA=${opts.lra}`,
        `tp=${opts.truePeak}`,
        `measured_I=${measured.input_i}`,
        `measured_LRA=${measured.input_lra}`,
        `measured_TP=${measured.input_tp}`,
        `measured_thresh=${measured.input_thresh}`,
        `linear=true`,
        `print_format=summary`,
    ].join(":");
    // Mux args branch by output container. The pipeline calls us with .mp4
    // (loudnorm sits between Remotion render and the optional final encode);
    // if no exportPreset is configured the loudnorm output IS the final
    // artifact, so it must be web-playable. PCM_S16LE in an mp4 container
    // plus moov-at-end made browsers / QuickTime stutter on playback because
    // PCM-in-mp4 has no decoder fast-path and demux competes with video on
    // the main thread. For audio-only outputs (.wav fixtures in tests) keep
    // PCM since AAC can't be muxed into a WAV container.
    const isVideoContainer = /\.(mp4|mov|m4v|mkv)$/i.test(outputPath);
    const muxArgs = isVideoContainer
        ? [
            "-c:v", "copy",
            "-c:a", "aac",
            "-b:a", "192k",
            "-ar", "48000",
            "-movflags", "+faststart",
        ]
        : ["-c:a", "pcm_s16le", "-ar", "48000"];
    await runCmd(FFMPEG_BIN, [
        "-i", inputPath,
        "-af", pass2Filter,
        ...muxArgs,
        "-y",
        outputPath,
    ], 120_000);
    const outStat = await stat(outputPath);
    if (outStat.size === 0) {
        throw new Error(`normalizeLufs pass-2 produced empty output (ffmpeg may have failed silently). Output: ${outputPath}`);
    }
}
/**
 * Re-measure the integrated loudness of a file (for tests / verification).
 * Returns the integrated-loudness value as a number (e.g. -14.02).
 */
export async function measureLufs(filePath) {
    const stderr = await runCmd(FFMPEG_BIN, ["-i", filePath, "-af", "loudnorm=print_format=json", "-f", "null", "-"], 60_000);
    const m = parseLoudnormJson(stderr);
    if (!m)
        throw new Error(`measureLufs: no loudnorm block in stderr`);
    return parseFloat(m.input_i);
}
// ─── Phase 3.B — Subtitle burning adapter ─────────────────────────────────
const DEFAULT_FONT_PATH = join(homedir(), ".autoviral", "fonts", "NotoSansCJKsc-Regular.otf");
/**
 * Pure helper: walk a Composition's tracks and emit flat-list subtitle JSON
 * matching subtitle_burn.py's parse_json_subs() expected shape:
 *   [{ start: number, end: number, text: string }, ...]
 *
 * Returns the FIRST text track's clips, sorted by trackOffset. If the comp
 * has no text track, returns an empty array. Animations and styling are
 * dropped here — the burn renders static text per Phase 3 decision D2.
 */
export function compositionTextTrackToJson(comp) {
    const textTrack = comp.tracks.find((t) => t.kind === "text");
    if (!textTrack)
        return [];
    return textTrack.clips
        .filter((c) => c.kind === "text" && typeof c.text === "string")
        .slice()
        .sort((a, b) => a.trackOffset - b.trackOffset)
        .map((c) => ({
        start: c.trackOffset,
        end: c.trackOffset + (c.duration ?? 0),
        text: c.text,
    }));
}
/**
 * Phase 3.B font guard. subtitle_burn.py's font_manager import is dead
 * code (audit §11.11), so the script silently relies on the canonical
 * font being pre-installed. Asserting here BEFORE invoking the script gives
 * a "missing font" failure with clear remediation instead of a cryptic
 * moviepy traceback.
 */
export async function assertFontInstalled(fontPath = DEFAULT_FONT_PATH) {
    try {
        const s = await stat(fontPath);
        if (!s.isFile()) {
            throw new Error(`Font path is not a file: ${fontPath}`);
        }
        return fontPath;
    }
    catch (e) {
        const cause = e instanceof Error ? e.message : String(e);
        throw new Error(`Font not installed at ${fontPath} (${cause}). ` +
            `Set AUTOVIRAL_FONT_PATH to a TTF/OTF you have locally, or download Noto Sans CJK SC from ` +
            `https://github.com/notofonts/noto-cjk to ~/.autoviral/fonts/NotoSansCJKsc-Regular.otf`);
    }
}
/**
 * Removed in agentic-terminal refactor (2026-05-14). The libass-style burn-in
 * path lived in skills/autoviral/modules/assembly/scripts/subtitle_burn.py
 * (preserved in git tag pre-skill-rewrite-snapshot). It was workstation
 * infrastructure mis-located in skills/; rather than relocate, we direct
 * callers to the overlay strategy that ships with the workstation:
 * set `composition.captionStrategy = "overlay"` + attach a `captions`
 * model, and the CaptionsLayer React component renders captions in-line
 * during the Remotion render — no Python, no MoviePy, no font-install dance.
 */
export async function burnSubtitles(_opts) {
    throw new Error("burnSubtitles: removed in agentic-terminal refactor. " +
        'Use `composition.captionStrategy = "overlay"` + `composition.captions` ' +
        "for in-render caption rendering via CaptionsLayer. The libass burn-in " +
        "Python script is preserved in git tag pre-skill-rewrite-snapshot.");
}
//# sourceMappingURL=audio-tools.js.map