"""Validated full-epoch perception results and native recorded-video delivery."""

from __future__ import annotations

import hashlib
import json
import math
import os
import re
import stat
import subprocess
import threading
from dataclasses import dataclass
from pathlib import Path
from typing import Any

from k1link.sessions import SessionIntegrityError

from .jobs import CameraComputeJob, validate_camera_compute_job

RESULT_SCHEMA = "missioncore.recorded-perception-result/v2"
IDENTITY_SCHEMA = "missioncore.recorded-perception-identity/v2"
REPORT_SCHEMA = "missioncore.perception-run-report/v1"
FRAME_SCHEMA = "missioncore.panoptic-frame/v1"
MAX_JSON_BYTES = 64 * 1024 * 1024
MAX_FRAME_LINE_BYTES = 1024 * 1024
MAX_JOB_SCAN = 512
MAX_SAFE_INTEGER = (1 << 53) - 1

_SAFE_RESULT_ID = re.compile(r"^result-[a-f0-9]{64}$")
_SAFE_RECORDING_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$")
_SHA256 = re.compile(r"^[a-f0-9]{64}$")


@dataclass(frozen=True, slots=True)
class PerceptionArtifact:
    kind: str
    path: Path
    media_type: str
    byte_length: int
    sha256: str
    schema_version: str | None


@dataclass(frozen=True, slots=True)
class RecordedPerceptionEpochResult:
    result_id: str
    result_root: Path
    job: CameraComputeJob
    created_at_utc: str
    calibration_sha256: str
    calibration_slot: str
    artifacts: tuple[PerceptionArtifact, ...]

    def artifact(self, kind: str) -> PerceptionArtifact:
        matches = tuple(item for item in self.artifacts if item.kind == kind)
        if len(matches) != 1:
            raise SessionIntegrityError(f"perception result artifact is unavailable: {kind}")
        return matches[0]


@dataclass(frozen=True, slots=True)
class RecordedPerceptionVideo:
    result_id: str
    session_id: str
    source_id: str
    public_source_id: str
    label: str
    path: Path
    media_type: str
    byte_length: int
    sha256: str
    timeline_start_seconds: float
    timeline_end_seconds: float


@dataclass(frozen=True, slots=True)
class _LatestResultCacheEntry:
    jobs_root_mtime_ns: int
    result_parent_mtime_ns: int
    file_fingerprint: tuple[tuple[str, int, int, int, int], ...]
    result: RecordedPerceptionEpochResult


