#!/usr/bin/env python3
"""Extract a bounded set of explicitly requested review frames."""

from __future__ import annotations

import argparse
from decimal import Decimal, InvalidOperation
import json
import math
import os
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
from typing import Any

MAX_FRAMES_HARD = 100
MAX_TIMESTAMP_HARD = Decimal("604800")
MAX_TIMEOUT_HARD = 300.0
MAX_DIAGNOSTIC_BYTES = 1024 * 1024


class CLIError(Exception):
    def __init__(self, code: str, message: str, exit_code: int = 2, **details: Any) -> None:
        super().__init__(message)
        self.code = code
        self.message = message
        self.exit_code = exit_code
        self.details = details


class JSONArgumentParser(argparse.ArgumentParser):
    def error(self, message: str) -> None:
        if "--json" in sys.argv[1:]:
            print(json.dumps({"ok": False, "error": {"code": "invalid_arguments", "message": message}}, sort_keys=True))
            raise SystemExit(2)
        super().error(message)


def emit(payload: dict[str, Any], as_json: bool, *, error: bool = False) -> None:
    if as_json:
        print(json.dumps(payload, sort_keys=True, separators=(",", ":")))
    elif error:
        err = payload.get("error", {})
        print(f"{err.get('code', 'error')}: {err.get('message', 'unknown error')}", file=sys.stderr)
    else:
        print(json.dumps(payload, sort_keys=True, indent=2))


def resolve_tool(command: str) -> str | None:
    expanded = os.path.expanduser(command)
    if os.path.sep in expanded or (os.path.altsep and os.path.altsep in expanded):
        path = Path(expanded)
        if path.is_file() and os.access(path, os.X_OK):
            return str(path.resolve())
        return None
    return shutil.which(command)


def canonical_decimal(value: Decimal) -> str:
    rendered = format(value, "f")
    if "." in rendered:
        rendered = rendered.rstrip("0").rstrip(".")
    return "0" if rendered in {"", "-0"} else rendered


def parse_timestamp(raw: str) -> Decimal:
    if len(raw) > 64:
        raise CLIError("invalid_timestamp", f"timestamp is too long: {raw[:32]}…")
    parts = raw.split(":")
    try:
        if len(parts) == 1:
            value = Decimal(parts[0])
        elif len(parts) == 2:
            minutes = Decimal(parts[0])
            seconds = Decimal(parts[1])
            if minutes != minutes.to_integral_value() or minutes < 0 or not 0 <= seconds < 60:
                raise InvalidOperation
            value = minutes * 60 + seconds
        elif len(parts) == 3:
            hours = Decimal(parts[0])
            minutes = Decimal(parts[1])
            seconds = Decimal(parts[2])
            if (
                hours != hours.to_integral_value()
                or minutes != minutes.to_integral_value()
                or hours < 0
                or not 0 <= minutes < 60
                or not 0 <= seconds < 60
            ):
                raise InvalidOperation
            value = hours * 3600 + minutes * 60 + seconds
        else:
            raise InvalidOperation
    except (InvalidOperation, ValueError):
        raise CLIError("invalid_timestamp", f"invalid timestamp: {raw}") from None
    if not value.is_finite() or value < 0:
        raise CLIError("invalid_timestamp", f"timestamp must be finite and nonnegative: {raw}")
    return value


def run_bounded(argv: list[str], timeout: float) -> tuple[int, str]:
    with tempfile.TemporaryFile() as stderr_file:
        try:
            process = subprocess.Popen(
                argv,
                stdin=subprocess.DEVNULL,
                stdout=subprocess.DEVNULL,
                stderr=stderr_file,
            )
        except OSError as exc:
            raise CLIError("tool_start_failed", f"could not start {argv[0]}: {exc}", 3) from exc
        try:
            process.wait(timeout=timeout)
        except subprocess.TimeoutExpired as exc:
            process.kill()
            process.wait()
            raise CLIError("extract_timeout", f"ffmpeg exceeded {timeout:g} seconds", 4) from exc
        size = stderr_file.tell()
        if size > MAX_DIAGNOSTIC_BYTES:
            raise CLIError(
                "diagnostic_output_too_large",
                f"ffmpeg stderr exceeded {MAX_DIAGNOSTIC_BYTES} bytes",
                4,
                stderr_bytes=size,
            )
        stderr_file.seek(0)
        return process.returncode, stderr_file.read().decode("utf-8", errors="replace").strip()


def build_parser() -> argparse.ArgumentParser:
    parser = JSONArgumentParser(description=__doc__)
    parser.add_argument("input", help="source media file")
    parser.add_argument("-o", "--output-dir", required=True, help="directory for extracted JPEG frames")
    parser.add_argument(
        "-t",
        "--timestamp",
        action="append",
        default=[],
        help="timestamp in seconds or [HH:]MM:SS; repeat for each frame",
    )
    parser.add_argument(
        "--timestamps",
        nargs="+",
        default=[],
        metavar="TIME",
        help="additional explicit timestamps",
    )
    parser.add_argument("--ffmpeg", default="ffmpeg", help="ffmpeg executable (default: ffmpeg from PATH)")
    parser.add_argument("--max-frames", type=int, default=24, help="maximum requested frames (1-100)")
    parser.add_argument(
        "--max-timestamp",
        default="86400",
        help="maximum allowed timestamp in seconds (default: 86400; hard maximum: 604800)",
    )
    parser.add_argument("--timeout", type=float, default=30.0, help="per-frame ffmpeg timeout in seconds")
    parser.add_argument("--overwrite", action="store_true", help="allow replacing existing frame files")
    parser.add_argument("--json", action="store_true", help="emit compact JSON")
    return parser


