"""Tests for WS4: Replay and Bundle Export."""

from __future__ import annotations

import hashlib
import json
import sqlite3
import types
import zipfile
from pathlib import Path

import pytest

from easycat.debug.bundle import (
    FORMAT_VERSION,
    ArtifactEntry,
    BundleError,
    BundleExists,
    BundleInUseError,
    BundleRecoveryError,
    BundleValidationError,
    BundleVersionError,
    CommittableCheckpoint,
    DebugCaptureDisabledError,
    Manifest,
    RunBundle,
    discover_bundles,
)
from easycat.debug.export import export_debug_bundle
from easycat.debug.testing import load_bundle
from easycat.runtime.artifacts import FilesystemArtifactStore, InMemoryArtifactStore
from easycat.runtime.journal_memory import InMemoryRingBuffer
from easycat.runtime.records import ErrorInfo, JournalRecord, JournalRecordKind, TimingInfo
from easycat.runtime.replay import (
    REPLAY_IGNORE_FIELDS,
    ProviderVersionMismatchError,
    ReplayFidelity,
    ReplaySideEffectBlocked,
    ReplaySpec,
    ToolReplayPolicy,
)
from easycat.runtime.replay import ReplaySpec as StubReplaySpec
from easycat.stages.base import NONDETERMINISTIC_FIELDS

# ── Helpers ──────────────────────────────────────────────────────


def _make_bundle_zip(
    tmp_path: Path,
    *,
    manifest: dict | None = None,
    journal_lines: list[str] | None = None,
    artifacts: dict[str, bytes] | None = None,
    name: str = "test.zip",
) -> Path:
    """Create a minimal bundle zip for testing."""
    if manifest is None:
        manifest = {"format_version": FORMAT_VERSION}
    if journal_lines is None:
        journal_lines = []
    if artifacts is None:
        artifacts = {}

    bundle_path = tmp_path / name
    with zipfile.ZipFile(bundle_path, "w", zipfile.ZIP_DEFLATED) as zf:
        zf.writestr("manifest.json", json.dumps(manifest))
        zf.writestr("journal.ndjson", "\n".join(journal_lines))
        for ref, data in artifacts.items():
            zf.writestr(f"artifacts/{ref}.bin", data)
    return bundle_path


class _FakeJournal:
    """Minimal journal stub for export tests."""

    def __init__(self, records=None, *, dropped_records: int = 0):
        self._records = records or []
        self.dropped_records = dropped_records

    def read(self, start=0, limit=None):
        return self._records[start:]


class _FakeArtifactStore:
    """Minimal artifact store stub for export tests."""

    def __init__(self, store=None):
        self._store = store or {}


class _FakeSession:
    """Minimal session stub for export tests."""

    def __init__(
        self,
        *,
        debug="light",
        journal=None,
        artifact_store=None,
        config=None,
    ):
        self._debug = debug
        self._journal = journal
        self._artifact_store = artifact_store
        self._config = config


# ── TestReplaySpec ───────────────────────────────────────────────


class TestReplaySpec:
    def test_fidelity_is_required(self):
        """ReplaySpec must require fidelity (no default)."""
        with pytest.raises(TypeError):
            ReplaySpec()  # type: ignore[call-arg]

    def test_construction_with_fidelity(self):
        spec = ReplaySpec(fidelity=ReplayFidelity.ARTIFACT)
        assert spec.fidelity == ReplayFidelity.ARTIFACT
        assert spec.from_sequence is None
        assert spec.to_sequence is None
        assert spec.stage_filter is None
        assert spec.overrides == {}
        assert spec.timing == "fast"
        assert spec.force is False
        assert spec.tool_policy == ToolReplayPolicy.DENY

    def test_tool_policy_defaults_to_deny(self):
        spec = ReplaySpec(fidelity=ReplayFidelity.LIVE)
        assert spec.tool_policy == ToolReplayPolicy.DENY

    def test_overrides_and_filter(self):
        spec = ReplaySpec(
            fidelity=ReplayFidelity.SIMULATED,
            from_sequence=5,
            to_sequence=10,
            stage_filter=["stt", "agent"],
            overrides={"key": "val"},
            timing="wall",
            force=True,
            tool_policy=ToolReplayPolicy.ALLOW,
        )
        assert spec.from_sequence == 5
        assert spec.to_sequence == 10
        assert spec.stage_filter == ["stt", "agent"]
        assert spec.overrides == {"key": "val"}
        assert spec.timing == "wall"
        assert spec.force is True
        assert spec.tool_policy == ToolReplayPolicy.ALLOW

    def test_frozen(self):
        spec = ReplaySpec(fidelity=ReplayFidelity.ARTIFACT)
        with pytest.raises(AttributeError):
            spec.fidelity = ReplayFidelity.LIVE  # type: ignore[misc]


# ── TestReplayFidelity ──────────────────────────────────────────


class TestReplayFidelity:
    def test_enum_values(self):
        assert ReplayFidelity.ARTIFACT.value == "artifact"
        assert ReplayFidelity.SIMULATED.value == "simulated"
        assert ReplayFidelity.LIVE.value == "live"

    def test_all_members(self):
        assert set(ReplayFidelity) == {
            ReplayFidelity.ARTIFACT,
            ReplayFidelity.SIMULATED,
            ReplayFidelity.LIVE,
        }


# ── TestToolReplayPolicy ────────────────────────────────────────


class TestToolReplayPolicy:
    def test_enum_values(self):
        assert ToolReplayPolicy.DENY.value == "deny"
        assert ToolReplayPolicy.STUB.value == "stub"
        assert ToolReplayPolicy.ALLOW.value == "allow"

    def test_all_members(self):
        assert set(ToolReplayPolicy) == {
            ToolReplayPolicy.DENY,
            ToolReplayPolicy.STUB,
            ToolReplayPolicy.ALLOW,
        }


# ── TestReplayIgnoreFields ──────────────────────────────────────


class TestReplayIgnoreFields:
    def test_includes_nondeterministic_fields(self):
        """REPLAY_IGNORE_FIELDS must be a superset of NONDETERMINISTIC_FIELDS."""
        assert NONDETERMINISTIC_FIELDS.issubset(REPLAY_IGNORE_FIELDS)

    def test_includes_ws4_extensions(self):
        assert "timing.wall_deadline_ns" in REPLAY_IGNORE_FIELDS
        assert "artifact_written_at" in REPLAY_IGNORE_FIELDS
        assert "artifact_hashed_at" in REPLAY_IGNORE_FIELDS

    def test_is_frozenset(self):
        assert isinstance(REPLAY_IGNORE_FIELDS, frozenset)


# ── TestRunBundleFormat ──────────────────────────────────────────


class TestRunBundleFormat:
    def test_export_and_load_round_trip(self, tmp_path):
        """A bundle exported then loaded should preserve journal content."""
        journal_lines = [
            json.dumps({"sequence": 1, "data": {"stage": "stt"}, "turn_id": "t1"}),
            json.dumps({"sequence": 2, "data": {"stage": "agent"}, "turn_id": "t1"}),
        ]
        ref = hashlib.sha256(b"hello").hexdigest()
        bundle_path = _make_bundle_zip(
            tmp_path,
            journal_lines=journal_lines,
            artifacts={ref: b"hello"},
        )

        loaded = RunBundle.load(bundle_path)
        assert loaded.format_version == FORMAT_VERSION
        records = list(loaded.records())
        assert len(records) == 2
        assert records[0]["sequence"] == 1
        assert records[1]["data"]["stage"] == "agent"

    def test_load_rejects_non_object_or_malformed_json_lines(self, tmp_path):
        journal_lines = [
            json.dumps(["not", "a", "record"]),
            json.dumps("not a record"),
            "{not valid json",
            json.dumps({"sequence": 1, "data": {"stage": "stt"}}),
        ]
        bundle_path = _make_bundle_zip(tmp_path, journal_lines=journal_lines)

        with pytest.raises(
            BundleValidationError, match="line 1 must be a JSON object"
        ) as exc_info:
            RunBundle.load(bundle_path)

        assert exc_info.value.reason_code == "INVALID_JOURNAL"

    def test_filter_by_stage(self, tmp_path):
        journal_lines = [
            json.dumps({"sequence": 1, "data": {"stage": "stt"}}),
            json.dumps({"sequence": 2, "data": {"stage": "agent"}}),
            json.dumps({"sequence": 3, "data": {"stage": "stt"}}),
        ]
        bundle_path = _make_bundle_zip(tmp_path, journal_lines=journal_lines)
        loaded = RunBundle.load(bundle_path)
        stt_records = loaded.filter_by_stage("stt")
        assert len(stt_records) == 2
        assert all(r["data"]["stage"] == "stt" for r in stt_records)

    def test_filter_by_turn(self, tmp_path):
        journal_lines = [
            json.dumps({"sequence": 1, "turn_id": "t1"}),
            json.dumps({"sequence": 2, "turn_id": "t2"}),
            json.dumps({"sequence": 3, "turn_id": "t1"}),
        ]
        bundle_path = _make_bundle_zip(tmp_path, journal_lines=journal_lines)
        loaded = RunBundle.load(bundle_path)
        t1_records = loaded.filter_by_turn("t1")
        assert len(t1_records) == 2

    def test_lookup_by_sequence(self, tmp_path):
        journal_lines = [
            json.dumps({"sequence": 1, "data": {"stage": "stt"}}),
            json.dumps({"sequence": 2, "data": {"stage": "agent"}}),
        ]
        bundle_path = _make_bundle_zip(tmp_path, journal_lines=journal_lines)
        loaded = RunBundle.load(bundle_path)
        found = loaded.lookup_by_sequence(2)
        assert found is not None
        assert found["data"]["stage"] == "agent"
        assert loaded.lookup_by_sequence(999) is None


