#!/usr/bin/env python3
"""Produce bounded audio evidence and a reviewable podcast edit plan."""

from __future__ import annotations

import argparse
import hashlib
import json
import math
import os
import re
import shutil
import subprocess
import sys
from pathlib import Path
from typing import Any


class AudioError(Exception):
    def __init__(self, status: str, message: str, exit_code: int = 2) -> None:
        super().__init__(message)
        self.status = status
        self.message = message
        self.exit_code = exit_code


def resolve_tool(name: str) -> str:
    expanded = os.path.expanduser(name)
    if os.path.sep in expanded:
        candidate = Path(expanded)
        if candidate.is_file() and os.access(candidate, os.X_OK):
            return str(candidate.resolve())
    else:
        resolved = shutil.which(name)
        if resolved:
            return resolved
    raise AudioError("missing_tool", f"executable not found: {name}", 3)


def run(argv: list[str], timeout: float) -> subprocess.CompletedProcess[str]:
    try:
        return subprocess.run(
            argv,
            stdin=subprocess.DEVNULL,
            capture_output=True,
            text=True,
            timeout=timeout,
            check=False,
        )
    except subprocess.TimeoutExpired as exc:
        raise AudioError("timeout", f"command exceeded {timeout:g} seconds", 4) from exc
    except OSError as exc:
        raise AudioError("tool_start_failed", str(exc), 3) from exc


def require_success(result: subprocess.CompletedProcess[str], status: str) -> None:
    if result.returncode:
        detail = result.stderr.strip()[-2000:] or result.stdout.strip()[-2000:]
        raise AudioError(status, detail or f"command exited {result.returncode}", 1)


def finite(value: Any) -> bool:
    return isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value)


def ffmpeg_window(args: argparse.Namespace) -> list[str]:
    values: list[str] = []
    if args.analysis_start:
        values.extend(["-ss", f"{args.analysis_start:g}"])
    values.extend(["-t", f"{args.analysis_duration:g}"])
    return values


def inventory_filters(ffmpeg: str, timeout: float) -> set[str]:
    result = run([ffmpeg, "-hide_banner", "-filters"], timeout)
    require_success(result, "filter_inventory_failed")
    names: set[str] = set()
    for line in result.stdout.splitlines():
        parts = line.split()
        if len(parts) >= 2 and set(parts[0]).issubset(set(".TSCAVN|->")):
            names.add(parts[1])
    return names


def parse_probe(stdout: str) -> dict[str, Any]:
    try:
        document = json.loads(stdout)
    except json.JSONDecodeError as exc:
        raise AudioError("invalid_json", f"invalid ffprobe JSON: {exc}", 1) from exc
    if not isinstance(document, dict):
        raise AudioError("invalid_json", "ffprobe JSON root must be an object", 1)
    streams = document.get("streams", [])
    if not isinstance(streams, list) or not any(
        isinstance(stream, dict) and stream.get("codec_type") == "audio" for stream in streams
    ):
        raise AudioError("audio_stream_missing", "probe did not report an audio stream", 1)
    return document


def probe_duration(document: dict[str, Any]) -> float | None:
    raw = document.get("format", {}).get("duration")
    try:
        return float(raw)
    except (TypeError, ValueError):
        return None


def parse_silence(log: str, analysis_end: float | None) -> list[dict[str, Any]]:
    starts = [float(value) for value in re.findall(r"silence_start:\s*([-+0-9.eE]+)", log)]
    ends = [
        (float(end), float(duration))
        for end, duration in re.findall(
            r"silence_end:\s*([-+0-9.eE]+)\s*\|\s*silence_duration:\s*([-+0-9.eE]+)",
            log,
        )
    ]
    intervals: list[dict[str, Any]] = []
    for index, start in enumerate(starts):
        if index < len(ends):
            end, measured_duration = ends[index]
            intervals.append({"start": start, "end": end, "duration": measured_duration})
        else:
            intervals.append(
                {
                    "start": start,
                    "end": analysis_end,
                    "duration": None if analysis_end is None else max(0.0, analysis_end - start),
                    "open_ended": True,
                }
            )
    return intervals


def last_measure(pattern: str, log: str) -> float | None:
    values = re.findall(pattern, log, flags=re.MULTILINE)
    if not values:
        return None
    value = values[-1]
    if value.lower() in {"-inf", "inf", "+inf"}:
        return None
    return float(value)


def measurement(
    ffmpeg: str,
    source: str,
    filter_name: str,
    filter_expression: str,
    filters: set[str],
    args: argparse.Namespace,
) -> tuple[dict[str, Any], str | None]:
    if filter_name not in filters:
        return {
            "status": "UNAVAILABLE",
            "filter": filter_name,
            "reason": f"local FFmpeg filter inventory does not contain {filter_name}",
        }, None
    command = [
        ffmpeg,
        "-nostats",
        "-v",
        "info",
        *ffmpeg_window(args),
        "-i",
        source,
        "-map",
        "0:a:0",
        "-af",
        filter_expression,
        "-f",
        "null",
        "-",
    ]
    result = run(command, args.timeout)
    require_success(result, f"{filter_name}_failed")
    return {
        "status": "MEASURED",
        "filter": filter_name,
        "command": command,
        "analysis_window": {
            "start_seconds": args.analysis_start,
            "duration_seconds": args.analysis_duration,
        },
    }, result.stderr