class RecordedPerceptionEpochStore:
    """Discover immutable full-epoch results and expose their panoptic video."""

    def __init__(self, *, jobs_root: Path, results_root: Path, ffprobe_path: Path) -> None:
        self.jobs_root = jobs_root.expanduser().absolute()
        self.results_root = results_root.expanduser().absolute()
        self.ffprobe_path = ffprobe_path.expanduser().resolve(strict=True)
        self._lock = threading.Lock()
        self._video_probe_cache: dict[tuple[str, int, int], tuple[float, str, int, int]] = {}
        self._latest_result_cache: dict[str, _LatestResultCacheEntry] = {}

    def latest(self, session_id: str) -> RecordedPerceptionEpochResult | None:
        if _SAFE_RECORDING_ID.fullmatch(session_id) is None:
            raise ValueError("observation session id is invalid")
        with self._lock:
            return self._latest_unlocked(session_id)

    def video(
        self,
        session_id: str,
        result_id: str | None = None,
    ) -> RecordedPerceptionVideo | None:
        if _SAFE_RECORDING_ID.fullmatch(session_id) is None:
            raise ValueError("observation session id is invalid")
        if result_id is not None and _SAFE_RESULT_ID.fullmatch(result_id) is None:
            raise ValueError("perception result id is invalid")
        with self._lock:
            result = (
                self._latest_unlocked(session_id)
                if result_id is None
                else self._resolve_unlocked(session_id, result_id)
            )
            if result is None:
                return None
            artifact = result.artifact("panoptic-overlay-video")
            duration, codec, width, height = self._probe_video(artifact)
            expected = result.job.timeline_end_seconds - result.job.timeline_start_seconds
            if codec != "h264" or width != 800 or height != 600 or abs(duration - expected) > 1:
                raise SessionIntegrityError("panoptic video stream contract is invalid")
            suffix = result.job.source_id.removeprefix("sensor.camera.")
            return RecordedPerceptionVideo(
                result_id=result.result_id,
                session_id=result.job.session_id,
                source_id=result.job.source_id,
                public_source_id=f"recorded.perception.{suffix}",
                label=f"Сегментация · камера {suffix}",
                path=artifact.path,
                media_type='video/mp4; codecs="avc1.640028"',
                byte_length=artifact.byte_length,
                sha256=artifact.sha256,
                timeline_start_seconds=result.job.timeline_start_seconds,
                timeline_end_seconds=result.job.timeline_end_seconds,
            )

    def _latest_unlocked(self, session_id: str) -> RecordedPerceptionEpochResult | None:
        cached = self._latest_result_cache.get(session_id)
        if cached is not None:
            if self._latest_cache_is_current(cached):
                return cached.result
            self._latest_result_cache.pop(session_id, None)
        try:
            job_roots = sorted(self.jobs_root.iterdir())
        except FileNotFoundError:
            return None
        if len(job_roots) > MAX_JOB_SCAN:
            raise SessionIntegrityError("compute job catalog is outside bounds")
        matches: list[RecordedPerceptionEpochResult] = []
        for job_root in job_roots:
            if job_root.is_symlink():
                continue
            try:
                job = validate_camera_compute_job(job_root)
            except (OSError, SessionIntegrityError):
                continue
            if job.session_id != session_id:
                continue
            parent = self.results_root / job.job_id
            try:
                result_roots = sorted(
                    path
                    for path in parent.iterdir()
                    if path.is_dir()
                    and not path.is_symlink()
                    and _SAFE_RESULT_ID.fullmatch(path.name) is not None
                )
            except FileNotFoundError:
                continue
            for result_root in result_roots:
                try:
                    matches.append(validate_recorded_perception_epoch_result(job_root, result_root))
                except (OSError, SessionIntegrityError):
                    continue
        if not matches:
            return None
        latest = max(matches, key=lambda value: (value.created_at_utc, value.result_id))
        self._latest_result_cache[session_id] = self._latest_cache_entry(latest)
        return latest

    def _latest_cache_entry(
        self,
        result: RecordedPerceptionEpochResult,
    ) -> _LatestResultCacheEntry:
        result_parent = self.results_root / result.job.job_id
        return _LatestResultCacheEntry(
            jobs_root_mtime_ns=self.jobs_root.stat().st_mtime_ns,
            result_parent_mtime_ns=result_parent.stat().st_mtime_ns,
            file_fingerprint=_result_file_fingerprint(result),
            result=result,
        )

    def _latest_cache_is_current(self, cached: _LatestResultCacheEntry) -> bool:
        """Reuse a fully validated immutable publication while it is unchanged.

        New jobs and new result generations invalidate through their parent
        directory mtimes. In-place changes to the manifest or any served
        result artifact invalidate through the bounded file fingerprint and
        force the full hash validation again.
        """

        try:
            result_parent = self.results_root / cached.result.job.job_id
            return (
                self.jobs_root.stat().st_mtime_ns == cached.jobs_root_mtime_ns
                and result_parent.stat().st_mtime_ns == cached.result_parent_mtime_ns
                and _result_file_fingerprint(cached.result) == cached.file_fingerprint
            )
        except (OSError, SessionIntegrityError):
            return False

    def _resolve_unlocked(
        self,
        session_id: str,
        result_id: str,
    ) -> RecordedPerceptionEpochResult | None:
        latest = self._latest_unlocked(session_id)
        if latest is None or latest.result_id != result_id:
            return None
        return latest

    def _probe_video(self, artifact: PerceptionArtifact) -> tuple[float, str, int, int]:
        metadata = _confined_regular_file(artifact.path, artifact.path.parent)
        key = (artifact.sha256, metadata.st_mtime_ns, metadata.st_size)
        cached = self._video_probe_cache.get(key)
        if cached is not None:
            return cached
        try:
            completed = subprocess.run(
                [
                    str(self.ffprobe_path),
                    "-v",
                    "error",
                    "-select_streams",
                    "v:0",
                    "-show_entries",
                    "stream=codec_name,width,height:format=duration",
                    "-of",
                    "json",
                    str(artifact.path),
                ],
                capture_output=True,
                check=False,
                text=True,
                timeout=30,
            )
            document = json.loads(completed.stdout)
            stream = document["streams"][0]
            value = (
                float(document["format"]["duration"]),
                str(stream["codec_name"]),
                int(stream["width"]),
                int(stream["height"]),
            )
        except (OSError, subprocess.SubprocessError, ValueError, KeyError, IndexError) as exc:
            raise SessionIntegrityError("panoptic video metadata is unavailable") from exc
        if completed.returncode != 0 or not math.isfinite(value[0]) or value[0] <= 0:
            raise SessionIntegrityError("panoptic video could not be decoded")
        self._video_probe_cache = {key: value}
        return value