# ── TestRunBundleSave ────────────────────────────────────────────


class TestRunBundleSave:
    def test_save_round_trips_records_and_artifacts(self, tmp_path):
        """RunBundle.save → load preserves records, artifacts, and entry points."""
        ref = hashlib.sha256(b"audio").hexdigest()
        src_path = _make_bundle_zip(
            tmp_path,
            manifest={
                "format_version": FORMAT_VERSION,
                "provider_versions": {"stt": "openai-1.0"},
                "replay_entry_points": [{"sequence": 1, "stage": "stt", "unit_id": "u1"}],
            },
            journal_lines=[
                json.dumps({"sequence": 1, "turn_id": "t1", "output_ref": ref}),
                json.dumps({"sequence": 2, "turn_id": "t1"}),
            ],
            artifacts={ref: b"audio"},
            name="src.zip",
        )
        original = RunBundle.load(src_path)

        out = tmp_path / "saved.zip"
        original.save(out)
        reloaded = RunBundle.load(out)

        assert list(reloaded.records()) == list(original.records())
        assert reloaded.artifact_blobs == original.artifact_blobs
        assert reloaded.manifest.provider_versions == {"stt": "openai-1.0"}
        assert [cp.sequence for cp in reloaded.replay_entry_points] == [1]

    def test_save_mirrors_export_member_layout(self, tmp_path):
        """The saved zip uses the same member names export_debug_bundle writes."""
        ref = hashlib.sha256(b"x").hexdigest()
        src_path = _make_bundle_zip(
            tmp_path,
            journal_lines=[json.dumps({"sequence": 1, "input_ref": ref})],
            artifacts={ref: b"x"},
            name="src2.zip",
        )
        out = tmp_path / "saved2.zip"
        RunBundle.load(src_path).save(out)
        with zipfile.ZipFile(out, "r") as zf:
            names = set(zf.namelist())
        assert "manifest.json" in names
        assert "journal.ndjson" in names
        assert f"artifacts/{ref}.bin" in names

    @pytest.mark.parametrize("ref", ["not-a-sha", "a" * 64 + "\n"])
    def test_save_rejects_non_sha256_artifact_ref(self, tmp_path, ref):
        """A tampered in-memory bundle with a bad ref must not be written."""
        bundle = RunBundle(journal_ndjson=b"", artifact_blobs={ref: b"x"})
        out = tmp_path / "bad.zip"
        with pytest.raises(BundleValidationError) as exc_info:
            bundle.save(out)
        assert exc_info.value.reason_code == "INVALID_REF"
        assert not out.exists()

    def test_save_rejects_artifact_checksum_mismatch(self, tmp_path):
        """A blob stored under another payload's digest must not be written."""
        ref = hashlib.sha256(b"expected").hexdigest()
        bundle = RunBundle(journal_ndjson=b"", artifact_blobs={ref: b"tampered"})
        out = tmp_path / "bad-checksum.zip"

        with pytest.raises(BundleValidationError) as exc_info:
            bundle.save(out)

        assert exc_info.value.reason_code == "CHECKSUM_MISMATCH"
        assert not out.exists()

    def test_save_is_atomic_on_failure(self, tmp_path, monkeypatch):
        """A mid-write failure must not leave the destination or a temp file."""
        ref = hashlib.sha256(b"x").hexdigest()
        bundle = RunBundle(journal_ndjson=b"", artifact_blobs={ref: b"x"})
        out = tmp_path / "atomic.zip"

        real_writestr = zipfile.ZipFile.writestr

        def boom(self, name, data, *a, **kw):
            if name == "journal.ndjson":
                raise OSError("disk full")
            return real_writestr(self, name, data, *a, **kw)

        monkeypatch.setattr(zipfile.ZipFile, "writestr", boom)
        with pytest.raises(OSError, match="disk full"):
            bundle.save(out)
        assert not out.exists()
        assert list(tmp_path.glob("*.tmp")) == []

    def test_save_rejects_invalid_journal_before_writing(self, tmp_path):
        bundle = RunBundle(journal_ndjson=b'{"sequence": 1}\nnot-json\n')
        out = tmp_path / "invalid-journal.zip"

        with pytest.raises(BundleValidationError) as exc_info:
            bundle.save(out)

        assert exc_info.value.reason_code == "INVALID_JOURNAL"
        assert not out.exists()

    def test_save_rejects_dangling_artifact_reference_before_writing(self, tmp_path):
        ref = "a" * 64
        bundle = RunBundle(journal_ndjson=json.dumps({"sequence": 1, "output_ref": ref}).encode())
        out = tmp_path / "dangling-artifact.zip"

        with pytest.raises(BundleValidationError) as exc_info:
            bundle.save(out)

        assert exc_info.value.reason_code == "MISSING_ARTIFACT"
        assert not out.exists()


# ── TestSliceBundleByTurn ────────────────────────────────────────


class TestSliceBundleByTurn:
    def test_slice_keeps_only_target_turn(self, tmp_path):
        from easycat.debug.export import slice_bundle_by_turn

        ref_a = hashlib.sha256(b"a").hexdigest()
        ref_b = hashlib.sha256(b"b").hexdigest()
        src_path = _make_bundle_zip(
            tmp_path,
            manifest={
                "format_version": FORMAT_VERSION,
                "replay_entry_points": [
                    {"sequence": 1, "stage": "stt", "unit_id": "u1"},
                    {"sequence": 9, "stage": "tts", "unit_id": "u9"},
                ],
            },
            journal_lines=[
                json.dumps({"sequence": 1, "turn_id": "t1", "output_ref": ref_a}),
                json.dumps({"sequence": 2, "turn_id": "t2", "output_ref": ref_b}),
                json.dumps({"sequence": 3, "turn_id": "t1"}),
            ],
            artifacts={ref_a: b"a", ref_b: b"b"},
            name="slice-src.zip",
        )
        bundle = RunBundle.load(src_path)
        sliced = slice_bundle_by_turn(bundle, "t1")

        records = list(sliced.records())
        assert {r["turn_id"] for r in records} == {"t1"}
        # Only t1's referenced artifact survives; t2's blob is dropped.
        assert set(sliced.artifact_blobs) == {ref_a}
        # Entry points are filtered to the sliced sequence set.
        assert [cp.sequence for cp in sliced.replay_entry_points] == [1]

    def test_slice_missing_turn_raises(self, tmp_path):
        from easycat.debug.export import slice_bundle_by_turn

        src_path = _make_bundle_zip(
            tmp_path,
            journal_lines=[json.dumps({"sequence": 1, "turn_id": "t1"})],
            name="slice-missing.zip",
        )
        bundle = RunBundle.load(src_path)
        with pytest.raises(ValueError, match="No journal records"):
            slice_bundle_by_turn(bundle, "nope")

    def test_export_turn_bundle_round_trips(self, tmp_path):
        from easycat.debug.export import export_turn_bundle

        ref = hashlib.sha256(b"a").hexdigest()
        src_path = _make_bundle_zip(
            tmp_path,
            journal_lines=[
                json.dumps({"sequence": 1, "turn_id": "t1", "output_ref": ref}),
                json.dumps({"sequence": 2, "turn_id": "t2"}),
            ],
            artifacts={ref: b"a"},
            name="exp-src.zip",
        )
        bundle = RunBundle.load(src_path)
        out = tmp_path / "t1.zip"
        export_turn_bundle(bundle, "t1", out)

        reloaded = RunBundle.load(out)
        records = list(reloaded.records())
        assert len(records) == 1
        assert records[0]["turn_id"] == "t1"
        assert set(reloaded.artifact_blobs) == {ref}


# ── TestBundleManifest ───────────────────────────────────────────


class TestBundleManifest:
    def test_artifact_indexed_by_ref(self, tmp_path):
        data = b"artifact-data"
        ref = hashlib.sha256(data).hexdigest()
        bundle_path = _make_bundle_zip(tmp_path, artifacts={ref: data})
        loaded = RunBundle.load(bundle_path)
        assert ref in loaded.artifact_index
        assert loaded.artifact_index[ref].ref == ref
        assert loaded.artifact_index[ref].size_bytes == len(data)

    def test_format_version_preserved(self, tmp_path):
        bundle_path = _make_bundle_zip(tmp_path, manifest={"format_version": FORMAT_VERSION})
        loaded = RunBundle.load(bundle_path)
        assert loaded.format_version == FORMAT_VERSION

    def test_provider_versions(self, tmp_path):
        bundle_path = _make_bundle_zip(
            tmp_path,
            manifest={
                "format_version": FORMAT_VERSION,
                "provider_versions": {"stt": "deepgram-v3", "tts": "elevenlabs-v2"},
            },
        )
        loaded = RunBundle.load(bundle_path)
        assert loaded.manifest.provider_versions["stt"] == "deepgram-v3"
        assert loaded.manifest.provider_versions["tts"] == "elevenlabs-v2"


# ── TestBundleExport ────────────────────────────────────────────


