from __future__ import annotations

import hashlib
import json
import os
import threading
import time
from concurrent.futures import ThreadPoolExecutor
from dataclasses import replace
from pathlib import Path
from types import SimpleNamespace

import pytest

import k1link.sessions.recording as recording_module
from k1link.sessions.models import ReplayArtifact, ReplayCommand
from k1link.sessions.recording import (
    CACHE_SCHEMA,
    RECORDING_CACHE_FILENAME,
    RECORDING_CACHE_SIDECAR_FILENAME,
    RERUN_RECORDING_MEDIA_TYPE,
    RecordingMaterializationError,
    SessionRecordingMaterializer,
)


def _sha256(path: Path) -> str:
    return hashlib.sha256(path.read_bytes()).hexdigest()


class FakeExporter:
    def __init__(self, *, delay_seconds: float = 0.0) -> None:
        self.calls = 0
        self.delay_seconds = delay_seconds
        self._lock = threading.Lock()

    def __call__(self, source: Path, destination: Path) -> dict[str, object]:
        with self._lock:
            self.calls += 1
            sequence = self.calls
        if self.delay_seconds:
            time.sleep(self.delay_seconds)
        destination.write_bytes(b"RRD" + sequence.to_bytes(2, "big") + source.read_bytes())
        return {
            "source_sha256": _sha256(source),
            "rrd_sha256": _sha256(destination),
            "rrd_bytes": destination.stat().st_size,
            "timeline": "session_time",
            "timeline_start_ns": 0,
            "timeline_end_ns": 2_500_000_000,
        }


def _command(tmp_path: Path) -> ReplayCommand:
    tmp_path.mkdir(parents=True, exist_ok=True)
    source = tmp_path / "mqtt.raw.k1mqtt"
    source.write_bytes(b"native-source-recording")
    metadata = tmp_path / "mqtt.metadata.jsonl"
    metadata.write_text('{"record_type":"message","sequence":1}\n', encoding="utf-8")
    return ReplayCommand(
        session_id="20260716T205632Z_viewer_live",
        plugin_id="test.recording-plugin",
        allowed_root=tmp_path,
        session_root=tmp_path,
        primary_artifact_id="primary",
        artifacts=(
            ReplayArtifact(
                artifact_id="primary",
                path=source,
                media_type="application/x-test-recording",
                file_byte_length=source.stat().st_size,
                replay_byte_length=source.stat().st_size,
                expected_sha256=None,
            ),
            ReplayArtifact(
                artifact_id="index",
                path=metadata,
                media_type="application/x-ndjson",
                file_byte_length=metadata.stat().st_size,
                replay_byte_length=metadata.stat().st_size,
                expected_sha256=None,
            ),
        ),
        timeline_origin_epoch_ns=0,
        timeline_origin_monotonic_ns=0,
        speed=1.0,
        loop=False,
    )


def _replace_primary(command: ReplayCommand, **changes: object) -> ReplayCommand:
    replacement = replace(command.primary_artifact, **changes)
    return replace(
        command,
        artifacts=tuple(
            replacement if artifact.artifact_id == command.primary_artifact_id else artifact
            for artifact in command.artifacts
        ),
    )


def test_materializer_reuses_only_a_digest_validated_private_cache(tmp_path: Path) -> None:
    command = _command(tmp_path)
    exporter = FakeExporter()
    materializer = SessionRecordingMaterializer(tmp_path / "private", exporter=exporter)

    first = materializer.materialize(command)
    second = materializer.materialize(command)

    assert first == second
    assert exporter.calls == 1
    assert first.media_type == RERUN_RECORDING_MEDIA_TYPE
    assert first.timeline == "session_time"
    assert first.timeline_start_ns == 0
    assert first.timeline_end_ns == 2_500_000_000
    assert first.path.is_relative_to(materializer.recordings_root)
    assert first.path.stat().st_mode & 0o777 == 0o600
    sidecar = first.path.with_name(RECORDING_CACHE_SIDECAR_FILENAME)
    assert sidecar.stat().st_mode & 0o777 == 0o600
    document = json.loads(sidecar.read_text(encoding="utf-8"))
    assert document["schema_version"] == CACHE_SCHEMA
    assert "source_path" not in document
    assert "recording_path" not in document
    assert str(tmp_path) not in sidecar.read_text(encoding="utf-8")

    after_restart = SessionRecordingMaterializer(
        tmp_path / "private",
        exporter=exporter,
    ).materialize(command)
    assert after_restart == first
    assert exporter.calls == 1


