#!/usr/bin/env python3
"""Fase 0 — inventário multi-fabricante via ONVIF + RTSP/ffprobe.

Núcleo genérico: sem if fabricante == "...".
Hikvision/HiLook entram como quaisquer outras câmeras ONVIF/RTSP.
"""

from __future__ import annotations

import argparse
import json
import os
import re
import shutil
import subprocess
import sys
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from urllib.parse import quote, urlparse, urlunparse

import yaml

ROOT = Path(__file__).resolve().parents[2]
OUT_DIR = ROOT / "results" / "cameras"
DEFAULT_CONFIG = Path(__file__).resolve().parent / "cameras.yaml"


def redact_url(url: str) -> str:
    if not url:
        return url
    return re.sub(r"(rtsp://[^:/?#]+):([^@/]+)@", r"\1:***@", url)


def which(bin_name: str) -> str | None:
    return shutil.which(bin_name)


def load_config(path: Path) -> dict[str, Any]:
    data = yaml.safe_load(path.read_text(encoding="utf-8"))
    if not isinstance(data, dict) or "cameras" not in data:
        raise SystemExit(f"Config inválida: {path}")
    return data


def inject_auth(rtsp_url: str, username: str, password: str) -> str:
    if not rtsp_url:
        return rtsp_url
    parsed = urlparse(rtsp_url)
    if parsed.username:
        return rtsp_url
    user = quote(username, safe="")
    pwd = quote(password, safe="")
    netloc = f"{user}:{pwd}@{parsed.hostname}"
    if parsed.port:
        netloc += f":{parsed.port}"
    return urlunparse(parsed._replace(netloc=netloc))


def tcp_open(host: str, port: int, timeout: float) -> bool:
    import socket

    try:
        with socket.create_connection((host, port), timeout=timeout):
            return True
    except OSError:
        return False


def onvif_collect(cam: dict[str, Any], timeout: float) -> dict[str, Any]:
    result: dict[str, Any] = {
        "available": False,
        "error": None,
        "manufacturer": None,
        "model": None,
        "firmware": None,
        "serial": None,
        "hardware": None,
        "profiles": [],
        "capabilities_summary": {},
    }
    host = cam["host"]
    port = int(cam.get("onvif_port") or 80)
    user = cam.get("username") or ""
    password = cam.get("password") or ""

    if not tcp_open(host, port, timeout):
        result["error"] = f"porta ONVIF/HTTP {port} inacessível"
        return result

    try:
        from onvif import ONVIFCamera  # type: ignore
    except ImportError:
        result["error"] = "pacote onvif-zeep não instalado no venv"
        return result

    # WSDL bundle shipped with onvif-zeep
    try:
        import onvif
        wsdl_dir = str(Path(onvif.__file__).resolve().parent / "wsdl")
    except Exception:
        wsdl_dir = None

    try:
        kwargs = {"host": host, "port": port, "user": user, "passwd": password}
        if wsdl_dir and Path(wsdl_dir).is_dir():
            cam_dev = ONVIFCamera(**kwargs, wsdl_dir=wsdl_dir)
        else:
            cam_dev = ONVIFCamera(**kwargs)

        # Alguns firmwares demoram; zeep usa requests por baixo
        try:
            cam_dev.devicemgmt.zeep_client.transport.session.verify = False
        except Exception:
            pass

        info = cam_dev.devicemgmt.GetDeviceInformation()
        result["manufacturer"] = getattr(info, "Manufacturer", None)
        result["model"] = getattr(info, "Model", None)
        result["firmware"] = getattr(info, "FirmwareVersion", None)
        result["serial"] = getattr(info, "SerialNumber", None)
        result["hardware"] = getattr(info, "HardwareId", None)
        result["available"] = True

        try:
            caps = cam_dev.devicemgmt.GetCapabilities({"Category": "All"})
            result["capabilities_summary"] = {
                "media": bool(getattr(caps, "Media", None)),
                "events": bool(getattr(caps, "Events", None)),
                "ptz": bool(getattr(caps, "PTZ", None)),
                "imaging": bool(getattr(caps, "Imaging", None)),
                "device": bool(getattr(caps, "Device", None)),
            }
        except Exception as exc:
            result["capabilities_summary"] = {"error": str(exc)}

        media = cam_dev.create_media_service()
        profiles = media.GetProfiles()
        for idx, profile in enumerate(profiles or []):
            token = getattr(profile, "token", None) or getattr(profile, "Token", None)
            name = getattr(profile, "Name", None)
            video = getattr(profile, "VideoEncoderConfiguration", None)
            audio = getattr(profile, "AudioEncoderConfiguration", None)
            enc = {}
            if video is not None:
                res = getattr(video, "Resolution", None)
                rate = getattr(video, "RateControl", None)
                enc = {
                    "encoding": getattr(video, "Encoding", None),
                    "width": getattr(res, "Width", None) if res else None,
                    "height": getattr(res, "Height", None) if res else None,
                    "fps": getattr(rate, "FrameRateLimit", None) if rate else None,
                    "bitrate_kbps": getattr(rate, "BitrateLimit", None) if rate else None,
                    "quality": getattr(video, "Quality", None),
                }
            audio_info = None
            if audio is not None:
                audio_info = {
                    "encoding": getattr(audio, "Encoding", None),
                    "sample_rate": getattr(audio, "SampleRate", None),
                    "bitrate": getattr(audio, "Bitrate", None),
                }

            uri = None
            uri_error = None
            try:
                stream = media.GetStreamUri(
                    {
                        "StreamSetup": {
                            "Stream": "RTP-Unicast",
                            "Transport": {"Protocol": "RTSP"},
                        },
                        "ProfileToken": token,
                    }
                )
                uri = getattr(stream, "Uri", None)
            except Exception as exc:
                uri_error = str(exc)

            result["profiles"].append(
                {
                    "index": idx,
                    "token": token,
                    "name": name,
                    "video": enc,
                    "audio": audio_info,
                    "rtsp_uri": redact_url(uri) if uri else None,
                    "rtsp_uri_raw": uri,
                    "rtsp_uri_error": uri_error,
                }
            )
    except Exception as exc:
        result["error"] = f"{type(exc).__name__}: {exc}"
        result["available"] = False

    return result