class TestBundleExport:
    def test_export_api(self, tmp_path):
        """export_debug_bundle creates a valid zip."""
        session = _FakeSession(
            debug="light",
            journal=_FakeJournal(),
            artifact_store=_FakeArtifactStore(),
        )
        path = tmp_path / "export.zip"
        export_debug_bundle(session, path)
        assert path.exists()
        # Should be a valid zip
        with zipfile.ZipFile(path, "r") as zf:
            assert "manifest.json" in zf.namelist()
            assert "journal.ndjson" in zf.namelist()

    def test_export_accepts_public_journal_only_session_stub(self, tmp_path):
        session = types.SimpleNamespace(journal=_FakeJournal())
        path = tmp_path / "public-journal.zip"

        export_debug_bundle(session, path)

        with zipfile.ZipFile(path, "r") as zf:
            assert zf.read("journal.ndjson") == b""
            assert not any(name.startswith("artifacts/") for name in zf.namelist())

    def test_export_accepts_private_journal_without_artifact_store(self, tmp_path):
        session = types.SimpleNamespace(_journal=_FakeJournal(), _debug="light")
        path = tmp_path / "private-journal.zip"

        export_debug_bundle(session, path)

        assert path.exists()

    def test_debug_off_raises(self, tmp_path):
        session = _FakeSession(debug="off")
        path = tmp_path / "export.zip"
        with pytest.raises(DebugCaptureDisabledError, match="debug='off'"):
            export_debug_bundle(session, path)

    def test_overwrite_false_raises(self, tmp_path):
        session = _FakeSession(debug="light", journal=_FakeJournal())
        path = tmp_path / "export.zip"
        export_debug_bundle(session, path)
        with pytest.raises(BundleExists, match="already exists"):
            export_debug_bundle(session, path, overwrite=False)

    def test_overwrite_true_succeeds(self, tmp_path):
        session = _FakeSession(debug="light", journal=_FakeJournal())
        path = tmp_path / "export.zip"
        export_debug_bundle(session, path)
        export_debug_bundle(session, path, overwrite=True)
        assert path.exists()

    def test_export_with_artifacts(self, tmp_path):
        """Artifacts from the store are included in the bundle."""
        data = b"tts-audio-bytes"
        ref = hashlib.sha256(data).hexdigest()
        session = _FakeSession(
            debug="full",
            journal=_FakeJournal([{"sequence": 1, "input_ref": ref}]),
            artifact_store=_FakeArtifactStore({ref: data}),
        )
        path = tmp_path / "export.zip"
        export_debug_bundle(session, path)

        with zipfile.ZipFile(path, "r") as zf:
            assert f"artifacts/{ref}.bin" in zf.namelist()
            assert zf.read(f"artifacts/{ref}.bin") == data

    def test_export_rejects_missing_referenced_artifact(self, tmp_path):
        ref = hashlib.sha256(b"missing").hexdigest()
        path = tmp_path / "missing-artifact.zip"

        with pytest.raises(BundleValidationError) as exc_info:
            export_debug_bundle(
                _FakeSession(
                    debug="full",
                    journal=_FakeJournal([{"sequence": 1, "input_ref": ref}]),
                ),
                path,
            )

        assert exc_info.value.reason_code == "MISSING_ARTIFACT"
        assert ref in str(exc_info.value)
        assert not path.exists()

    def test_export_pages_journal_in_bounded_reads(self, tmp_path):
        class _PagedJournal:
            def __init__(self):
                self.records = [
                    {"sequence": sequence, "kind": "event", "name": f"event-{sequence}"}
                    for sequence in range(1, 2_506)
                ]
                self.latest_sequence = 2_505
                self.calls = []

            def read(self, start=0, limit=None):
                self.calls.append((start, limit))
                matching = [record for record in self.records if record["sequence"] >= start]
                return matching[:limit]

        journal = _PagedJournal()
        path = tmp_path / "paged.zip"

        export_debug_bundle(_FakeSession(debug="full", journal=journal), path)

        assert journal.calls == [(0, 1_000), (1_001, 1_000), (2_001, 1_000)]
        with zipfile.ZipFile(path) as zf:
            lines = zf.read("journal.ndjson").splitlines()
        assert len(lines) == 2_505
        assert json.loads(lines[0])["sequence"] == 1
        assert json.loads(lines[-1])["sequence"] == 2_505

    def test_export_snapshots_bounded_journal_before_paging(self, tmp_path, monkeypatch):
        import easycat.debug.export as export_module

        journal = InMemoryRingBuffer(capacity=2)
        for name in ("first", "second"):
            journal.append(JournalRecordKind.EVENT, name, "sess")
        original_snapshot = journal.snapshot

        def _snapshot_then_evict():
            frozen = original_snapshot()
            journal.append(JournalRecordKind.EVENT, "third", "sess")
            journal.append(JournalRecordKind.EVENT, "fourth", "sess")
            return frozen

        monkeypatch.setattr(journal, "snapshot", _snapshot_then_evict)
        monkeypatch.setattr(export_module, "_JOURNAL_PAGE_SIZE", 1)
        path = tmp_path / "bounded-snapshot.zip"

        export_debug_bundle(_FakeSession(debug="full", journal=journal), path)

        with zipfile.ZipFile(path) as zf:
            records = [json.loads(line) for line in zf.read("journal.ndjson").splitlines()]
        assert [record["name"] for record in records] == ["first", "second"]

    def test_export_rejects_sequence_gap_from_unsnapshotted_paged_journal(
        self, tmp_path, monkeypatch
    ):
        import easycat.debug.export as export_module

        class _EvictingPagedJournal:
            latest_sequence = 2

            def __init__(self):
                self.calls = 0

            def read(self, start=0, limit=None):
                self.calls += 1
                if self.calls == 1:
                    return [{"sequence": 1, "kind": "event", "name": "first"}]
                return [{"sequence": 3, "kind": "event", "name": "third"}]

        monkeypatch.setattr(export_module, "_JOURNAL_PAGE_SIZE", 1)
        path = tmp_path / "gapped.zip"

        with pytest.raises(BundleValidationError) as exc_info:
            export_debug_bundle(
                _FakeSession(debug="full", journal=_EvictingPagedJournal()),
                path,
            )

        assert exc_info.value.reason_code == "JOURNAL_CHANGED_DURING_EXPORT"
        assert not path.exists()

    def test_export_snapshots_memory_artifacts_before_streaming(self, tmp_path):
        store = InMemoryArtifactStore()
        first_ref = store.put(b"first")
        second_ref = store.put(b"second")
        replacement_ref: str | None = None

        class _MutatingJournal:
            def read(self, start=0, limit=None):
                nonlocal replacement_ref
                store.delete(second_ref)
                replacement_ref = store.put(b"replacement")
                return [
                    {"sequence": 1, "input_ref": first_ref},
                    {"sequence": 2, "output_ref": second_ref},
                ]

        path = tmp_path / "memory-artifact-snapshot.zip"
        export_debug_bundle(
            _FakeSession(
                debug="full",
                journal=_MutatingJournal(),
                artifact_store=store,
            ),
            path,
        )

        assert replacement_ref is not None
        with zipfile.ZipFile(path) as zf:
            names = set(zf.namelist())
            assert f"artifacts/{first_ref}.bin" in names
            assert f"artifacts/{second_ref}.bin" in names
            assert f"artifacts/{replacement_ref}.bin" not in names

    def test_export_streams_sharded_files_without_path_read_bytes(self, tmp_path, monkeypatch):
        payload = b"streamed-artifact" * 100
        store = FilesystemArtifactStore("sess", data_dir=tmp_path)
        ref = store.put(payload)
        artifact_path = tmp_path / "artifacts" / "sess" / ref[:2] / f"{ref}.bin"
        assert artifact_path.is_file()
        assert artifact_path.parent.name == ref[:2]

        original_read_bytes = Path.read_bytes

        def reject_artifact_materialization(self):
            if self.suffix == ".bin":
                raise AssertionError("filesystem artifact was materialized")
            return original_read_bytes(self)

        monkeypatch.setattr(Path, "read_bytes", reject_artifact_materialization)
        path = tmp_path / "streamed.zip"

        export_debug_bundle(
            _FakeSession(
                debug="full",
                journal=_FakeJournal([{"sequence": 1, "input_ref": ref}]),
                artifact_store=store,
            ),
            path,
        )

        with zipfile.ZipFile(path) as zf:
            assert zf.read(f"artifacts/{ref}.bin") == payload

    def test_export_does_not_read_artifacts_through_parent_symlink(self, tmp_path):
        """The exporter must not bypass the store's parent-link guard."""
        payload = b"outside artifact"
        ref = hashlib.sha256(payload).hexdigest()
        outside_dir = tmp_path / "outside"
        outside_path = outside_dir / "sess" / ref[:2] / f"{ref}.bin"
        outside_path.parent.mkdir(parents=True)
        outside_path.write_bytes(payload)
        artifacts_dir = tmp_path / "artifacts"
        try:
            artifacts_dir.symlink_to(outside_dir, target_is_directory=True)
        except OSError:
            pytest.skip("symlinks are unavailable in this test environment")
        store = FilesystemArtifactStore("sess", data_dir=tmp_path)
        path = tmp_path / "linked-artifact.zip"

        with pytest.raises(BundleValidationError) as exc_info:
            export_debug_bundle(
                _FakeSession(
                    debug="full",
                    journal=_FakeJournal([{"sequence": 1, "input_ref": ref}]),
                    artifact_store=store,
                ),
                path,
            )

        assert exc_info.value.reason_code == "MISSING_ARTIFACT"
        assert not path.exists()
        assert outside_path.read_bytes() == payload

    def test_export_does_not_read_artifacts_through_data_dir_symlink(self, tmp_path):
        payload = b"outside artifact"
        ref = hashlib.sha256(payload).hexdigest()
        outside_data = tmp_path / "outside-data"
        outside_path = outside_data / "artifacts" / "sess" / ref[:2] / f"{ref}.bin"
        outside_path.parent.mkdir(parents=True)
        outside_path.write_bytes(payload)
        linked_data = tmp_path / "linked-data"
        try:
            linked_data.symlink_to(outside_data, target_is_directory=True)
        except OSError:
            pytest.skip("symlinks are unavailable in this test environment")

        class _LinkedFilesystemStore:
            _dir = linked_data / "artifacts" / "sess"

        path = tmp_path / "linked-data-artifact.zip"
        with pytest.raises(BundleValidationError) as exc_info:
            export_debug_bundle(
                _FakeSession(
                    debug="full",
                    journal=_FakeJournal([{"sequence": 1, "input_ref": ref}]),
                    artifact_store=_LinkedFilesystemStore(),
                ),
                path,
            )

        assert exc_info.value.reason_code == "MISSING_ARTIFACT"
        assert not path.exists()
        assert outside_path.read_bytes() == payload

    def test_inline_export_streams_sharded_files(self, tmp_path):
        payload = b"inline-streamed-artifact"
        store = FilesystemArtifactStore("sess", data_dir=tmp_path)
        ref = store.put(payload)
        path = tmp_path / "inline-streamed.zip"

        export_debug_bundle(
            _FakeSession(
                debug="full",
                journal=_FakeJournal([{"sequence": 1, "input_ref": ref}]),
                artifact_store=store,
            ),
            path,
            inline_artifacts=True,
        )

        loaded = RunBundle.load(path)
        assert loaded.artifact_blobs[ref] == payload

    def test_export_rejects_oversize_artifact_before_reading_it(self, tmp_path, monkeypatch):
        import easycat.debug.export as export_module

        store = FilesystemArtifactStore("sess", data_dir=tmp_path)
        ref = store.put(b"seventh")
        monkeypatch.setattr(export_module, "_ARTIFACT_SIZE_CAP", 6)

        def fail_if_read(_source):
            raise AssertionError("oversize artifact should fail from stat size")

        monkeypatch.setattr(export_module, "_artifact_chunks", fail_if_read)
        path = tmp_path / "oversize.zip"

        with pytest.raises(BundleValidationError) as exc_info:
            export_debug_bundle(
                _FakeSession(
                    debug="full",
                    journal=_FakeJournal([{"sequence": 1, "input_ref": ref}]),
                    artifact_store=store,
                ),
                path,
            )

        assert exc_info.value.reason_code == "SIZE_EXCEEDED"
        assert not path.exists()

    def test_export_rejects_invalid_artifact_ref_before_writing(self, tmp_path):
        session = _FakeSession(
            debug="full",
            journal=_FakeJournal([{"sequence": 1, "input_ref": "../outside"}]),
            artifact_store=_FakeArtifactStore({"../outside": b"data"}),
        )
        path = tmp_path / "export.zip"

        with pytest.raises(BundleValidationError) as exc_info:
            export_debug_bundle(session, path)

        assert exc_info.value.reason_code == "INVALID_REF"
        assert not path.exists()

    def test_export_rejects_artifact_checksum_mismatch_before_writing(self, tmp_path):
        data = b"artifact-data"
        wrong_ref = hashlib.sha256(b"different-data").hexdigest()
        session = _FakeSession(
            debug="full",
            journal=_FakeJournal([{"sequence": 1, "input_ref": wrong_ref}]),
            artifact_store=_FakeArtifactStore({wrong_ref: data}),
        )
        path = tmp_path / "export.zip"

        with pytest.raises(BundleValidationError) as exc_info:
            export_debug_bundle(session, path)

        assert exc_info.value.reason_code == "CHECKSUM_MISMATCH"
        assert not path.exists()

    def test_export_preserves_existing_archive_after_write_failure(self, tmp_path, monkeypatch):
        session = _FakeSession(debug="light", journal=_FakeJournal())
        path = tmp_path / "export.zip"
        path.write_bytes(b"existing archive")

        def fail_write(*args, **kwargs):
            raise RuntimeError("archive write failed")

        monkeypatch.setattr(zipfile.ZipFile, "writestr", fail_write)

        with pytest.raises(RuntimeError, match="archive write failed") as exc_info:
            export_debug_bundle(session, path, overwrite=True)

        assert exc_info.value.code == "EASYCAT_E401"
        assert exc_info.value.context == {
            "path": str(path),
            "detail": "archive write failed",
        }
        assert path.read_bytes() == b"existing archive"
        assert list(tmp_path.glob("*.tmp")) == []

    def test_inline_export_rejects_artifact_count_overflow(self, tmp_path, monkeypatch):
        import easycat.debug.export as export_module

        monkeypatch.setattr(export_module, "_INLINE_ARTIFACT_COUNT_CAP", 1)
        first_ref = hashlib.sha256(b"first").hexdigest()
        second_ref = hashlib.sha256(b"second").hexdigest()
        artifacts = {first_ref: b"first", second_ref: b"second"}
        session = _FakeSession(
            debug="full",
            journal=_FakeJournal(
                [
                    {"sequence": 1, "input_ref": first_ref},
                    {"sequence": 2, "output_ref": second_ref},
                ]
            ),
            artifact_store=_FakeArtifactStore(artifacts),
        )

        with pytest.raises(BundleValidationError) as exc_info:
            export_debug_bundle(session, tmp_path / "too-many-inline.zip", inline_artifacts=True)

        assert exc_info.value.reason_code == "SIZE_EXCEEDED"

    def test_export_omits_stale_artifacts_after_clean_journal_reuse(self, tmp_path):
        from easycat.runtime import SqliteJournal

        session_id = "reused-session"
        artifact_store = FilesystemArtifactStore(session_id, data_dir=tmp_path)
        stale_ref = artifact_store.put(b"prior private audio")
        prior_journal = SqliteJournal(session_id, data_dir=tmp_path)
        prior_journal.append(
            JournalRecordKind.EVENT,
            "old",
            session_id,
            input_ref=stale_ref,
        )
        prior_journal.close()

        journal = SqliteJournal(session_id, data_dir=tmp_path)
        fresh_ref = artifact_store.put(b"fresh audio")
        journal.append(
            JournalRecordKind.EVENT,
            "fresh",
            session_id,
            input_ref=fresh_ref,
        )
        path = tmp_path / "reused-session.zip"
        try:
            export_debug_bundle(
                _FakeSession(
                    debug="full",
                    journal=journal,
                    artifact_store=artifact_store,
                ),
                path,
            )
        finally:
            journal.close()

        with zipfile.ZipFile(path) as zf:
            names = set(zf.namelist())
        assert f"artifacts/{fresh_ref}.bin" in names
        assert f"artifacts/{stale_ref}.bin" not in names

    def test_export_serializes_nested_enum_values(self, tmp_path):
        record = JournalRecord(
            sequence=1,
            session_id="sess",
            kind=JournalRecordKind.METRIC,
            name="metric",
            timing=TimingInfo(wall_ns=1, mono_ns=2, cpu_ns=3),
            data={
                "policy": ToolReplayPolicy.ALLOW,
                "nested": {"kind": JournalRecordKind.EVENT},
                "mixed_set": {1, "a"},
            },
            error=ErrorInfo(
                type="ExceptionGroup",
                message="boom",
                children=(ErrorInfo(type="ValueError", message="bad input"),),
            ),
            tags=frozenset({"debug", "runtime"}),
        )
        session = _FakeSession(debug="light", journal=_FakeJournal([record]))
        path = tmp_path / "export.zip"

        export_debug_bundle(session, path)

        with zipfile.ZipFile(path, "r") as zf:
            line = zf.read("journal.ndjson").decode("utf-8").strip()
        exported = json.loads(line)
        assert exported["kind"] == "metric"
        assert exported["timing"] == {"wall_ns": 1, "mono_ns": 2, "cpu_ns": 3}
        assert exported["data"]["policy"] == "allow"
        assert exported["data"]["nested"]["kind"] == "event"
        assert exported["data"]["mixed_set"] == ["a", 1]
        assert exported["error"] == {
            "type": "ExceptionGroup",
            "message": "boom",
            "traceback": None,
            "notes": None,
            "children": [
                {
                    "type": "ValueError",
                    "message": "bad input",
                    "traceback": None,
                    "notes": None,
                    "children": [],
                }
            ],
        }
        assert exported["tags"] == ["debug", "runtime"]


