"""Tests for :mod:`mgf.vm.forensics.manifest` (Phase 17, FN-A1.3)."""

from __future__ import annotations

import json
from typing import TYPE_CHECKING

import pytest
from mgf.vm.core.exceptions import OperationError

from mgf.vm.forensics import (
    MANIFEST_FILENAME,
    MANIFEST_SCHEMA_VERSION,
    build_manifest,
    load_manifest,
    save_manifest,
    verify_manifest,
)

if TYPE_CHECKING:
    from pathlib import Path


class TestConstants:
    def test_filename(self) -> None:
        assert MANIFEST_FILENAME == "manifest.json"

    def test_version(self) -> None:
        assert MANIFEST_SCHEMA_VERSION == 1


class TestBuildManifest:
    def test_empty_dir_yields_empty_manifest(self, tmp_path: Path) -> None:
        m = build_manifest(tmp_path)
        assert m.entries == ()

    def test_lists_files_recursively(self, tmp_path: Path) -> None:
        (tmp_path / "a.txt").write_text("hello")
        sub = tmp_path / "sub"
        sub.mkdir()
        (sub / "b.bin").write_bytes(b"\x00\x01\x02")
        m = build_manifest(tmp_path)
        paths = {e.relative_path for e in m.entries}
        assert paths == {"a.txt", "sub/b.bin"}

    def test_excludes_manifest_itself(self, tmp_path: Path) -> None:
        (tmp_path / MANIFEST_FILENAME).write_text("anything")
        (tmp_path / "real.txt").write_text("hi")
        m = build_manifest(tmp_path)
        paths = {e.relative_path for e in m.entries}
        assert MANIFEST_FILENAME not in paths
        assert "real.txt" in paths

    def test_excludes_in_flight_temp_files(self, tmp_path: Path) -> None:
        (tmp_path / "evidence.partial").write_text("nope")
        (tmp_path / ".manifest-abc.json").write_text("nope")
        (tmp_path / "x.tmp").write_text("nope")
        (tmp_path / "good.txt").write_text("hi")
        m = build_manifest(tmp_path)
        paths = {e.relative_path for e in m.entries}
        assert paths == {"good.txt"}

    def test_hash_is_stable_for_same_content(self, tmp_path: Path) -> None:
        (tmp_path / "a.txt").write_text("hello")
        m1 = build_manifest(tmp_path)
        m2 = build_manifest(tmp_path)
        assert m1.entries[0].sha256 == m2.entries[0].sha256

    def test_hash_known_value(self, tmp_path: Path) -> None:
        (tmp_path / "x.txt").write_text("abc")
        m = build_manifest(tmp_path)
        # sha256("abc") = ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad
        assert m.entries[0].sha256.startswith("ba7816bf")

    def test_size_bytes_recorded(self, tmp_path: Path) -> None:
        (tmp_path / "x.txt").write_bytes(b"12345")
        m = build_manifest(tmp_path)
        assert m.entries[0].size_bytes == 5

    def test_missing_dir_raises(self, tmp_path: Path) -> None:
        with pytest.raises(OperationError, match="does not exist"):
            build_manifest(tmp_path / "nope")

    def test_path_is_file_raises(self, tmp_path: Path) -> None:
        f = tmp_path / "file"
        f.write_text("x")
        with pytest.raises(OperationError, match="not a directory"):
            build_manifest(f)


class TestSaveLoadRoundTrip:
    def test_round_trip_preserves_entries(self, tmp_path: Path) -> None:
        (tmp_path / "a").write_text("alpha")
        (tmp_path / "b").write_text("beta")
        m = build_manifest(tmp_path)
        save_manifest(tmp_path, m)
        loaded = load_manifest(tmp_path)
        assert {e.relative_path for e in loaded.entries} == {"a", "b"}

    def test_save_writes_mode_0o600(self, tmp_path: Path) -> None:
        (tmp_path / "a").write_text("x")
        save_manifest(tmp_path, build_manifest(tmp_path))
        path = tmp_path / MANIFEST_FILENAME
        assert path.stat().st_mode & 0o777 == 0o600

    def test_save_is_atomic_no_temp_files_left(self, tmp_path: Path) -> None:
        (tmp_path / "a").write_text("x")
        save_manifest(tmp_path, build_manifest(tmp_path))
        # No .manifest-* tempfiles linger.
        leftovers = list(tmp_path.glob(".manifest-*"))
        assert leftovers == []

    def test_load_missing_raises_with_hint(self, tmp_path: Path) -> None:
        with pytest.raises(OperationError, match="rebuild-manifest"):
            load_manifest(tmp_path)

    def test_load_corrupt_raises(self, tmp_path: Path) -> None:
        (tmp_path / MANIFEST_FILENAME).write_text("not json", encoding="utf-8")
        with pytest.raises(OperationError, match="unreadable"):
            load_manifest(tmp_path)

    def test_load_unsupported_schema_raises(self, tmp_path: Path) -> None:
        (tmp_path / MANIFEST_FILENAME).write_text(
            json.dumps(
                {"schema_version": 99, "generated_at": "2026-04-26T12:00:00+00:00", "entries": []},
            ),
            encoding="utf-8",
        )
        with pytest.raises(OperationError, match="schema_version"):
            load_manifest(tmp_path)