def classify_streams(profiles: list[dict[str, Any]]) -> dict[str, Any]:
    """Heurística genérica: maior resolução = main; menor = sub."""
    scored: list[tuple[int, dict[str, Any]]] = []
    for p in profiles:
        video = p.get("video") or {}
        w = int(video.get("width") or 0)
        h = int(video.get("height") or 0)
        scored.append((w * h, p))
    scored.sort(key=lambda x: x[0], reverse=True)
    main = scored[0][1] if scored else None
    sub = None
    if len(scored) >= 2:
        sub = scored[-1][1]
        if sub is main:
            sub = scored[1][1] if len(scored) > 1 else None
    return {"main_profile": main, "sub_profile": sub}


def ffprobe_stream(rtsp_url: str, timeout: float) -> dict[str, Any]:
    ffprobe = which("ffprobe")
    if not ffprobe:
        return {"ok": False, "error": "ffprobe AUSENTE no PATH"}
    if not rtsp_url:
        return {"ok": False, "error": "URL RTSP vazia"}

    cmd = [
        ffprobe,
        "-v",
        "error",
        "-rtsp_transport",
        "tcp",
        "-timeout",
        str(int(timeout * 1_000_000)),
        "-print_format",
        "json",
        "-show_streams",
        "-show_format",
        rtsp_url,
    ]
    try:
        proc = subprocess.run(
            cmd,
            capture_output=True,
            text=True,
            timeout=timeout + 5,
            check=False,
        )
    except subprocess.TimeoutExpired:
        return {"ok": False, "error": "ffprobe timeout"}

    if proc.returncode != 0:
        err = (proc.stderr or proc.stdout or "").strip().splitlines()
        return {"ok": False, "error": err[-1] if err else f"exit {proc.returncode}"}

    try:
        payload = json.loads(proc.stdout or "{}")
    except json.JSONDecodeError as exc:
        return {"ok": False, "error": f"json inválido: {exc}"}

    video = next((s for s in payload.get("streams", []) if s.get("codec_type") == "video"), None)
    audio = next((s for s in payload.get("streams", []) if s.get("codec_type") == "audio"), None)
    fmt = payload.get("format") or {}

    def _num(v: Any) -> float | None:
        if v is None:
            return None
        try:
            if isinstance(v, str) and "/" in v:
                a, b = v.split("/", 1)
                return float(a) / float(b) if float(b) else None
            return float(v)
        except (TypeError, ValueError, ZeroDivisionError):
            return None

    return {
        "ok": True,
        "codec": video.get("codec_name") if video else None,
        "profile": video.get("profile") if video else None,
        "width": video.get("width") if video else None,
        "height": video.get("height") if video else None,
        "fps": _num(video.get("avg_frame_rate") or video.get("r_frame_rate")) if video else None,
        "pix_fmt": video.get("pix_fmt") if video else None,
        "audio_codec": audio.get("codec_name") if audio else None,
        "audio_channels": audio.get("channels") if audio else None,
        "audio_sample_rate": _num(audio.get("sample_rate")) if audio else None,
        "bit_rate_declared": _num(fmt.get("bit_rate") or (video or {}).get("bit_rate")),
        "format_name": fmt.get("format_name"),
    }


