#!/usr/bin/env python3
"""Inventory files and attach bounded ffprobe JSON metadata."""

from __future__ import annotations

import argparse
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
from typing import Any, Iterator

MAX_FILES_HARD = 1000
MAX_TIMEOUT_HARD = 300.0
MAX_OUTPUT_BYTES_HARD = 16 * 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 run_bounded(argv: list[str], timeout: float, max_output_bytes: int) -> tuple[int, bytes, bytes]:
    with tempfile.TemporaryFile() as stdout_file, tempfile.TemporaryFile() as stderr_file:
        try:
            process = subprocess.Popen(argv, stdin=subprocess.DEVNULL, stdout=stdout_file, 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("probe_timeout", f"ffprobe exceeded {timeout:g} seconds", 4) from exc

        stdout_size = stdout_file.tell()
        stderr_size = stderr_file.tell()
        if stdout_size > max_output_bytes or stderr_size > max_output_bytes:
            raise CLIError(
                "probe_output_too_large",
                f"ffprobe output exceeded {max_output_bytes} bytes",
                4,
                stdout_bytes=stdout_size,
                stderr_bytes=stderr_size,
            )
        stdout_file.seek(0)
        stderr_file.seek(0)
        return process.returncode, stdout_file.read(), stderr_file.read()


def iter_regular_files(root: Path) -> Iterator[Path]:
    if root.is_file():
        yield root
        return
    for current, dirnames, filenames in os.walk(root, followlinks=False):
        dirnames[:] = sorted(name for name in dirnames if not (Path(current) / name).is_symlink())
        for filename in sorted(filenames):
            candidate = Path(current) / filename
            if candidate.is_file():
                yield candidate


def probe_file(tool: str, path: Path, timeout: float, max_output_bytes: int) -> dict[str, Any]:
    argv = [
        tool,
        "-v",
        "error",
        "-print_format",
        "json",
        "-show_format",
        "-show_streams",
        str(path),
    ]
    returncode, stdout, stderr = run_bounded(argv, timeout, max_output_bytes)
    if returncode != 0:
        message = stderr.decode("utf-8", errors="replace").strip()
        raise CLIError("probe_failed", message or f"ffprobe exited {returncode}", 1, returncode=returncode)
    try:
        document = json.loads(stdout.decode("utf-8"))
    except (UnicodeDecodeError, json.JSONDecodeError) as exc:
        raise CLIError("invalid_probe_json", f"ffprobe returned invalid JSON: {exc}", 1) from exc
    if not isinstance(document, dict):
        raise CLIError("invalid_probe_json", "ffprobe JSON root must be an object", 1)
    return document


def build_parser() -> argparse.ArgumentParser:
    parser = JSONArgumentParser(description=__doc__)
    parser.add_argument("path", help="file or directory to inventory")
    parser.add_argument("--ffprobe", default="ffprobe", help="ffprobe executable (default: ffprobe from PATH)")
    parser.add_argument("--max-files", type=int, default=100, help="maximum files to inventory (1-1000)")
    parser.add_argument("--timeout", type=float, default=15.0, help="per-file ffprobe timeout in seconds")
    parser.add_argument(
        "--max-output-bytes",
        type=int,
        default=1024 * 1024,
        help="maximum stdout or stderr bytes accepted from each ffprobe call",
    )
    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_files <= MAX_FILES_HARD:
            raise CLIError("invalid_limit", f"--max-files must be between 1 and {MAX_FILES_HARD}")
        if 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}")
        if not 1024 <= args.max_output_bytes <= MAX_OUTPUT_BYTES_HARD:
            raise CLIError(
                "invalid_limit",
                f"--max-output-bytes must be between 1024 and {MAX_OUTPUT_BYTES_HARD}",
            )

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

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

        selected: list[Path] = []
        truncated = False
        for candidate in iter_regular_files(source):
            if len(selected) == args.max_files:
                truncated = True
                break
            selected.append(candidate.resolve())

        entries: list[dict[str, Any]] = []
        failures = 0
        for candidate in selected:
            stat = candidate.stat()
            entry: dict[str, Any] = {
                "path": str(candidate),
                "size_bytes": stat.st_size,
                "mtime_ns": stat.st_mtime_ns,
            }
            try:
                entry["ffprobe"] = probe_file(tool, candidate, args.timeout, args.max_output_bytes)
                entry["probe_ok"] = True
            except CLIError as exc:
                failures += 1
                entry["probe_ok"] = False
                entry["probe_error"] = {"code": exc.code, "message": exc.message, **exc.details}
            entries.append(entry)

        payload = {
            "ok": failures == 0,
            "schema_version": 1,
            "root": str(source),
            "limits": {
                "max_files": args.max_files,
                "timeout_seconds": args.timeout,
                "max_output_bytes": args.max_output_bytes,
            },
            "file_count": len(entries),
            "probe_failures": failures,
            "truncated": truncated,
            "files": entries,
        }
        emit(payload, args.json)
        return 0 if failures == 0 else 1
    except CLIError as exc:
        error = {"code": exc.code, "message": exc.message, **exc.details}
        emit({"ok": False, "error": error}, 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())