class TestVerifyManifest:
    def test_clean_dir_verifies(self, tmp_path: Path) -> None:
        (tmp_path / "a").write_text("x")
        m = build_manifest(tmp_path)
        save_manifest(tmp_path, m)
        result = verify_manifest(tmp_path)
        assert result.is_clean()
        assert result.mismatches == ()
        assert result.missing == ()
        assert result.unexpected == ()

    def test_tampered_file_flagged(self, tmp_path: Path) -> None:
        (tmp_path / "a").write_text("x")
        m = build_manifest(tmp_path)
        save_manifest(tmp_path, m)
        # Tamper after manifest written.
        (tmp_path / "a").write_text("MUTATED")
        result = verify_manifest(tmp_path)
        assert "a" in result.mismatches
        assert not result.is_clean()

    def test_deleted_file_flagged_as_missing(self, tmp_path: Path) -> None:
        (tmp_path / "a").write_text("x")
        save_manifest(tmp_path, build_manifest(tmp_path))
        (tmp_path / "a").unlink()
        result = verify_manifest(tmp_path)
        assert "a" in result.missing

    def test_extra_file_flagged_as_unexpected(self, tmp_path: Path) -> None:
        (tmp_path / "a").write_text("x")
        save_manifest(tmp_path, build_manifest(tmp_path))
        (tmp_path / "b").write_text("y")
        result = verify_manifest(tmp_path)
        assert "b" in result.unexpected

    def test_explicit_manifest_arg_skips_load(self, tmp_path: Path) -> None:
        (tmp_path / "a").write_text("x")
        m = build_manifest(tmp_path)
        # No manifest.json on disk, but we pass it directly.
        result = verify_manifest(tmp_path, manifest=m)
        assert result.is_clean()

    def test_coordinated_tamper_passes_verify_documented_limitation(
        self,
        tmp_path: Path,
    ) -> None:
        """Pin RA-06's documented limitation — :func:`verify_manifest`
        catches *accidental* corruption (bit rot, truncated copies,
        forgotten ``_refresh_manifest`` calls) but does **not** defend
        against an attacker with write access to the case directory.

        An attacker who tampers BOTH an artifact AND its row in
        ``manifest.json`` re-hashes consistently and verify passes.
        True tamper-evidence needs a detached signature stored
        off-box; we do not promise that bar today. This test pins
        the limitation so the recovery path stays honest and the
        manifest module docstring + ``docs/FORENSICS.md`` §2.1
        language don't drift back to "tamper-evident"."""
        # Create an artifact, build + save the manifest.
        artifact = tmp_path / "evidence.bin"
        artifact.write_bytes(b"original content")
        save_manifest(tmp_path, build_manifest(tmp_path))

        # Coordinated tamper: edit the artifact AND rebuild the
        # manifest from the tampered state. The attacker has write
        # access — they can do exactly this.
        artifact.write_bytes(b"tampered content")
        # The "attacker" rebuilds the manifest over the tampered tree
        # (equivalent to editing both the file and its sha256 row).
        save_manifest(tmp_path, build_manifest(tmp_path))

        # Verify passes cleanly — exactly the limitation we document.
        result = verify_manifest(tmp_path)
        assert result.is_clean(), (
            "verify_manifest must pass on a coordinated tamper — the "
            "manifest is an accidental-corruption check, not full "
            "tamper-evidence (RA-06)"
        )