def validate_recorded_perception_epoch_result(
    job_root: Path,
    result_root: Path,
) -> RecordedPerceptionEpochResult:
    job = validate_camera_compute_job(job_root)
    root = result_root.expanduser().resolve(strict=True)
    if not root.is_dir() or _SAFE_RESULT_ID.fullmatch(root.name) is None:
        raise SessionIntegrityError("full-epoch perception result root is invalid")
    result = _read_json_object(root / "result.json", root)
    identity = result.get("identity")
    identity_sha256 = result.get("identity_sha256")
    if (
        result.get("schema_version") != RESULT_SCHEMA
        or result.get("result_id") != root.name
        or not isinstance(identity, dict)
        or identity.get("schema_version") != IDENTITY_SCHEMA
        or not isinstance(identity_sha256, str)
        or _SHA256.fullmatch(identity_sha256) is None
        or root.name != f"result-{identity_sha256}"
        or hashlib.sha256(_canonical_json(identity)).hexdigest() != identity_sha256
        or result.get("job_id") != job.job_id
        or result.get("input_sha256") != job.input_sha256
        or result.get("session_id") != job.session_id
        or result.get("source_id") != job.source_id
        or result.get("codec_epoch") != job.codec_epoch
        or result.get("timestamp_basis") != "session-time-seconds"
        or result.get("timeline_start_seconds") != job.timeline_start_seconds
        or result.get("timeline_end_seconds") != job.timeline_end_seconds
        or result.get("frames_processed") != job.segment_count
    ):
        raise SessionIntegrityError("full-epoch perception result identity is inconsistent")
    if identity.get("job_id") != job.job_id or identity.get("input_sha256") != job.input_sha256:
        raise SessionIntegrityError("full-epoch perception identity is not bound to its job")
    calibration = identity.get("calibration")
    if (
        not isinstance(calibration, dict)
        or not isinstance(calibration.get("content_identity_sha256"), str)
        or _SHA256.fullmatch(calibration["content_identity_sha256"]) is None
        or not isinstance(calibration.get("camera_slot"), str)
        or not calibration["camera_slot"]
    ):
        raise SessionIntegrityError("full-epoch perception calibration binding is invalid")
    artifacts = _validate_artifacts(root, result.get("artifacts"))
    report_artifact = _artifact(artifacts, "perception-run-report")
    report = _read_json_object(report_artifact.path, root)
    metrics = report.get("metrics")
    report_input = report.get("input")
    if (
        report.get("schema_version") != REPORT_SCHEMA
        or report.get("state") != "published"
        or report.get("result_id") != root.name
        or not isinstance(metrics, dict)
        or metrics.get("frames_expected") != job.segment_count
        or metrics.get("frames_processed") != job.segment_count
        or metrics.get("frames_failed") != 0
        or metrics.get("frames_skipped") != 0
        or not isinstance(report_input, dict)
        or report_input.get("job_id") != job.job_id
        or report_input.get("input_sha256") != job.input_sha256
    ):
        raise SessionIntegrityError("full-epoch perception run report is inconsistent")
    _validate_frame_metadata(_artifact(artifacts, "panoptic-frame-metadata"), job)
    created_at_utc = result.get("created_at_utc")
    if not isinstance(created_at_utc, str) or not 1 <= len(created_at_utc) <= 64:
        raise SessionIntegrityError("full-epoch perception creation time is invalid")
    return RecordedPerceptionEpochResult(
        result_id=root.name,
        result_root=root,
        job=job,
        created_at_utc=created_at_utc,
        calibration_sha256=calibration["content_identity_sha256"],
        calibration_slot=calibration["camera_slot"],
        artifacts=artifacts,
    )


def _validate_artifacts(root: Path, raw: object) -> tuple[PerceptionArtifact, ...]:
    expected = {
        "panoptic-overlay-video": ("perception.mp4", "video/mp4", None),
        "panoptic-mask-archive": ("masks.tar.gz", "application/gzip", None),
        "panoptic-frame-metadata": ("frames.jsonl", "application/x-ndjson", FRAME_SCHEMA),
        "worker-gpu-telemetry": ("gpu-telemetry.jsonl", "application/x-ndjson", None),
        "perception-run-report": ("run-report.json", "application/json", REPORT_SCHEMA),
    }
    if not isinstance(raw, list) or len(raw) != len(expected):
        raise SessionIntegrityError("full-epoch perception artifact set is invalid")
    artifacts: list[PerceptionArtifact] = []
    seen: set[str] = set()
    for value in raw:
        if not isinstance(value, dict):
            raise SessionIntegrityError("full-epoch perception artifact is invalid")
        kind = value.get("kind")
        if not isinstance(kind, str) or kind in seen or kind not in expected:
            raise SessionIntegrityError("full-epoch perception artifact kind is invalid")
        seen.add(kind)
        expected_path, expected_media_type, expected_schema = expected[kind]
        path = root / expected_path
        metadata = _confined_regular_file(path, root)
        byte_length = value.get("byte_length")
        digest = value.get("sha256")
        if (
            value.get("path") != expected_path
            or value.get("media_type") != expected_media_type
            or value.get("schema_version") != expected_schema
            or not isinstance(byte_length, int)
            or isinstance(byte_length, bool)
            or not 0 < byte_length <= MAX_SAFE_INTEGER
            or metadata.st_size != byte_length
            or not isinstance(digest, str)
            or _SHA256.fullmatch(digest) is None
            or _sha256_file(path) != digest
        ):
            raise SessionIntegrityError("full-epoch perception artifact identity changed")
        artifacts.append(
            PerceptionArtifact(
                kind=kind,
                path=path,
                media_type=expected_media_type,
                byte_length=byte_length,
                sha256=digest,
                schema_version=expected_schema,
            )
        )
    return tuple(artifacts)