# ── TestBundleSafeDefaults ──────────────────────────────────────


class TestBundleSafeDefaults:
    def test_dropped_record_count_is_exported_and_loaded(self, tmp_path):
        session = _FakeSession(
            debug="light",
            journal=_FakeJournal(dropped_records=17),
        )
        path = tmp_path / "export.zip"

        export_debug_bundle(session, path)

        with zipfile.ZipFile(path, "r") as zf:
            manifest = json.loads(zf.read("manifest.json"))
        assert manifest["journal_dropped_records"] == 17
        assert RunBundle.load(path).manifest.journal_dropped_records == 17

    def test_api_key_excluded_from_snapshot(self, tmp_path):
        """Config fields containing 'key' should not appear in the snapshot."""

        class _FakeConfig:
            api_key = "sk-secret-123"
            stt = "deepgram"
            debug = "full"

        session = _FakeSession(
            debug="full",
            journal=_FakeJournal(),
            config=_FakeConfig(),
        )
        path = tmp_path / "export.zip"
        export_debug_bundle(session, path)

        with zipfile.ZipFile(path, "r") as zf:
            manifest = json.loads(zf.read("manifest.json"))
            snapshot = manifest.get("config_snapshot", {})
            assert "api_key" not in snapshot
            # 'stt' is in the safe allowlist
            assert "stt" in snapshot

    def test_banner_present(self, tmp_path):
        """Manifest should have a sharing banner."""
        session = _FakeSession(debug="light", journal=_FakeJournal())
        path = tmp_path / "export.zip"
        export_debug_bundle(session, path)

        with zipfile.ZipFile(path, "r") as zf:
            manifest = json.loads(zf.read("manifest.json"))
            assert "sharing_banner" in manifest
            assert len(manifest["sharing_banner"]) > 0