def measure_stability_and_bitrate(rtsp_url: str, sample_seconds: int, timeout: float) -> dict[str, Any]:
    """Grava amostra curta em /dev/null via ffmpeg e estima Mbps + estabilidade."""
    ffmpeg = which("ffmpeg")
    if not ffmpeg:
        return {"ok": False, "error": "ffmpeg AUSENTE no PATH"}
    if not rtsp_url:
        return {"ok": False, "error": "URL RTSP vazia"}

    cmd = [
        ffmpeg,
        "-hide_banner",
        "-rtsp_transport",
        "tcp",
        "-rw_timeout",
        str(int(timeout * 1_000_000)),
        "-i",
        rtsp_url,
        "-t",
        str(sample_seconds),
        "-c",
        "copy",
        "-f",
        "null",
        "-",
    ]
    started = time.monotonic()
    try:
        proc = subprocess.run(
            cmd,
            capture_output=True,
            text=True,
            timeout=sample_seconds + timeout + 10,
            check=False,
        )
    except subprocess.TimeoutExpired:
        return {
            "ok": False,
            "stable": False,
            "error": "ffmpeg sample timeout",
            "sample_seconds_requested": sample_seconds,
        }

    elapsed = max(time.monotonic() - started, 0.001)
    stderr = proc.stderr or ""
    # ffmpeg null muxer reporta tamanho aproximado em linhas "video:1234kB"
    video_kb = 0.0
    audio_kb = 0.0
    m_v = re.search(r"video:\s*([0-9.]+)kB", stderr, re.I)
    m_a = re.search(r"audio:\s*([0-9.]+)kB", stderr, re.I)
    if m_v:
        video_kb = float(m_v.group(1))
    if m_a:
        audio_kb = float(m_a.group(1))
    total_bits = (video_kb + audio_kb) * 1024 * 8
    mbps = total_bits / elapsed / 1_000_000

    frame_drops = len(re.findall(r"error|disconnect|timed out|Connection refused", stderr, re.I))
    ok = proc.returncode == 0 and video_kb > 0
    return {
        "ok": ok,
        "stable": ok and frame_drops == 0,
        "ffmpeg_exit_code": proc.returncode,
        "elapsed_seconds": round(elapsed, 2),
        "sample_seconds_requested": sample_seconds,
        "bytes_approx": int((video_kb + audio_kb) * 1024),
        "network_mbps_approx": round(mbps, 3),
        "warning_hits_in_log": frame_drops,
        "error": None if ok else (stderr.strip().splitlines()[-1] if stderr.strip() else "ffmpeg falhou"),
    }


def pick_rtsp_urls(cam: dict[str, Any], onvif: dict[str, Any]) -> dict[str, str | None]:
    manual_main = (cam.get("rtsp_main") or "").strip() or None
    manual_sub = (cam.get("rtsp_sub") or "").strip() or None
    classified = classify_streams(onvif.get("profiles") or [])
    main_p = classified.get("main_profile") or {}
    sub_p = classified.get("sub_profile") or {}
    onvif_main = main_p.get("rtsp_uri_raw") if isinstance(main_p, dict) else None
    onvif_sub = sub_p.get("rtsp_uri_raw") if isinstance(sub_p, dict) else None

    user = cam.get("username") or ""
    password = cam.get("password") or ""
    main = inject_auth(manual_main or onvif_main or "", user, password) or None
    sub = inject_auth(manual_sub or onvif_sub or "", user, password) or None
    return {
        "main": main,
        "sub": sub,
        "main_source": "manual" if manual_main else ("onvif" if onvif_main else None),
        "sub_source": "manual" if manual_sub else ("onvif" if onvif_sub else None),
    }