def main(argv: list[str] | None = None) -> int:
    args = build_parser().parse_args(argv)
    try:
        if not 1 <= args.max_frames <= MAX_FRAMES_HARD:
            raise CLIError("invalid_limit", f"--max-frames must be between 1 and {MAX_FRAMES_HARD}")
        if not math.isfinite(args.timeout) or not 0 < args.timeout <= MAX_TIMEOUT_HARD:
            raise CLIError("invalid_limit", f"--timeout must be greater than 0 and at most {MAX_TIMEOUT_HARD:g}")
        max_timestamp = parse_timestamp(args.max_timestamp)
        if max_timestamp <= 0 or max_timestamp > MAX_TIMESTAMP_HARD:
            raise CLIError(
                "invalid_limit",
                f"--max-timestamp must be greater than 0 and at most {canonical_decimal(MAX_TIMESTAMP_HARD)}",
            )

        raw_timestamps = [*args.timestamp, *args.timestamps]
        if not raw_timestamps:
            raise CLIError("timestamps_required", "at least one explicit --timestamp is required")
        if len(raw_timestamps) > args.max_frames:
            raise CLIError(
                "frame_limit_exceeded",
                f"requested {len(raw_timestamps)} frames; limit is {args.max_frames}",
                requested=len(raw_timestamps),
                limit=args.max_frames,
            )
        timestamps = [parse_timestamp(raw) for raw in raw_timestamps]
        for raw, timestamp in zip(raw_timestamps, timestamps):
            if timestamp > max_timestamp:
                raise CLIError(
                    "timestamp_limit_exceeded",
                    f"timestamp {raw} exceeds limit {canonical_decimal(max_timestamp)} seconds",
                    timestamp=raw,
                    limit_seconds=canonical_decimal(max_timestamp),
                )

        source = Path(args.input).expanduser()
        if not source.exists():
            raise CLIError("input_not_found", f"input does not exist: {source}")
        if not source.is_file():
            raise CLIError("invalid_input", f"input is not a regular file: {source}")
        source = source.resolve()

        output_dir = Path(args.output_dir).expanduser().resolve()
        if output_dir.exists() and not output_dir.is_dir():
            raise CLIError("invalid_output_directory", f"output path is not a directory: {output_dir}")
        outputs = [output_dir / f"frame-{index:04d}.jpg" for index in range(1, len(timestamps) + 1)]
        existing = [str(path) for path in outputs if path.exists()]
        if existing and not args.overwrite:
            raise CLIError(
                "output_exists",
                "one or more output frames already exist; use --overwrite to replace them",
                4,
                paths=existing,
            )

        tool = resolve_tool(args.ffmpeg)
        if tool is None:
            raise CLIError("tool_not_found", f"ffmpeg executable not found: {args.ffmpeg}", 3, tool=args.ffmpeg)

        output_dir.mkdir(parents=True, exist_ok=True)
        results: list[dict[str, Any]] = []
        for timestamp, output in zip(timestamps, outputs):
            timestamp_text = canonical_decimal(timestamp)
            command = [
                tool,
                "-hide_banner",
                "-loglevel",
                "error",
                "-nostdin",
                "-ss",
                timestamp_text,
                "-i",
                str(source),
                "-frames:v",
                "1",
                "-q:v",
                "2",
                "-y" if args.overwrite else "-n",
                str(output),
            ]
            returncode, diagnostic = run_bounded(command, args.timeout)
            if returncode != 0:
                raise CLIError(
                    "extract_failed",
                    diagnostic or f"ffmpeg exited {returncode}",
                    1,
                    returncode=returncode,
                    timestamp=timestamp_text,
                    output=str(output),
                )
            if not output.is_file():
                raise CLIError(
                    "output_missing",
                    "ffmpeg reported success but did not create the requested frame",
                    1,
                    timestamp=timestamp_text,
                    output=str(output),
                )
            results.append(
                {
                    "timestamp_seconds": timestamp_text,
                    "output": str(output),
                    "size_bytes": output.stat().st_size,
                }
            )

        emit(
            {
                "ok": True,
                "input": str(source),
                "output_directory": str(output_dir),
                "frame_count": len(results),
                "overwrite": args.overwrite,
                "frames": results,
            },
            args.json,
        )
        return 0
    except CLIError as exc:
        emit(
            {"ok": False, "error": {"code": exc.code, "message": exc.message, **exc.details}},
            args.json,
            error=True,
        )
        return exc.exit_code
    except OSError as exc:
        emit(
            {"ok": False, "error": {"code": "io_error", "message": str(exc)}},
            args.json,
            error=True,
        )
        return 2


if __name__ == "__main__":
    raise SystemExit(main())