# ── TestBundleValidation ────────────────────────────────────────


class TestBundleValidation:
    @pytest.mark.parametrize("value", [-1, True, "1"])
    def test_journal_dropped_records_must_be_non_negative_integer(self, tmp_path, value):
        bundle_path = _make_bundle_zip(
            tmp_path,
            manifest={
                "format_version": FORMAT_VERSION,
                "journal_dropped_records": value,
            },
        )

        with pytest.raises(BundleValidationError, match="journal_dropped_records"):
            RunBundle.load(bundle_path)

    def test_path_traversal(self, tmp_path):
        """Bundles with path traversal in filenames should be rejected."""
        bundle_path = tmp_path / "bad.zip"
        with zipfile.ZipFile(bundle_path, "w") as zf:
            zf.writestr("manifest.json", json.dumps({"format_version": 1}))
            zf.writestr("journal.ndjson", "")
            zf.writestr("../etc/passwd", "pwned")

        with pytest.raises(BundleValidationError) as exc_info:
            RunBundle.load(bundle_path)
        assert exc_info.value.reason_code == "PATH_TRAVERSAL"
        assert exc_info.value.code == "EASYCAT_E402"
        assert exc_info.value.context["path"] == str(bundle_path)

    def test_bad_artifact_ref(self, tmp_path):
        """Artifact refs that are not valid SHA-256 hex should be rejected."""
        bundle_path = tmp_path / "bad_ref.zip"
        with zipfile.ZipFile(bundle_path, "w") as zf:
            zf.writestr(
                "manifest.json",
                json.dumps({"format_version": 1}),
            )
            zf.writestr("journal.ndjson", "")
            zf.writestr("artifacts/not-a-sha256.bin", b"data")

        with pytest.raises(BundleValidationError) as exc_info:
            RunBundle.load(bundle_path)
        assert exc_info.value.reason_code == "INVALID_REF"

    def test_dangling_journal_artifact_ref_is_rejected(self, tmp_path):
        ref = "a" * 64
        bundle_path = _make_bundle_zip(
            tmp_path,
            journal_lines=[json.dumps({"sequence": 1, "input_ref": ref})],
        )

        with pytest.raises(BundleValidationError) as exc_info:
            RunBundle.load(bundle_path)

        assert exc_info.value.reason_code == "MISSING_ARTIFACT"
        assert ref in str(exc_info.value)

    @pytest.mark.parametrize("ref", ["", "not-a-sha256-ref", 123])
    def test_malformed_journal_artifact_ref_is_rejected(self, tmp_path, ref):
        bundle_path = _make_bundle_zip(
            tmp_path,
            journal_lines=[json.dumps({"sequence": 1, "output_ref": ref})],
        )

        with pytest.raises(BundleValidationError) as exc_info:
            RunBundle.load(bundle_path)

        assert exc_info.value.reason_code == "INVALID_REF"

    def test_oversized_artifact(self, tmp_path):
        """Bundles exceeding the 500MB artifact cap should be rejected."""
        # We can't actually create a 500MB file in tests, so we monkey-patch
        # the size threshold. Instead test that the mechanism works by creating
        # a bundle and checking the validation path exists.
        # This is a structural test — the actual cap is enforced by the code.
        bundle_path = _make_bundle_zip(tmp_path)
        loaded = RunBundle.load(bundle_path)
        assert loaded is not None

    def test_format_version_too_new(self, tmp_path):
        """Bundles with format_version > current should be rejected."""
        bundle_path = _make_bundle_zip(tmp_path, manifest={"format_version": FORMAT_VERSION + 1})
        with pytest.raises(BundleVersionError, match="newer than"):
            RunBundle.load(bundle_path)

    @pytest.mark.parametrize(
        "manifest",
        [
            [],
            {"format_version": "1"},
            {"format_version": True},
        ],
    )
    def test_invalid_manifest_shape_is_rejected(self, tmp_path, manifest):
        bundle_path = _make_bundle_zip(tmp_path, manifest=manifest)
        with pytest.raises(BundleValidationError) as exc_info:
            RunBundle.load(bundle_path)
        assert exc_info.value.reason_code == "INVALID_MANIFEST"

    @pytest.mark.parametrize(
        "inline_artifacts",
        [
            [],
            {"a" * 64: 123},
        ],
    )
    def test_invalid_inline_artifacts_shape_is_rejected(self, tmp_path, inline_artifacts):
        bundle_path = _make_bundle_zip(
            tmp_path,
            manifest={
                "format_version": FORMAT_VERSION,
                "inline_artifacts": inline_artifacts,
            },
        )
        with pytest.raises(BundleValidationError) as exc_info:
            RunBundle.load(bundle_path)
        assert exc_info.value.reason_code == "INVALID_MANIFEST"

    @pytest.mark.parametrize(
        ("field", "value"),
        [
            ("provider_versions", []),
            ("config_snapshot", []),
            ("env_metadata", []),
            ("sharing_banner", {}),
        ],
    )
    def test_invalid_manifest_metadata_shape_is_rejected(self, tmp_path, field, value):
        bundle_path = _make_bundle_zip(
            tmp_path,
            manifest={
                "format_version": FORMAT_VERSION,
                field: value,
            },
        )
        with pytest.raises(BundleValidationError) as exc_info:
            RunBundle.load(bundle_path)
        assert exc_info.value.reason_code == "INVALID_MANIFEST"

    def test_env_metadata_values_must_be_strings(self, tmp_path):
        bundle_path = _make_bundle_zip(
            tmp_path,
            manifest={
                "format_version": FORMAT_VERSION,
                "env_metadata": {"python": 314},
            },
        )

        with pytest.raises(BundleValidationError) as exc_info:
            RunBundle.load(bundle_path)

        assert exc_info.value.reason_code == "INVALID_MANIFEST"

    def test_manifest_must_be_utf8_json(self, tmp_path):
        bundle_path = tmp_path / "invalid-utf8.zip"
        with zipfile.ZipFile(bundle_path, "w") as archive:
            archive.writestr("manifest.json", b"\xff\xfe")
            archive.writestr("journal.ndjson", b"")

        with pytest.raises(BundleValidationError) as exc_info:
            RunBundle.load(bundle_path)

        assert exc_info.value.reason_code == "INVALID_MANIFEST_JSON"

    def test_metadata_too_large(self, tmp_path):
        """Journal records with >1MB metadata should be rejected."""
        # Create a record with oversized metadata
        big_meta = {"x": "y" * 1_100_000}
        journal_lines = [json.dumps({"sequence": 1, "metadata": big_meta})]
        bundle_path = _make_bundle_zip(tmp_path, journal_lines=journal_lines)
        with pytest.raises(BundleValidationError) as exc_info:
            RunBundle.load(bundle_path)
        assert exc_info.value.reason_code == "METADATA_TOO_LARGE"

    def test_file_not_found(self):
        with pytest.raises(FileNotFoundError) as exc_info:
            RunBundle.load("/nonexistent/path.zip")
        assert exc_info.value.code == "EASYCAT_E402"


# ── TestBundlePartialJournal ────────────────────────────────────