def inventory_camera(cam: dict[str, Any], defaults: dict[str, Any]) -> dict[str, Any]:
    timeout = float(cam.get("timeout_seconds") or defaults.get("timeout_seconds") or 8)
    sample_seconds = int(cam.get("sample_seconds") or defaults.get("sample_seconds") or 15)
    host = cam["host"]
    rtsp_port = int(cam.get("rtsp_port") or defaults.get("rtsp_port") or 554)
    onvif_port = int(cam.get("onvif_port") or defaults.get("onvif_port") or 80)

    report: dict[str, Any] = {
        "id": cam.get("id"),
        "name": cam.get("name"),
        "host": host,
        "ports": {
            "onvif_http_open": tcp_open(host, onvif_port, timeout),
            "rtsp_open": tcp_open(host, rtsp_port, timeout),
            "onvif_port": onvif_port,
            "rtsp_port": rtsp_port,
        },
        "manufacturer_hint": cam.get("manufacturer_hint") or None,
        "model_hint": cam.get("model_hint") or None,
        "onvif": None,
        "streams": {},
        "notes": [],
    }

    onvif = onvif_collect({**cam, "onvif_port": onvif_port}, timeout)
    # remove raw URIs do bloco profiles na saída pública (redacted já existe)
    public_profiles = []
    for p in onvif.get("profiles") or []:
        public_profiles.append({k: v for k, v in p.items() if k != "rtsp_uri_raw"})
    report["onvif"] = {**onvif, "profiles": public_profiles}

    report["manufacturer"] = onvif.get("manufacturer") or cam.get("manufacturer_hint") or None
    report["model"] = onvif.get("model") or cam.get("model_hint") or None

    urls = pick_rtsp_urls(cam, onvif)
    for role in ("main", "sub"):
        url = urls.get(role)
        entry: dict[str, Any] = {
            "configured": bool(url),
            "source": urls.get(f"{role}_source"),
            "uri_redacted": redact_url(url) if url else None,
            "ffprobe": None,
            "stability": None,
        }
        if url:
            entry["ffprobe"] = ffprobe_stream(url, timeout)
            entry["stability"] = measure_stability_and_bitrate(url, sample_seconds, timeout)
        else:
            entry["notes"] = "sem URI (ONVIF falhou e rtsp_* manual vazio)"
        report["streams"][role] = entry

    if not which("ffprobe") or not which("ffmpeg"):
        report["notes"].append(
            "ffprobe/ffmpeg ausentes — instale apenas quando for coletar métricas RTSP "
            "(a Fase 0 não instala automaticamente)."
        )
    if not onvif.get("available") and not urls.get("main"):
        report["notes"].append(
            "Sem ONVIF e sem RTSP manual — preencha rtsp_main/rtsp_sub em cameras.yaml."
        )

    return report