def test_default_recording_cache_has_no_application_byte_quota(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    monkeypatch.delenv("MISSIONCORE_RRD_CACHE_MAX_BYTES", raising=False)

    materializer = SessionRecordingMaterializer(
        tmp_path / "private",
        exporter=FakeExporter(),
    )

    assert materializer.cache_max_bytes is None


def test_delete_cached_refuses_a_live_lease_then_removes_the_exact_cache(
    tmp_path: Path,
) -> None:
    command = _command(tmp_path / "source")
    materializer = SessionRecordingMaterializer(
        tmp_path / "private",
        exporter=FakeExporter(),
    )
    recording, release = materializer.materialize_pinned(command)

    assert materializer.delete_cached(command.session_id) is False
    assert recording.path.is_file()

    release()
    assert materializer.delete_cached(command.session_id) is True
    assert not recording.path.parent.exists()
    assert command.primary_artifact.path.is_file()


def test_materializer_rejects_changed_digest_bound_secondary_artifact(
    tmp_path: Path,
) -> None:
    command = _command(tmp_path / "source")
    secondary = command.artifacts[1]
    expected = _sha256(secondary.path)
    command = replace(
        command,
        artifacts=(
            command.artifacts[0],
            replace(secondary, expected_sha256=expected),
        ),
    )
    secondary.path.write_text('{"record_type":"tampered","sequence":1}\n', encoding="utf-8")
    command = replace(
        command,
        artifacts=(
            command.artifacts[0],
            replace(
                command.artifacts[1],
                file_byte_length=secondary.path.stat().st_size,
                replay_byte_length=secondary.path.stat().st_size,
            ),
        ),
    )
    exporter = FakeExporter()
    materializer = SessionRecordingMaterializer(tmp_path / "private", exporter=exporter)

    with pytest.raises(RecordingMaterializationError, match="digest.*catalog"):
        materializer.materialize(command)

    assert exporter.calls == 0


@pytest.mark.parametrize(
    "obsolete_schema",
    [
        "missioncore.derived-rerun-recording-cache/v1",
        "missioncore.derived-rerun-recording-cache/v2",
        "missioncore.derived-rerun-recording-cache/v4",
        "missioncore.derived-rerun-recording-cache/v5",
    ],
)
def test_materializer_rebuilds_incompatible_recording_cache_schema(
    tmp_path: Path,
    obsolete_schema: str,
) -> None:
    command = _command(tmp_path)
    exporter = FakeExporter()
    private_root = tmp_path / "private"
    first = SessionRecordingMaterializer(private_root, exporter=exporter).materialize(command)
    sidecar = first.path.with_name(RECORDING_CACHE_SIDECAR_FILENAME)
    document = json.loads(sidecar.read_text(encoding="utf-8"))
    document["schema_version"] = obsolete_schema
    sidecar.write_text(json.dumps(document), encoding="utf-8")

    rebuilt = SessionRecordingMaterializer(private_root, exporter=exporter).materialize(command)

    assert exporter.calls == 2
    assert rebuilt.path == first.path
    assert json.loads(sidecar.read_text(encoding="utf-8"))["schema_version"] == CACHE_SCHEMA


def test_failed_rebuild_preserves_previously_published_cache(tmp_path: Path) -> None:
    command = _command(tmp_path)
    exporter = FakeExporter()
    materializer = SessionRecordingMaterializer(tmp_path / "private", exporter=exporter)
    first = materializer.materialize(command)
    published_bytes = first.path.read_bytes()
    sidecar = first.path.with_name(RECORDING_CACHE_SIDECAR_FILENAME)
    published_sidecar = sidecar.read_bytes()
    command.primary_artifact.path.write_bytes(b"new-source-that-requires-rebuild")
    changed = _replace_primary(
        command,
        file_byte_length=command.primary_artifact.path.stat().st_size,
        replay_byte_length=command.primary_artifact.path.stat().st_size,
    )

    def fail_export(_source: Path, destination: Path) -> dict[str, object]:
        destination.write_bytes(b"incomplete-candidate")
        raise OSError("simulated export failure")

    failing = SessionRecordingMaterializer(tmp_path / "private", exporter=fail_export)
    with pytest.raises(RecordingMaterializationError):
        failing.materialize(changed)

    assert first.path.read_bytes() == published_bytes
    assert sidecar.read_bytes() == published_sidecar
    assert not tuple(first.path.parent.glob(".scene.*.candidate.rrd"))


def test_materializer_rebuilds_when_source_or_recording_changes(tmp_path: Path) -> None:
    command = _command(tmp_path)
    exporter = FakeExporter()
    materializer = SessionRecordingMaterializer(tmp_path / "private", exporter=exporter)

    first = materializer.materialize(command)
    original_recording_stat = first.path.stat()
    corrupted = bytearray(first.path.read_bytes())
    corrupted[-1] ^= 0x01
    first.path.write_bytes(corrupted)
    os.utime(
        first.path,
        ns=(original_recording_stat.st_atime_ns, original_recording_stat.st_mtime_ns),
    )

    repaired = materializer.materialize(command)
    assert exporter.calls == 2
    assert repaired.sha256 == _sha256(repaired.path)
    assert repaired.sha256 != hashlib.sha256(corrupted).hexdigest()

    command.primary_artifact.path.write_bytes(b"new-native-source")
    command = _replace_primary(
        command,
        file_byte_length=command.primary_artifact.path.stat().st_size,
        replay_byte_length=command.primary_artifact.path.stat().st_size,
    )
    updated = materializer.materialize(command)
    assert exporter.calls == 3
    assert updated.source_sha256 == _sha256(command.primary_artifact.path)


def test_concurrent_materialization_exports_one_recording(tmp_path: Path) -> None:
    command = _command(tmp_path)
    exporter = FakeExporter(delay_seconds=0.05)
    materializer = SessionRecordingMaterializer(tmp_path / "private", exporter=exporter)

    with ThreadPoolExecutor(max_workers=8) as executor:
        recordings = tuple(executor.map(materializer.materialize, [command] * 8))

    assert exporter.calls == 1
    assert len({recording.sha256 for recording in recordings}) == 1
    assert len({recording.path for recording in recordings}) == 1


def test_materializer_never_follows_cache_symlinks_outside_private_root(
    tmp_path: Path,
) -> None:
    command = _command(tmp_path)
    exporter = FakeExporter()
    materializer = SessionRecordingMaterializer(tmp_path / "private", exporter=exporter)
    outside = tmp_path / "outside"
    outside.mkdir()
    session_cache = materializer.recordings_root / command.session_id
    session_cache.symlink_to(outside, target_is_directory=True)

    with pytest.raises(RecordingMaterializationError, match="must not be a symlink"):
        materializer.materialize(command)

    assert list(outside.iterdir()) == []
    session_cache.unlink()
    session_cache.mkdir()
    outside_recording = outside / RECORDING_CACHE_FILENAME
    outside_recording.write_bytes(b"do-not-touch")
    (session_cache / RECORDING_CACHE_FILENAME).symlink_to(outside_recording)

    recording = materializer.materialize(command)

    assert recording.path.read_bytes().startswith(b"RRD")
    assert outside_recording.read_bytes() == b"do-not-touch"


def test_materializer_revalidates_prepared_source_without_following_replaced_symlink(
    tmp_path: Path,
) -> None:
    command = _command(tmp_path / "session")
    exporter = FakeExporter()
    materializer = SessionRecordingMaterializer(tmp_path / "private", exporter=exporter)
    outside = tmp_path / "outside.k1mqtt"
    outside.write_bytes(command.primary_artifact.path.read_bytes())
    command.primary_artifact.path.unlink()
    command.primary_artifact.path.symlink_to(outside)

    with pytest.raises(RecordingMaterializationError, match="missing or unsafe"):
        materializer.materialize(command)

    assert exporter.calls == 0
    assert outside.read_bytes() == b"native-source-recording"


def test_materializer_exports_only_validated_prefix_before_crash_tail(
    tmp_path: Path,
) -> None:
    command = _command(tmp_path / "session")
    committed_raw_bytes = command.primary_artifact.replay_byte_length
    committed_metadata_bytes = command.artifacts[1].replay_byte_length
    with command.primary_artifact.path.open("ab") as stream:
        stream.write(b"uncommitted-raw-tail")
    metadata = command.primary_artifact.path.with_name("mqtt.metadata.jsonl")
    with metadata.open("ab") as stream:
        stream.write(b'{"record_type":"message"')
    observed: list[tuple[int, int, set[str]]] = []

    class PrefixExporter(FakeExporter):
        def __call__(
            self,
            source: Path,
            destination: Path,
            *,
            artifacts: dict[str, Path],
        ) -> dict[str, object]:
            observed.append(
                (
                    source.stat().st_size,
                    artifacts["index"].stat().st_size,
                    set(artifacts),
                )
            )
            assert artifacts["primary"] == source
            assert all(path.parent == source.parent for path in artifacts.values())
            return super().__call__(source, destination)

    exporter = PrefixExporter()
    materializer = SessionRecordingMaterializer(tmp_path / "private", exporter=exporter)

    recording = materializer.materialize(command)

    assert observed == [(committed_raw_bytes, committed_metadata_bytes, {"primary", "index"})]
    assert recording.source_sha256 == hashlib.sha256(b"native-source-recording").hexdigest()
    assert command.primary_artifact.path.read_bytes().endswith(b"uncommitted-raw-tail")


def test_global_singleflight_bounds_exports_across_different_sessions(tmp_path: Path) -> None:
    first = _command(tmp_path / "first")
    second = replace(
        _command(tmp_path / "second"),
        session_id="20260716T205633Z_viewer_live",
    )

    class ConcurrencyExporter(FakeExporter):
        def __init__(self) -> None:
            super().__init__()
            self.active = 0
            self.max_active = 0

        def __call__(self, source: Path, destination: Path) -> dict[str, object]:
            with self._lock:
                self.active += 1
                self.max_active = max(self.max_active, self.active)
            try:
                time.sleep(0.03)
                return super().__call__(source, destination)
            finally:
                with self._lock:
                    self.active -= 1

    exporter = ConcurrencyExporter()
    materializer = SessionRecordingMaterializer(tmp_path / "private", exporter=exporter)

    with ThreadPoolExecutor(max_workers=2) as executor:
        tuple(executor.map(materializer.materialize, (first, second)))

    assert exporter.calls == 2
    assert exporter.max_active == 1


def test_cross_process_file_lock_serializes_independent_materializer_instances(
    tmp_path: Path,
) -> None:
    first = _command(tmp_path / "first")
    second = replace(
        _command(tmp_path / "second"),
        session_id="20260716T205634Z_viewer_live",
    )

    class ConcurrencyExporter(FakeExporter):
        def __init__(self) -> None:
            super().__init__()
            self.active = 0
            self.max_active = 0

        def __call__(self, source: Path, destination: Path) -> dict[str, object]:
            with self._lock:
                self.active += 1
                self.max_active = max(self.max_active, self.active)
            try:
                time.sleep(0.03)
                return super().__call__(source, destination)
            finally:
                with self._lock:
                    self.active -= 1

    exporter = ConcurrencyExporter()
    private_root = tmp_path / "private"
    first_materializer = SessionRecordingMaterializer(private_root, exporter=exporter)
    second_materializer = SessionRecordingMaterializer(private_root, exporter=exporter)

    with ThreadPoolExecutor(max_workers=2) as executor:
        first_future = executor.submit(first_materializer.materialize, first)
        second_future = executor.submit(second_materializer.materialize, second)
        assert first_future.result(timeout=2).path.exists()
        assert second_future.result(timeout=2).path.exists()

    assert exporter.calls == 2
    assert exporter.max_active == 1


def test_export_scavenges_confined_crash_candidates_before_quota_check(
    tmp_path: Path,
) -> None:
    command = _command(tmp_path / "session")
    private_root = tmp_path / "private"
    materializer = SessionRecordingMaterializer(
        private_root,
        exporter=FakeExporter(),
        cache_max_bytes=32 * 1024,
        free_space_reserve_bytes=0,
    )
    session_cache = materializer.recordings_root / command.session_id
    session_cache.mkdir()
    stale_candidate = session_cache / ".scene.deadbeef.candidate.rrd"
    stale_candidate.write_bytes(b"x" * (24 * 1024))
    stale_export_temp = session_cache / "..scene.deadbeef.candidate.rrd.uuid.tmp"
    stale_export_temp.write_bytes(b"x" * 1024)
    stale_stage = session_cache / ".source.deadbeef.tmp"
    stale_stage.mkdir()
    (stale_stage / "mqtt.raw.k1mqtt").write_bytes(b"x" * 1024)

    recording = materializer.materialize(command)

    assert recording.path.exists()
    assert not stale_candidate.exists()
    assert not stale_export_temp.exists()
    assert not stale_stage.exists()


def test_cached_recording_and_response_lease_do_not_wait_for_other_export(
    tmp_path: Path,
) -> None:
    first = _command(tmp_path / "first")
    second = replace(
        _command(tmp_path / "second"),
        session_id="20260716T205633Z_viewer_live",
    )

    class BlockingSecondExporter(FakeExporter):
        def __init__(self) -> None:
            super().__init__()
            self.second_started = threading.Event()
            self.release_second = threading.Event()

        def __call__(self, source: Path, destination: Path) -> dict[str, object]:
            with self._lock:
                next_call = self.calls + 1
            if next_call == 2:
                self.second_started.set()
                if not self.release_second.wait(timeout=2):
                    raise AssertionError("test did not release the blocked export")
            return super().__call__(source, destination)

    exporter = BlockingSecondExporter()
    materializer = SessionRecordingMaterializer(tmp_path / "private", exporter=exporter)
    first_recording = materializer.materialize(first)

    with ThreadPoolExecutor(max_workers=3) as executor:
        exporting = executor.submit(materializer.materialize, second)
        assert exporter.second_started.wait(timeout=1)

        cached = executor.submit(materializer.materialize, first).result(timeout=0.2)
        pinned, release = executor.submit(
            materializer.materialize_pinned,
            first,
        ).result(timeout=0.2)

        assert cached == first_recording
        assert pinned == first_recording
        release()
        exporter.release_second.set()
        assert exporting.result(timeout=1).session_id == second.session_id


def test_cache_quota_evicts_lru_derived_recording_without_deleting_raw(
    tmp_path: Path,
) -> None:
    first = _command(tmp_path / "first")
    second = replace(
        _command(tmp_path / "second"),
        session_id="20260716T205633Z_viewer_live",
    )

    class SizedExporter(FakeExporter):
        def __call__(self, source: Path, destination: Path) -> dict[str, object]:
            with self._lock:
                self.calls += 1
            destination.write_bytes(b"R" * (40 * 1024))
            return {
                "source_sha256": _sha256(source),
                "rrd_sha256": _sha256(destination),
                "rrd_bytes": destination.stat().st_size,
                "timeline": "session_time",
                "timeline_start_ns": 0,
                "timeline_end_ns": 1,
            }

    materializer = SessionRecordingMaterializer(
        tmp_path / "private",
        exporter=SizedExporter(),
        cache_max_bytes=50 * 1024,
        free_space_reserve_bytes=0,
    )
    first_recording = materializer.materialize(first)
    first_source_bytes = first.primary_artifact.path.read_bytes()

    second_recording = materializer.materialize(second)

    assert not first_recording.path.exists()
    assert second_recording.path.exists()
    assert first.primary_artifact.path.read_bytes() == first_source_bytes
    assert second.primary_artifact.path.exists()


def test_pinned_cache_entry_survives_eviction_until_release(tmp_path: Path) -> None:
    first = _command(tmp_path / "first")
    second = replace(
        _command(tmp_path / "second"),
        session_id="20260716T205633Z_viewer_live",
    )

    class SizedExporter(FakeExporter):
        def __call__(self, source: Path, destination: Path) -> dict[str, object]:
            with self._lock:
                self.calls += 1
            destination.write_bytes(b"R" * (40 * 1024))
            return {
                "source_sha256": _sha256(source),
                "rrd_sha256": _sha256(destination),
                "rrd_bytes": destination.stat().st_size,
                "timeline": "session_time",
                "timeline_start_ns": 0,
                "timeline_end_ns": 1,
            }

    materializer = SessionRecordingMaterializer(
        tmp_path / "private",
        exporter=SizedExporter(),
        cache_max_bytes=50 * 1024,
        free_space_reserve_bytes=0,
    )
    first_recording, release = materializer.materialize_pinned(first)

    with pytest.raises(RecordingMaterializationError, match="cache quota"):
        materializer.materialize(second)

    assert first_recording.path.exists()

    release()
    second_recording = materializer.materialize(second)

    assert not first_recording.path.exists()
    assert second_recording.path.exists()


def test_cache_free_space_reserve_refuses_export_without_touching_raw(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    command = _command(tmp_path / "session")
    source_bytes = command.primary_artifact.path.read_bytes()
    materializer = SessionRecordingMaterializer(
        tmp_path / "private",
        exporter=FakeExporter(),
        cache_max_bytes=1024 * 1024,
        free_space_reserve_bytes=1024,
    )
    monkeypatch.setattr(
        recording_module.shutil,
        "disk_usage",
        lambda _path: SimpleNamespace(free=1023),
    )

    with pytest.raises(RecordingMaterializationError, match="free-space reserve"):
        materializer.materialize(command)

    assert command.primary_artifact.path.read_bytes() == source_bytes