class TestBundlePartialJournal:
    @pytest.mark.parametrize("directory_name", ["runs?blue", "runs#blue"])
    def test_from_partial_journal_handles_reserved_uri_characters(
        self,
        tmp_path,
        directory_name: str,
    ) -> None:
        data_dir = tmp_path / directory_name
        data_dir.mkdir()
        db_path = data_dir / "partial.sqlite"
        conn = sqlite3.connect(db_path)
        conn.execute("CREATE TABLE records (sequence INTEGER PRIMARY KEY, data TEXT)")
        conn.execute(
            "INSERT INTO records (sequence, data) VALUES (?, ?)",
            (1, json.dumps({"sequence": 1, "data": {"stage": "agent"}})),
        )
        conn.commit()
        conn.close()

        bundle = RunBundle.from_partial_journal(db_path)

        assert [record["sequence"] for record in bundle.records()] == [1]

    def test_from_partial_journal(self, tmp_path):
        """from_partial_journal should load from SQLite journal + artifacts."""
        db_path = tmp_path / "test.sqlite"
        conn = sqlite3.connect(str(db_path))
        conn.execute("CREATE TABLE records (sequence INTEGER PRIMARY KEY, data TEXT)")
        conn.execute(
            "INSERT INTO records (sequence, data) VALUES (?, ?)",
            (1, json.dumps({"sequence": 1, "data": {"stage": "stt"}})),
        )
        conn.execute(
            "INSERT INTO records (sequence, data) VALUES (?, ?)",
            (2, json.dumps({"sequence": 2, "data": {"stage": "agent"}})),
        )
        conn.commit()
        conn.close()

        bundle = RunBundle.from_partial_journal(db_path)
        records = list(bundle.records())
        assert len(records) == 2
        assert records[0]["sequence"] == 1

    def test_from_partial_current_journal_preserves_cpu_and_full_error(self, tmp_path):
        from easycat.runtime import SqliteJournal

        journal = SqliteJournal("diagnostic-session", data_dir=tmp_path)
        error = ErrorInfo(
            type="ExceptionGroup",
            message="outer failure",
            traceback="outer traceback",
            notes="stage=agent",
            children=(
                ErrorInfo(
                    type="ValueError",
                    message="bad tool result",
                    traceback="child traceback",
                    notes="tool=lookup",
                ),
            ),
        )
        sequence = journal.append(
            kind=JournalRecordKind.EVENT,
            name="agent_failed",
            session_id="diagnostic-session",
            error=error,
        )
        original = journal.read(start=sequence, limit=1)[0]
        journal.close()

        bundle = RunBundle.from_partial_journal(
            tmp_path / "journals" / "diagnostic-session.sqlite"
        )
        recovered = next(
            record for record in bundle.records() if record.get("name") == "agent_failed"
        )

        assert recovered["cpu_ns"] == original.timing.cpu_ns
        assert recovered["error"] == {
            "type": "ExceptionGroup",
            "message": "outer failure",
            "traceback": "outer traceback",
            "notes": "stage=agent",
            "children": [
                {
                    "type": "ValueError",
                    "message": "bad tool result",
                    "traceback": "child traceback",
                    "notes": "tool=lookup",
                    "children": [],
                }
            ],
        }

    @pytest.mark.parametrize(
        ("error_children", "message"),
        [
            ("not-json", "error_children is not valid JSON"),
            (json.dumps({"type": "ValueError"}), "error_children must be a JSON list"),
            ("null", "error_children must be a JSON list"),
        ],
    )
    def test_from_partial_current_journal_rejects_malformed_error_children(
        self,
        tmp_path,
        error_children: str,
        message: str,
    ) -> None:
        from easycat.runtime import SqliteJournal

        journal = SqliteJournal("corrupt-errors", data_dir=tmp_path)
        sequence = journal.append(
            kind=JournalRecordKind.EVENT,
            name="agent_failed",
            session_id="corrupt-errors",
            error=ErrorInfo(type="ExceptionGroup", message="outer failure"),
        )
        journal.close()
        db_path = tmp_path / "journals" / "corrupt-errors.sqlite"
        conn = sqlite3.connect(db_path)
        conn.execute(
            "UPDATE journal SET error_children = ? WHERE sequence = ?",
            (error_children, sequence),
        )
        conn.commit()
        conn.close()

        with pytest.raises(BundleValidationError, match=message) as exc_info:
            RunBundle.from_partial_journal(db_path)

        assert exc_info.value.reason_code == "INVALID_JOURNAL"
        assert f"sequence {sequence}" in str(exc_info.value)

    def test_from_partial_current_journal_normalizes_deep_error_children(
        self,
        tmp_path,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        import easycat.debug.bundle as bundle_module
        from easycat.runtime import SqliteJournal

        journal = SqliteJournal("deep-corrupt-errors", data_dir=tmp_path)
        sequence = journal.append(
            kind=JournalRecordKind.EVENT,
            name="agent_failed",
            session_id="deep-corrupt-errors",
            error=ErrorInfo(type="ExceptionGroup", message="outer failure"),
        )
        journal.close()
        db_path = tmp_path / "journals" / "deep-corrupt-errors.sqlite"
        deeply_nested_json = "[[0]]"
        conn = sqlite3.connect(db_path)
        conn.execute(
            "UPDATE journal SET error_children = ? WHERE sequence = ?",
            (deeply_nested_json, sequence),
        )
        conn.commit()
        conn.close()

        def raise_recursion_error(value: object) -> object:
            assert value == deeply_nested_json
            raise RecursionError("maximum recursion depth exceeded")

        monkeypatch.setattr(
            bundle_module,
            "json",
            types.SimpleNamespace(loads=raise_recursion_error, dumps=json.dumps),
        )
        with pytest.raises(
            BundleValidationError,
            match="error_children is not valid JSON",
        ) as exc_info:
            RunBundle.from_partial_journal(db_path)

        assert exc_info.value.reason_code == "INVALID_JOURNAL"
        assert f"sequence {sequence}" in str(exc_info.value)
        assert isinstance(exc_info.value.__cause__, RecursionError)

    @pytest.mark.parametrize(
        ("corrupt_data", "expected"),
        [
            (sqlite3.Binary(b"\xff"), "b'\\xff'"),
            ("1" * 5_000, "1" * 5_000),
        ],
    )
    def test_from_partial_current_journal_preserves_unparseable_data_as_raw_evidence(
        self,
        tmp_path,
        corrupt_data: object,
        expected: str,
    ) -> None:
        from easycat.runtime import SqliteJournal

        journal = SqliteJournal("corrupt-data", data_dir=tmp_path)
        sequence = journal.append(
            kind=JournalRecordKind.EVENT,
            name="partial_write",
            session_id="corrupt-data",
        )
        journal.close()
        db_path = tmp_path / "journals" / "corrupt-data.sqlite"
        conn = sqlite3.connect(db_path)
        conn.execute(
            "UPDATE journal SET data = ? WHERE sequence = ?",
            (corrupt_data, sequence),
        )
        conn.commit()
        conn.close()

        recovered = list(RunBundle.from_partial_journal(db_path).records())

        assert recovered[0]["sequence"] == sequence
        assert recovered[0]["data"] == expected

    def test_from_partial_current_journal_ignores_non_string_tags(
        self,
        tmp_path,
    ) -> None:
        from easycat.runtime import SqliteJournal

        journal = SqliteJournal("corrupt-tags", data_dir=tmp_path)
        sequence = journal.append(
            kind=JournalRecordKind.EVENT,
            name="partial_write",
            session_id="corrupt-tags",
        )
        journal.close()
        db_path = tmp_path / "journals" / "corrupt-tags.sqlite"
        conn = sqlite3.connect(db_path)
        conn.execute(
            "UPDATE journal SET tags = ? WHERE sequence = ?",
            (sqlite3.Binary(b"legacy"), sequence),
        )
        conn.commit()
        conn.close()

        recovered = next(iter(RunBundle.from_partial_journal(db_path).records()))

        assert "tags" not in recovered

    def test_from_partial_legacy_journal_table_without_optional_columns(self, tmp_path):
        db_path = tmp_path / "legacy.sqlite"
        conn = sqlite3.connect(str(db_path))
        conn.execute(
            "CREATE TABLE journal ("
            "sequence INTEGER PRIMARY KEY, session_id TEXT, kind TEXT, name TEXT, "
            "wall_ns INTEGER, mono_ns INTEGER, turn_id TEXT, data TEXT, "
            "error_type TEXT, error_msg TEXT, input_ref TEXT, output_ref TEXT, tags TEXT)"
        )
        conn.execute(
            "INSERT INTO journal VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
            (
                1,
                "legacy-session",
                "event",
                "legacy_failure",
                10,
                20,
                None,
                "{}",
                "ValueError",
                "bad",
                None,
                None,
                "",
            ),
        )
        conn.commit()
        conn.close()

        recovered = next(iter(RunBundle.from_partial_journal(db_path).records()))

        assert recovered["cpu_ns"] == 0
        assert recovered["error"] == {
            "type": "ValueError",
            "message": "bad",
            "traceback": None,
            "notes": None,
            "children": [],
        }

    def test_from_partial_journal_rejects_malformed_legacy_record(self, tmp_path):
        db_path = tmp_path / "malformed.sqlite"
        conn = sqlite3.connect(str(db_path))
        conn.execute("CREATE TABLE records (sequence INTEGER PRIMARY KEY, data TEXT)")
        conn.execute(
            "INSERT INTO records (sequence, data) VALUES (?, ?)",
            (1, "not-json"),
        )
        conn.commit()
        conn.close()

        with pytest.raises(BundleValidationError, match="line 1 is not valid JSON") as exc_info:
            RunBundle.from_partial_journal(db_path)

        assert exc_info.value.reason_code == "INVALID_JOURNAL"

    def test_from_partial_journal_normalizes_unserializable_legacy_data(self, tmp_path):
        db_path = tmp_path / "malformed-blob.sqlite"
        conn = sqlite3.connect(str(db_path))
        conn.execute("CREATE TABLE records (sequence INTEGER PRIMARY KEY, data BLOB)")
        conn.execute(
            "INSERT INTO records (sequence, data) VALUES (?, ?)",
            (7, sqlite3.Binary(b"\xff")),
        )
        conn.commit()
        conn.close()

        with pytest.raises(
            BundleValidationError,
            match="Legacy journal sequence 7 data cannot be serialized",
        ) as exc_info:
            RunBundle.from_partial_journal(db_path)

        assert exc_info.value.reason_code == "INVALID_JOURNAL"
        assert isinstance(exc_info.value.__cause__, TypeError)

    def test_from_partial_journal_with_artifacts(self, tmp_path):
        """Legacy flat artifacts from the filesystem should be indexed."""
        db_path = tmp_path / "test.sqlite"
        conn = sqlite3.connect(str(db_path))
        conn.execute("CREATE TABLE records (sequence INTEGER PRIMARY KEY, data TEXT)")
        conn.execute(
            "INSERT INTO records (sequence, data) VALUES (?, ?)",
            (1, json.dumps({"sequence": 1})),
        )
        conn.commit()
        conn.close()

        art_dir = tmp_path / "artifacts"
        art_dir.mkdir()
        data = b"artifact-content"
        ref = hashlib.sha256(data).hexdigest()
        (art_dir / f"{ref}.bin").write_bytes(data)

        bundle = RunBundle.from_partial_journal(db_path, artifact_root=art_dir)
        assert ref in bundle.artifact_index
        assert bundle.artifact_index[ref].ref == ref
        assert bundle.artifact_index[ref].size_bytes == len(data)

    def test_from_partial_journal_rejects_dangling_artifact_reference(self, tmp_path):
        db_path = tmp_path / "dangling.sqlite"
        conn = sqlite3.connect(str(db_path))
        conn.execute("CREATE TABLE records (sequence INTEGER PRIMARY KEY, data TEXT)")
        ref = "a" * 64
        conn.execute(
            "INSERT INTO records (sequence, data) VALUES (?, ?)",
            (1, json.dumps({"sequence": 1, "output_ref": ref})),
        )
        conn.commit()
        conn.close()
        artifact_root = tmp_path / "artifacts"
        artifact_root.mkdir()

        with pytest.raises(BundleValidationError) as exc_info:
            RunBundle.from_partial_journal(db_path, artifact_root=artifact_root)

        assert exc_info.value.reason_code == "MISSING_ARTIFACT"

    def test_from_partial_journal_allows_unresolved_refs_without_artifact_root(self, tmp_path):
        db_path = tmp_path / "dangling.sqlite"
        conn = sqlite3.connect(str(db_path))
        conn.execute("CREATE TABLE records (sequence INTEGER PRIMARY KEY, data TEXT)")
        ref = "a" * 64
        conn.execute(
            "INSERT INTO records (sequence, data) VALUES (?, ?)",
            (1, json.dumps({"sequence": 1, "output_ref": ref})),
        )
        conn.commit()
        conn.close()

        recovered = list(RunBundle.from_partial_journal(db_path).records())

        assert recovered[0]["output_ref"] == ref

    def test_from_partial_journal_with_sharded_artifacts(self, tmp_path):
        db_path = tmp_path / "test.sqlite"
        conn = sqlite3.connect(str(db_path))
        conn.execute("CREATE TABLE records (sequence INTEGER PRIMARY KEY, data TEXT)")
        conn.execute(
            "INSERT INTO records (sequence, data) VALUES (?, ?)",
            (1, json.dumps({"sequence": 1})),
        )
        conn.commit()
        conn.close()

        store = FilesystemArtifactStore("sess", data_dir=tmp_path)
        data = b"sharded-artifact-content"
        ref = store.put(data)

        bundle = RunBundle.from_partial_journal(
            db_path,
            artifact_root=tmp_path / "artifacts" / "sess",
        )

        assert bundle.artifact_blobs[ref] == data
        assert bundle.artifact_index[ref].size_bytes == len(data)

    def test_from_partial_journal_rejects_artifact_checksum_mismatch(self, tmp_path):
        """Recovery must not trust corrupted bytes stored under a digest filename."""
        db_path = tmp_path / "test.sqlite"
        conn = sqlite3.connect(str(db_path))
        conn.execute("CREATE TABLE records (sequence INTEGER PRIMARY KEY, data TEXT)")
        conn.commit()
        conn.close()

        art_dir = tmp_path / "artifacts"
        art_dir.mkdir()
        ref = hashlib.sha256(b"expected").hexdigest()
        (art_dir / f"{ref}.bin").write_bytes(b"tampered")

        with pytest.raises(BundleValidationError) as exc_info:
            RunBundle.from_partial_journal(db_path, artifact_root=art_dir)

        assert exc_info.value.reason_code == "CHECKSUM_MISMATCH"

    def test_not_found(self, tmp_path):
        with pytest.raises(FileNotFoundError):
            RunBundle.from_partial_journal(tmp_path / "nonexistent.sqlite")

    def test_from_partial_journal_locked_message_is_actionable(self, tmp_path, monkeypatch):
        """A locked live journal should explain how to get an inspectable file."""
        db_path = tmp_path / "locked.sqlite"
        db_path.touch()

        def raise_locked(*args, **kwargs):
            raise sqlite3.OperationalError("database is locked")

        monkeypatch.setattr("easycat.debug.bundle.sqlite3.connect", raise_locked)

        with pytest.raises(BundleInUseError) as exc_info:
            RunBundle.from_partial_journal(db_path)

        message = str(exc_info.value)
        assert "currently in use" in message
        assert "Stop the session before inspecting it" in message
        assert "session.export_debug_bundle(...)" in message
        assert "bundles list" not in message

    def test_from_partial_journal_surfaces_degradation(self, tmp_path):
        """A bundle loaded from a degraded SQLite file must expose the
        degradation signal — the journal_degraded marker is persisted to disk
        and flows into the bundle records (regression for runtime-observability-1).
        """
        from easycat.runtime import SqliteJournal

        j = SqliteJournal("degraded-sess", data_dir=tmp_path)
        circular: dict[str, object] = {}
        circular["self"] = circular
        # Non-JSON data forces a write failure -> degraded mode, but the
        # connection survives so the marker is written and committed.
        assert (
            j.append(
                kind=JournalRecordKind.EVENT,
                name="boom",
                session_id="degraded-sess",
                data=circular,
            )
            == -1
        )
        assert j.degraded
        j.close()

        db_path = tmp_path / "journals" / "degraded-sess.sqlite"
        bundle = RunBundle.from_partial_journal(db_path)
        degraded = [
            r
            for r in bundle.records()
            if r.get("kind") == JournalRecordKind.DEGRADED.value
            and r.get("name") == "journal_degraded"
        ]
        assert len(degraded) == 1
        assert degraded[0]["sequence"] == -1


# ── TestBundleDiscovery ─────────────────────────────────────────


class TestBundleDiscovery:
    def test_discover_bundles(self, tmp_path):
        recordings = tmp_path / "recordings"
        recordings.mkdir()
        (recordings / "session1.zip").write_bytes(b"PK")
        (recordings / "session2.easycat-bundle").write_bytes(b"PK")
        (recordings / "not-a-bundle.txt").write_bytes(b"text")

        bundles = discover_bundles(data_dir=str(tmp_path))
        assert len(bundles) == 2
        names = [b.name for b in bundles]
        assert "session1.zip" in names
        assert "session2.easycat-bundle" in names

    def test_discover_empty(self, tmp_path):
        bundles = discover_bundles(data_dir=str(tmp_path))
        assert bundles == []

    def test_discover_crash_dumps(self, tmp_path):
        crash = tmp_path / "crash-dumps"
        crash.mkdir()
        (crash / "crashed.zip").write_bytes(b"PK")

        bundles = discover_bundles(data_dir=str(tmp_path))
        assert len(bundles) == 1
        assert bundles[0].name == "crashed.zip"


# ── TestCommittableBoundary ──────────────────────────────────────


class TestCommittableBoundary:
    def test_replay_entry_points(self, tmp_path):
        """Bundle should load replay_entry_points from manifest."""
        manifest = {
            "format_version": FORMAT_VERSION,
            "replay_entry_points": [
                {"sequence": 10, "stage": "agent", "unit_id": "u1"},
                {"sequence": 20, "stage": "tts", "unit_id": "u2"},
            ],
        }
        bundle_path = _make_bundle_zip(tmp_path, manifest=manifest)
        loaded = RunBundle.load(bundle_path)
        assert len(loaded.replay_entry_points) == 2
        assert loaded.replay_entry_points[0].sequence == 10
        assert loaded.replay_entry_points[0].stage == "agent"
        assert loaded.replay_entry_points[0].unit_id == "u1"
        assert loaded.replay_entry_points[1].sequence == 20

    @pytest.mark.parametrize(
        "entry_point",
        [
            {"sequence": "not-an-int", "stage": "agent", "unit_id": "u1"},
            {"sequence": -1, "stage": "agent", "unit_id": "u1"},
            {"sequence": True, "stage": "agent", "unit_id": "u1"},
            {"sequence": 1, "stage": ["agent"], "unit_id": "u1"},
            {"sequence": 1, "stage": "agent", "unit_id": {"bad": "id"}},
        ],
    )
    def test_replay_entry_points_reject_invalid_values(self, tmp_path, entry_point):
        """Bundle-controlled checkpoint metadata should be validated at load time."""
        manifest = {"format_version": FORMAT_VERSION, "replay_entry_points": [entry_point]}
        bundle_path = _make_bundle_zip(tmp_path, manifest=manifest)

        with pytest.raises(BundleValidationError) as exc_info:
            RunBundle.load(bundle_path)

        assert exc_info.value.reason_code == "INVALID_REPLAY_ENTRY_POINT"

    @pytest.mark.parametrize("entry_points", ["not-a-list", [{"sequence": 1}, "not-an-object"]])
    def test_replay_entry_points_reject_invalid_container_shapes(self, tmp_path, entry_points):
        """Malformed checkpoint containers should fail with a bundle validation error."""
        manifest = {"format_version": FORMAT_VERSION, "replay_entry_points": entry_points}
        bundle_path = _make_bundle_zip(tmp_path, manifest=manifest)

        with pytest.raises(BundleValidationError) as exc_info:
            RunBundle.load(bundle_path)

        assert exc_info.value.reason_code == "INVALID_REPLAY_ENTRY_POINT"

    def test_committable_checkpoint_is_frozen(self):
        cp = CommittableCheckpoint(sequence=1, stage="stt")
        with pytest.raises(AttributeError):
            cp.sequence = 2  # type: ignore[misc]


# ── TestStageReplay ──────────────────────────────────────────────


class TestStageReplay:
    """All stages should support replay() without raising NotImplementedError."""

    def test_stt_live(self):
        from easycat.stages.stt import STTStage

        class _Stub:
            async def send_audio(self, chunk):
                pass

        stage = STTStage(_Stub())
        spec = StubReplaySpec(fidelity="live")
        result = stage.replay(spec)
        assert result is None  # no overrides provided

    def test_stt_artifact(self):
        from easycat.stages.stt import STTStage

        class _Stub:
            async def send_audio(self, chunk):
                pass

        stage = STTStage(_Stub())
        spec = StubReplaySpec(fidelity="artifact")
        result = stage.replay(spec)
        assert result is None  # no overrides by default

    def test_tts_artifact(self):
        from easycat.stages.tts import TTSStage

        class _Stub:
            def synthesize(self, text):
                return b"audio"

        stage = TTSStage(_Stub())
        spec = StubReplaySpec(fidelity="artifact")
        result = stage.replay(spec)
        assert result is None

    def test_tts_live(self):
        from easycat.stages.tts import TTSStage

        class _Stub:
            def synthesize(self, text):
                return b"audio"

        stage = TTSStage(_Stub())
        spec = StubReplaySpec(fidelity="live")
        result = stage.replay(spec)
        assert result is None

    def test_vad_artifact(self):
        from easycat.stages.vad import VADStage

        class _Stub:
            async def process(self, chunk):
                return
                yield

        stage = VADStage(_Stub())
        spec = StubReplaySpec(fidelity="artifact")
        result = stage.replay(spec)
        assert result == []

    def test_agent_simulated(self):
        from easycat.stages.agent import AgentStage

        class _Stub:
            async def run(self, text):
                return "response"

        stage = AgentStage(_Stub())
        spec = StubReplaySpec(fidelity="simulated")
        result = stage.replay(spec)
        assert result is None  # no overrides

    def test_agent_live(self):
        from easycat.stages.agent import AgentStage

        class _Stub:
            async def run(self, text):
                return "response"

        stage = AgentStage(_Stub())
        spec = StubReplaySpec(fidelity="live")
        result = stage.replay(spec)
        assert result is None

    def test_audio_artifact(self):
        from easycat.stages.audio import AudioStage

        class _Stub:
            async def process(self, chunk):
                return chunk

        stage = AudioStage(_Stub())
        spec = StubReplaySpec(fidelity="artifact")
        result = stage.replay(spec)
        assert result is None

    def test_transport_artifact(self):
        from easycat.stages.transport import TransportStage

        class _Stub:
            async def send_audio(self, chunk):
                pass

        stage = TransportStage(_Stub())
        spec = StubReplaySpec(fidelity="artifact")
        result = stage.replay(spec)
        assert result is None

    def test_turn_artifact(self):
        from easycat.stages.turn import TurnStage

        class _Stub:
            async def detect(self, audio):
                return {"prediction": 1}

        stage = TurnStage(_Stub())
        spec = StubReplaySpec(fidelity="artifact")
        result = stage.replay(spec)
        assert result is None

    def test_vad_replay_decision(self):
        from easycat.stages.base import StageStateSnapshot
        from easycat.stages.vad import VADStage

        class _Stub:
            async def process(self, chunk):
                return
                yield

        stage = VADStage(_Stub())
        snapshot = StageStateSnapshot(stage_name="vad", fields={"decision": True})
        result = stage.replay_decision(snapshot)
        assert result is True

    def test_turn_replay_decision(self):
        from easycat.stages.base import StageStateSnapshot
        from easycat.stages.turn import TurnStage

        class _Stub:
            async def detect(self, audio):
                return {"prediction": 1}

        stage = TurnStage(_Stub())
        snapshot = StageStateSnapshot(stage_name="turn", fields={"decision": "end"})
        result = stage.replay_decision(snapshot)
        assert result == "end"


# ── TestStageReplayWithWS4Spec ──────────────────────────────────


class TestStageReplayWithWS4Spec:
    """Test stage replay using the WS4 ReplaySpec (from runtime.replay)."""

    def test_stt_artifact_with_overrides(self):
        from easycat.stages.stt import STTStage

        class _Stub:
            async def send_audio(self, chunk):
                pass

        stage = STTStage(_Stub())
        # WS4 ReplaySpec uses ReplayFidelity enum
        spec = ReplaySpec(
            fidelity=ReplayFidelity.ARTIFACT,
            overrides={"transcript": "hello world"},
        )
        result = stage.replay(spec)
        assert result == "hello world"

    def test_tts_artifact_with_overrides(self):
        from easycat.stages.tts import TTSStage

        class _Stub:
            def synthesize(self, text):
                return b"audio"

        stage = TTSStage(_Stub())
        spec = ReplaySpec(
            fidelity=ReplayFidelity.ARTIFACT,
            overrides={"audio": b"captured-audio"},
        )
        result = stage.replay(spec)
        assert result == b"captured-audio"

    def test_agent_simulated_with_overrides(self):
        from easycat.stages.agent import AgentStage

        class _Stub:
            async def run(self, text):
                return "response"

        stage = AgentStage(_Stub())
        spec = ReplaySpec(
            fidelity=ReplayFidelity.SIMULATED,
            overrides={"events": [{"type": "delta", "text": "hi"}]},
        )
        result = stage.replay(spec)
        assert result == [{"type": "delta", "text": "hi"}]

    def test_agent_artifact_with_overrides(self):
        from easycat.stages.agent import AgentStage

        class _Stub:
            async def run(self, text):
                return "response"

        stage = AgentStage(_Stub())
        spec = ReplaySpec(
            fidelity=ReplayFidelity.ARTIFACT,
            overrides={"response": "captured-response"},
        )
        result = stage.replay(spec)
        assert result == "captured-response"


# ── TestToolReplayPolicies ──────────────────────────────────────


class TestToolReplayPolicies:
    def test_deny_blocks(self):
        """DENY policy should raise ReplaySideEffectBlocked."""
        with pytest.raises(ReplaySideEffectBlocked):
            raise ReplaySideEffectBlocked("tool call blocked by DENY policy")

    def test_side_effect_blocked_is_runtime_error(self):
        assert issubclass(ReplaySideEffectBlocked, RuntimeError)

    def test_deny_is_default(self):
        spec = ReplaySpec(fidelity=ReplayFidelity.ARTIFACT)
        assert spec.tool_policy == ToolReplayPolicy.DENY

    def test_stub_uses_captured(self):
        spec = ReplaySpec(
            fidelity=ReplayFidelity.ARTIFACT,
            tool_policy=ToolReplayPolicy.STUB,
        )
        assert spec.tool_policy == ToolReplayPolicy.STUB

    def test_allow_policy(self):
        spec = ReplaySpec(
            fidelity=ReplayFidelity.LIVE,
            tool_policy=ToolReplayPolicy.ALLOW,
        )
        assert spec.tool_policy == ToolReplayPolicy.ALLOW


# ── TestProviderVersionMatch ────────────────────────────────────


class TestProviderVersionMatch:
    def test_unknown_provider(self, tmp_path):
        """Unknown providers in the manifest should not cause errors."""
        manifest = {
            "format_version": FORMAT_VERSION,
            "provider_versions": {"custom_stt": "v1.0"},
        }
        bundle_path = _make_bundle_zip(tmp_path, manifest=manifest)
        loaded = RunBundle.load(bundle_path)
        assert loaded.manifest.provider_versions.get("custom_stt") == "v1.0"

    def test_custom_error_code(self):
        err = ProviderVersionMismatchError("msg", error_code="CUSTOM_CODE")
        assert err.error_code == "CUSTOM_CODE"


# ── TestLoadBundleHelper ────────────────────────────────────────


class TestLoadBundleHelper:
    def test_load_bundle(self, tmp_path):
        """load_bundle fixture helper should load and return a RunBundle."""
        bundle_path = _make_bundle_zip(
            tmp_path,
            journal_lines=[json.dumps({"sequence": 1, "data": {}})],
        )
        bundle = load_bundle(bundle_path)
        assert isinstance(bundle, RunBundle)
        records = list(bundle.records())
        assert len(records) == 1

    def test_load_bundle_not_found(self):
        with pytest.raises(FileNotFoundError):
            load_bundle("/nonexistent/bundle.zip")


# ── TestBundleExceptions ────────────────────────────────────────


class TestBundleExceptions:
    def test_hierarchy(self):
        assert issubclass(BundleExists, BundleError)
        assert issubclass(BundleVersionError, BundleError)
        assert issubclass(BundleValidationError, BundleError)
        assert issubclass(BundleInUseError, BundleError)
        assert issubclass(BundleRecoveryError, BundleError)
        assert issubclass(DebugCaptureDisabledError, BundleError)
        assert issubclass(BundleError, RuntimeError)

    def test_validation_error_reason_code(self):
        err = BundleValidationError("bad", reason_code="TEST_CODE")
        assert err.reason_code == "TEST_CODE"
        assert str(err) == "bad"


# ── TestManifest ────────────────────────────────────────────────


class TestManifest:
    def test_defaults(self):
        m = Manifest()
        assert m.format_version == FORMAT_VERSION
        assert m.provider_versions == {}
        assert m.config_snapshot == {}
        assert m.env_metadata == {}
        assert m.journal_dropped_records == 0
        assert m.sharing_banner == ""

    def test_frozen(self):
        m = Manifest()
        with pytest.raises(AttributeError):
            m.format_version = 2  # type: ignore[misc]


# ── TestArtifactEntry ───────────────────────────────────────────


class TestArtifactEntry:
    def test_construction(self):
        ae = ArtifactEntry(ref="abc", size_bytes=42)
        assert ae.ref == "abc"
        assert ae.size_bytes == 42

    def test_frozen(self):
        ae = ArtifactEntry(ref="abc")
        with pytest.raises(AttributeError):
            ae.ref = "xyz"  # type: ignore[misc]