def load_transcript(path: str | None, source_duration: float | None) -> dict[str, Any]:
    if path is None:
        return {"status": "NOT_PROVIDED", "candidates": []}
    try:
        document = json.loads(Path(path).read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError) as exc:
        raise AudioError("invalid_transcript", f"could not load transcript JSON: {exc}") from exc
    if not isinstance(document, dict) or not isinstance(document.get("segments"), list):
        raise AudioError("invalid_transcript", "transcript must contain a segments array")
    quality = document.get("quality")
    if not isinstance(quality, dict) or not quality:
        raise AudioError("invalid_transcript", "transcript must disclose timing/alignment quality")
    candidates: list[dict[str, Any]] = []
    for index, segment in enumerate(document["segments"]):
        if not isinstance(segment, dict):
            raise AudioError("invalid_transcript", f"segment {index} must be an object")
        start, end, text = segment.get("start"), segment.get("end"), segment.get("text")
        if (
            not finite(start)
            or not finite(end)
            or start < 0
            or end <= start
            or not isinstance(text, str)
        ):
            raise AudioError("invalid_transcript", f"segment {index} has invalid timing or text")
        if source_duration is not None and end > source_duration:
            raise AudioError("invalid_transcript", f"segment {index} exceeds source duration")
        candidates.append(
            {
                "id": segment.get("id", f"transcript-{index + 1}"),
                "source_range": {"in": start, "out": end},
                "action": segment.get("proposed_action", "review"),
                "reason": segment.get("reason", "transcript navigation candidate"),
                "evidence": {
                    "type": "transcript",
                    "segment_index": index,
                    "text_sha256": hashlib.sha256(text.encode()).hexdigest(),
                    "timing_quality": quality,
                },
                "confidence": segment.get("confidence"),
                "review_status": "needs_listening_review",
            }
        )
    return {"status": "CANDIDATES_ONLY", "quality": quality, "candidates": candidates}


def write_exclusive(path: str, value: object) -> None:
    target = Path(path).expanduser()
    try:
        with target.open("x", encoding="utf-8") as handle:
            json.dump(value, handle, indent=2, sort_keys=True)
            handle.write("\n")
    except FileExistsError as exc:
        raise AudioError("output_exists", f"refusing to overwrite report: {target}") from exc
    except OSError as exc:
        raise AudioError("report_write_failed", str(exc)) from exc


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("input")
    parser.add_argument("--ffmpeg", default="ffmpeg")
    parser.add_argument("--ffprobe", default="ffprobe")
    parser.add_argument("--timeout", type=float, default=30.0)
    parser.add_argument("--analysis-start", type=float, default=0.0)
    parser.add_argument("--analysis-duration", type=float, default=300.0)
    parser.add_argument("--silence", action="store_true", help="alias for --measure-silence")
    parser.add_argument("--measure-silence", action="store_true")
    parser.add_argument("--measure-loudness", action="store_true")
    parser.add_argument("--measure-clipping", action="store_true")
    parser.add_argument("--silence-threshold", default="-40dB")
    parser.add_argument("--silence-duration", type=float, default=0.5)
    parser.add_argument("--clipping-threshold-db", type=float, default=-0.1)
    parser.add_argument("--transcript", help="timed transcript JSON with disclosed quality")
    parser.add_argument("--handles", type=float, default=0.05)
    parser.add_argument("--fade-duration", type=float, default=0.01)
    parser.add_argument("--target-lufs", type=float)
    parser.add_argument("--true-peak-limit", type=float)
    parser.add_argument("--output-codec")
    parser.add_argument("--output-sample-rate", type=int)
    parser.add_argument("--output-channel-layout")
    parser.add_argument("--report-output", help="write report to a new path; overwrite is refused")
    parser.add_argument("--json", action="store_true")
    return parser