def render_markdown(reports: list[dict[str, Any]], meta: dict[str, Any]) -> str:
    lines = [
        "# Inventário de câmeras — Fase 0",
        "",
        f"- Coletado (UTC): `{meta['collected_at_utc']}`",
        f"- Câmeras processadas: **{len(reports)}**",
        f"- ffprobe: `{meta.get('ffprobe')}`",
        f"- ffmpeg: `{meta.get('ffmpeg')}`",
        "",
        "## Princípio",
        "",
        "- Coleta genérica (ONVIF + RTSP). Sem lógica `if fabricante == ...`.",
        "- Recorder futuro deve ser independente do Laravel (restart do painel ≠ parar gravação).",
        "",
    ]
    for r in reports:
        lines += [
            f"## {r.get('id')} — {r.get('name')}",
            "",
            f"- Host: `{r.get('host')}`",
            f"- Fabricante: **{r.get('manufacturer') or 'DADO A COLETAR / indisponível'}**",
            f"- Modelo: **{r.get('model') or 'DADO A COLETAR / indisponível'}**",
            f"- ONVIF: {'OK' if (r.get('onvif') or {}).get('available') else 'FALHOU/INDISPONÍVEL'}"
            + (f" — {(r.get('onvif') or {}).get('error')}" if not (r.get('onvif') or {}).get('available') else ""),
            f"- Porta HTTP/ONVIF aberta: `{r['ports']['onvif_http_open']}` | RTSP aberta: `{r['ports']['rtsp_open']}`",
            "",
        ]
        profiles = (r.get("onvif") or {}).get("profiles") or []
        if profiles:
            lines += ["### Perfis ONVIF", ""]
            for p in profiles:
                v = p.get("video") or {}
                lines.append(
                    f"- `{p.get('name')}` token=`{p.get('token')}` "
                    f"{v.get('encoding')} {v.get('width')}x{v.get('height')} "
                    f"@ {v.get('fps')} fps / {v.get('bitrate_kbps')} kbps | "
                    f"URI `{p.get('rtsp_uri')}`"
                )
            lines.append("")

        for role, label in (("main", "Stream principal"), ("sub", "Substream")):
            s = (r.get("streams") or {}).get(role) or {}
            fp = s.get("ffprobe") or {}
            st = s.get("stability") or {}
            lines += [
                f"### {label}",
                "",
                f"- Fonte URI: `{s.get('source')}`",
                f"- URI: `{s.get('uri_redacted')}`",
                f"- Codec: `{fp.get('codec')}` | Resolução: `{fp.get('width')}x{fp.get('height')}` | "
                f"FPS: `{fp.get('fps')}` | Áudio: `{fp.get('audio_codec')}`",
                f"- Bitrate declarado (ffprobe): `{fp.get('bit_rate_declared')}`",
                f"- Estabilidade amostra: `{st.get('stable')}` | Mbps medido ≈ `{st.get('network_mbps_approx')}` | "
                f"ok=`{st.get('ok')}`",
                "",
            ]
        if r.get("notes"):
            lines += ["### Notas", ""]
            for n in r["notes"]:
                lines.append(f"- {n}")
            lines.append("")
    return "\n".join(lines) + "\n"


def main() -> int:
    parser = argparse.ArgumentParser(description="Inventário ONVIF+RTSP multi-fabricante")
    parser.add_argument("--config", type=Path, default=DEFAULT_CONFIG)
    parser.add_argument("--only", action="append", default=[], help="ID da câmera (repetível)")
    args = parser.parse_args()

    if not args.config.is_file():
        print(
            f"ERRO: {args.config} não encontrado.\n"
            f"Copie cameras.example.yaml → cameras.yaml e preencha IPs/credenciais.",
            file=sys.stderr,
        )
        return 2

    cfg = load_config(args.config)
    defaults = cfg.get("defaults") or {}
    cameras = cfg.get("cameras") or []
    if args.only:
        cameras = [c for c in cameras if c.get("id") in set(args.only)]
    if not cameras:
        print("Nenhuma câmera para processar.", file=sys.stderr)
        return 2

    # placeholders óbvios
    bad = [c["id"] for c in cameras if "ALTERAR" in str(c.get("password", "")) or str(c.get("host", "")).endswith((".X", ".Y", ".Z"))]
    if bad:
        print(
            f"AVISO: câmeras com placeholder provavelmente inválido: {bad}. "
            "Ainda assim tentarei coletar.",
            file=sys.stderr,
        )

    reports = []
    for cam in cameras:
        print(f"Coletando {cam.get('id')} ({cam.get('host')}) ...", file=sys.stderr)
        reports.append(inventory_camera(cam, defaults))

    stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
    meta = {
        "collected_at_utc": stamp,
        "ffprobe": which("ffprobe") or "AUSENTE",
        "ffmpeg": which("ffmpeg") or "AUSENTE",
        "config": str(args.config),
        "recorder_independence_principle": True,
    }
    OUT_DIR.mkdir(parents=True, exist_ok=True)
    payload = {"meta": meta, "cameras": reports}
    json_path = OUT_DIR / f"inventory-{stamp}.json"
    md_path = OUT_DIR / f"inventory-{stamp}.md"
    json_path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
    md_path.write_text(render_markdown(reports, meta), encoding="utf-8")
    (OUT_DIR / "inventory-latest.json").write_text(json_path.read_text(encoding="utf-8"), encoding="utf-8")
    (OUT_DIR / "inventory-latest.md").write_text(md_path.read_text(encoding="utf-8"), encoding="utf-8")
    print(f"OK: {md_path}")
    print(f"OK: {json_path}")
    return 0


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