def _artifact(artifacts: tuple[PerceptionArtifact, ...], kind: str) -> PerceptionArtifact:
    matches = tuple(item for item in artifacts if item.kind == kind)
    if len(matches) != 1:
        raise SessionIntegrityError("full-epoch perception artifact is unavailable")
    return matches[0]


def _validate_frame_metadata(artifact: PerceptionArtifact, job: CameraComputeJob) -> None:
    previous = -math.inf
    count = 0
    try:
        with artifact.path.open("rb") as stream:
            for expected_index, line in enumerate(stream):
                if not line or len(line) > MAX_FRAME_LINE_BYTES:
                    raise SessionIntegrityError("panoptic frame metadata line is outside bounds")
                value = json.loads(line)
                session_seconds = value.get("session_seconds") if isinstance(value, dict) else None
                if (
                    not isinstance(value, dict)
                    or value.get("schema_version") != FRAME_SCHEMA
                    or value.get("frame_index") != expected_index
                    or value.get("sequence") != expected_index + 1
                    or not isinstance(session_seconds, (int, float))
                    or isinstance(session_seconds, bool)
                    or not math.isfinite(float(session_seconds))
                    or float(session_seconds) <= previous
                    or float(session_seconds) < job.timeline_start_seconds - 0.001
                    or float(session_seconds) > job.timeline_end_seconds + 0.001
                    or not isinstance(value.get("instances"), list)
                    or not isinstance(value.get("semantic_classes"), list)
                ):
                    raise SessionIntegrityError("panoptic frame metadata is inconsistent")
                previous = float(session_seconds)
                count += 1
    except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
        raise SessionIntegrityError("panoptic frame metadata is unavailable") from exc
    if count != job.segment_count:
        raise SessionIntegrityError("panoptic frame metadata count changed")


def _read_json_object(path: Path, root: Path) -> dict[str, Any]:
    metadata = _confined_regular_file(path, root)
    if not 0 < metadata.st_size <= MAX_JSON_BYTES:
        raise SessionIntegrityError("full-epoch perception JSON is outside bounds")
    try:
        value = json.loads(path.read_text(encoding="utf-8-sig"))
    except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
        raise SessionIntegrityError("full-epoch perception JSON is unavailable") from exc
    if not isinstance(value, dict):
        raise SessionIntegrityError("full-epoch perception JSON is not an object")
    return value


def _confined_regular_file(path: Path, root: Path) -> os.stat_result:
    try:
        resolved_root = root.resolve(strict=True)
        resolved = path.resolve(strict=True)
        metadata = path.lstat()
    except OSError as exc:
        raise SessionIntegrityError("full-epoch perception artifact is unavailable") from exc
    if (
        stat.S_ISLNK(metadata.st_mode)
        or not stat.S_ISREG(metadata.st_mode)
        or not resolved.is_relative_to(resolved_root)
    ):
        raise SessionIntegrityError("full-epoch perception artifact is not confined")
    return metadata


def _result_file_fingerprint(
    result: RecordedPerceptionEpochResult,
) -> tuple[tuple[str, int, int, int, int], ...]:
    paths = (result.job.manifest_path, result.result_root / "result.json") + tuple(
        artifact.path for artifact in result.artifacts
    )
    values: list[tuple[str, int, int, int, int]] = []
    for path in paths:
        root = result.job.job_root if path == result.job.manifest_path else result.result_root
        metadata = _confined_regular_file(path, root)
        values.append(
            (
                str(path),
                metadata.st_dev,
                metadata.st_ino,
                metadata.st_size,
                metadata.st_mtime_ns,
            )
        )
    return tuple(values)


def _sha256_file(path: Path) -> str:
    digest = hashlib.sha256()
    with path.open("rb") as stream:
        while chunk := stream.read(1024 * 1024):
            digest.update(chunk)
    return digest.hexdigest()


def _canonical_json(value: object) -> bytes:
    try:
        return json.dumps(
            value,
            ensure_ascii=False,
            sort_keys=True,
            separators=(",", ":"),
            allow_nan=False,
        ).encode("utf-8")
    except (TypeError, ValueError) as exc:
        raise SessionIntegrityError("full-epoch perception identity cannot be encoded") from exc