def main(argv: list[str] | None = None) -> int:
    args = build_parser().parse_args(argv)
    try:
        if not 0 < args.timeout <= 300:
            raise AudioError("invalid_limit", "--timeout must be greater than 0 and at most 300")
        if not args.analysis_start >= 0 or not 0 < args.analysis_duration <= 3600:
            raise AudioError("invalid_limit", "analysis window must be within 0-3600 seconds")
        if not 0 < args.silence_duration <= args.analysis_duration:
            raise AudioError(
                "invalid_threshold", "silence duration must fit inside the analysis window"
            )
        if not -20 <= args.clipping_threshold_db <= 0:
            raise AudioError(
                "invalid_threshold", "clipping threshold must be between -20 and 0 dBFS"
            )
        if args.handles < 0 or args.fade_duration < 0:
            raise AudioError("invalid_plan", "handles and fade duration must be non-negative")

        ffprobe = resolve_tool(args.ffprobe)
        source = str(Path(args.input).expanduser())
        probe_command = [
            ffprobe,
            "-v",
            "error",
            "-show_format",
            "-show_streams",
            "-of",
            "json",
            source,
        ]
        probe_result = run(probe_command, args.timeout)
        require_success(probe_result, "probe_failed")
        probe = parse_probe(probe_result.stdout)
        source_duration = probe_duration(probe)

        requested = (
            args.silence or args.measure_silence or args.measure_loudness or args.measure_clipping
        )
        ffmpeg: str | None = None
        filters: set[str] = set()
        ffmpeg_version: str | None = None
        if requested:
            ffmpeg = resolve_tool(args.ffmpeg)
            version_result = run([ffmpeg, "-version"], args.timeout)
            require_success(version_result, "version_failed")
            ffmpeg_version = version_result.stdout.splitlines()[0]
            filters = inventory_filters(ffmpeg, args.timeout)

        evidence: dict[str, Any] = {}
        if args.silence or args.measure_silence:
            result, log = measurement(
                ffmpeg or "ffmpeg",
                source,
                "silencedetect",
                f"silencedetect=noise={args.silence_threshold}:d={args.silence_duration:g}",
                filters,
                args,
            )
            result.update(
                {
                    "evidence_class": "threshold_candidate",
                    "threshold": args.silence_threshold,
                    "minimum_duration_seconds": args.silence_duration,
                    "editorial_status": "needs_listening_review",
                }
            )
            if log is not None:
                analysis_end = args.analysis_start + min(
                    args.analysis_duration,
                    source_duration or args.analysis_duration,
                )
                result["intervals"] = parse_silence(log, analysis_end)
            evidence["silence"] = result

        if args.measure_loudness:
            result, log = measurement(
                ffmpeg or "ffmpeg",
                source,
                "ebur128",
                "ebur128=peak=true",
                filters,
                args,
            )
            if log is not None:
                result["integrated_lufs"] = last_measure(r"^\s*I:\s+(-?inf|[-+0-9.]+)\s+LUFS", log)
                result["true_peak_dbfs"] = last_measure(
                    r"^\s*Peak:\s+(-?inf|[-+0-9.]+)\s+dBFS", log
                )
                result["target_lufs"] = args.target_lufs
                result["true_peak_limit_dbfs"] = args.true_peak_limit
            evidence["loudness"] = result

        if args.measure_clipping:
            result, log = measurement(
                ffmpeg or "ffmpeg",
                source,
                "astats",
                "astats=metadata=1:reset=0",
                filters,
                args,
            )
            if log is not None:
                peak = last_measure(r"Peak level dB:\s+(-?inf|[-+0-9.]+)", log)
                result.update(
                    {
                        "peak_level_dbfs": peak,
                        "candidate_threshold_dbfs": args.clipping_threshold_db,
                        "clipping_candidate": peak is not None
                        and peak >= args.clipping_threshold_db,
                        "editorial_status": "needs_listening_review",
                    }
                )
            evidence["clipping"] = result

        transcript = load_transcript(args.transcript, source_duration)
        plan = {
            "schema_version": 1,
            "source": source,
            "source_preservation": "original_untouched",
            "overwrite_policy": "refuse",
            "output_contract": {
                "target_lufs": args.target_lufs,
                "true_peak_limit_dbfs": args.true_peak_limit,
                "codec": args.output_codec,
                "sample_rate": args.output_sample_rate,
                "channel_layout": args.output_channel_layout,
            },
            "candidates": [
                {
                    **candidate,
                    "handles_seconds": args.handles,
                    "fade_seconds": args.fade_duration,
                }
                for candidate in transcript["candidates"]
            ],
            "assembly_options": {
                "voice_music_separation": "UNPLANNED",
                "ducking": "UNPLANNED",
                "intro_outro": "UNPLANNED",
                "chapters": "UNPLANNED",
                "metadata": "UNPLANNED",
            },
            "approval_gate": "no candidate becomes an edit without listening review",
        }
        report = {
            "ok": True,
            "status": "MEASURED_WITH_BOUNDARIES" if evidence else "PROBED",
            "input": source,
            "tool_context": {"ffprobe_command": probe_command, "ffmpeg_version": ffmpeg_version},
            "probe": probe,
            "analysis": evidence,
            "transcript": transcript,
            "podcast_edit_plan": plan,
            "unverified": [
                "speaker identity",
                "transcript semantic accuracy",
                "editorial suitability of silence or clipping candidates",
                "listening quality",
                "downstream compatibility",
            ],
        }
        if args.report_output:
            write_exclusive(args.report_output, report)
        print(
            json.dumps(report, sort_keys=True, separators=(",", ":"))
            if args.json
            else json.dumps(report, indent=2, sort_keys=True)
        )
        return 0
    except AudioError as exc:
        print(json.dumps({"ok": False, "status": exc.status, "error": exc.message}, sort_keys=True))
        return exc.exit_code


if __name__ == "__main__":
    sys.exit(main())
