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

Covers atomic CRUD, status state-machine validation, lazy CF-04
path resolution, illegal-name rejection, artifact append, listing
with corrupt-case skip, deletion guard.
"""

from __future__ import annotations

from datetime import UTC, datetime
from pathlib import Path
from uuid import uuid4

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

from mgf.vm.forensics import (
    Artifact,
    ArtifactKind,
    Case,
    CaseStatus,
    CaseStore,
    default_forensics_dir,
)

# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------


@pytest.fixture
def fake_home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path:
    home = tmp_path / "home"
    home.mkdir()
    monkeypatch.setenv("HOME", str(home))
    return home


def _case(name: str = "vm-1") -> Case:
    return Case(
        name=name,
        case_id=uuid4(),
        vm_name="suspect-1",
        sample_path=Path("/tmp/sample.exe"),
        sample_sha256="deadbeef",
        description="hi",
        created_at=datetime(2026, 4, 26, 12, 0, tzinfo=UTC),
    )


def _artifact() -> Artifact:
    return Artifact(
        kind=ArtifactKind.NETWORK_PCAP,
        path=Path("/tmp/x.pcap"),
        sha256="abc",
        size_bytes=42,
        captured_at=datetime(2026, 4, 26, 12, 5, tzinfo=UTC),
        captured_by="tcpdump 4.99.1",
        metadata={"bpf": "tcp port 80"},
    )


# ---------------------------------------------------------------------------
# Path resolution (CF-04 lazy)
# ---------------------------------------------------------------------------


class TestPathResolution:
    def test_default_dir_resolves_lazily_against_home(
        self,
        fake_home: Path,
    ) -> None:
        path = default_forensics_dir()
        assert path == fake_home / ".local/share/vmanager/forensics"

    def test_default_dir_does_not_exist_until_create(
        self,
        fake_home: Path,
    ) -> None:
        default_forensics_dir()
        assert not (fake_home / ".local").exists()


# ---------------------------------------------------------------------------
# Name validation
# ---------------------------------------------------------------------------


class TestNameValidation:
    def test_empty_rejected(self, tmp_path: Path) -> None:
        store = CaseStore(root=tmp_path)
        with pytest.raises(OperationError, match="non-empty"):
            store.case_dir("")

    @pytest.mark.parametrize("bad", ["..", ".", "with/slash", "with\\backslash"])
    def test_path_traversal_rejected(
        self,
        tmp_path: Path,
        bad: str,
    ) -> None:
        store = CaseStore(root=tmp_path)
        with pytest.raises(OperationError, match="illegal"):
            store.case_dir(bad)


# ---------------------------------------------------------------------------
# create + load + save
# ---------------------------------------------------------------------------


class TestCreateLoadSave:
    def test_create_initialises_dir_with_subdirs(self, tmp_path: Path) -> None:
        store = CaseStore(root=tmp_path)
        case_dir = store.create(_case())
        assert case_dir.exists()
        for sub in ("network", "filesystem", "memory", "screenshots", "reports"):
            assert (case_dir / sub).is_dir()

    def test_create_dir_mode_is_0o700(self, tmp_path: Path) -> None:
        store = CaseStore(root=tmp_path)
        case_dir = store.create(_case())
        assert case_dir.stat().st_mode & 0o777 == 0o700

    def test_create_writes_yaml_with_mode_0o600(self, tmp_path: Path) -> None:
        store = CaseStore(root=tmp_path)
        store.create(_case())
        yaml_path = store.case_yaml("vm-1")
        assert yaml_path.exists()
        assert yaml_path.stat().st_mode & 0o777 == 0o600

    def test_create_refuses_existing(self, tmp_path: Path) -> None:
        store = CaseStore(root=tmp_path)
        store.create(_case())
        with pytest.raises(OperationError, match="already exists"):
            store.create(_case())

    def test_load_round_trips_fields(self, tmp_path: Path) -> None:
        store = CaseStore(root=tmp_path)
        original = _case()
        store.create(original)
        loaded = store.load("vm-1")
        assert loaded.name == original.name
        assert loaded.case_id == original.case_id
        assert loaded.vm_name == original.vm_name
        assert loaded.sample_path == original.sample_path
        assert loaded.sample_sha256 == original.sample_sha256
        assert loaded.status == original.status
        assert loaded.created_at == original.created_at

    def test_load_missing_raises(self, tmp_path: Path) -> None:
        store = CaseStore(root=tmp_path)
        with pytest.raises(OperationError, match="no case"):
            store.load("ghost")

    def test_load_corrupt_yaml_raises(self, tmp_path: Path) -> None:
        store = CaseStore(root=tmp_path)
        store.create(_case())
        yaml_path = store.case_yaml("vm-1")
        yaml_path.write_text("not: valid: yaml: at: all:", encoding="utf-8")
        with pytest.raises(OperationError):
            store.load("vm-1")

    def test_save_refuses_uncreated_case(self, tmp_path: Path) -> None:
        store = CaseStore(root=tmp_path)
        with pytest.raises(OperationError, match="does not exist"):
            store.save(_case("uncreated"))

    def test_save_overwrites_existing(self, tmp_path: Path) -> None:
        store = CaseStore(root=tmp_path)
        original = _case()
        store.create(original)
        updated = original.with_status(CaseStatus.COMPLETED)
        store.save(updated)
        loaded = store.load("vm-1")
        assert loaded.status == CaseStatus.COMPLETED


# ---------------------------------------------------------------------------
# list
# ---------------------------------------------------------------------------


class TestList:
    def test_empty_root_returns_empty(self, tmp_path: Path) -> None:
        store = CaseStore(root=tmp_path)
        assert store.list() == ()

    def test_lists_in_created_at_order(self, tmp_path: Path) -> None:
        store = CaseStore(root=tmp_path)
        c1 = Case(
            name="a",
            case_id=uuid4(),
            vm_name="vm",
            created_at=datetime(2026, 1, 1, tzinfo=UTC),
        )
        c2 = Case(
            name="b",
            case_id=uuid4(),
            vm_name="vm",
            created_at=datetime(2026, 2, 1, tzinfo=UTC),
        )
        store.create(c1)
        store.create(c2)
        result = store.list()
        assert [c.name for c in result] == ["a", "b"]

    def test_corrupt_case_skipped(self, tmp_path: Path) -> None:
        store = CaseStore(root=tmp_path)
        store.create(_case("good"))
        # Add a directory with a corrupt case.yaml.
        bad = tmp_path / "bad"
        bad.mkdir()
        (bad / "case.yaml").write_text("garbage: : :", encoding="utf-8")
        result = store.list()
        assert [c.name for c in result] == ["good"]

    def test_dirs_without_case_yaml_skipped(self, tmp_path: Path) -> None:
        store = CaseStore(root=tmp_path)
        store.create(_case("good"))
        (tmp_path / "stray").mkdir()  # no case.yaml inside
        result = store.list()
        assert [c.name for c in result] == ["good"]


# ---------------------------------------------------------------------------
# delete
# ---------------------------------------------------------------------------


class TestDelete:
    def test_delete_empty_case_succeeds(self, tmp_path: Path) -> None:
        store = CaseStore(root=tmp_path)
        store.create(_case())
        store.delete("vm-1")
        assert not (tmp_path / "vm-1").exists()

    def test_delete_with_artifacts_refuses_without_purge(
        self,
        tmp_path: Path,
    ) -> None:
        store = CaseStore(root=tmp_path)
        store.create(_case())
        store.add_artifact("vm-1", _artifact())
        with pytest.raises(OperationError, match="--purge"):
            store.delete("vm-1")
        assert (tmp_path / "vm-1").exists()

    def test_delete_with_purge_wipes_artifacts(
        self,
        tmp_path: Path,
    ) -> None:
        store = CaseStore(root=tmp_path)
        store.create(_case())
        store.add_artifact("vm-1", _artifact())
        store.delete("vm-1", purge=True)
        assert not (tmp_path / "vm-1").exists()

    def test_delete_missing_raises(self, tmp_path: Path) -> None:
        store = CaseStore(root=tmp_path)
        with pytest.raises(OperationError, match="no case"):
            store.delete("ghost")


# ---------------------------------------------------------------------------
# transition state machine
# ---------------------------------------------------------------------------


class TestStatusTransitions:
    def test_preparing_to_active_allowed(self, tmp_path: Path) -> None:
        store = CaseStore(root=tmp_path)
        store.create(_case())
        updated = store.transition("vm-1", CaseStatus.ACTIVE)
        assert updated.status == CaseStatus.ACTIVE

    def test_preparing_to_completed_allowed(self, tmp_path: Path) -> None:
        # Skipping ACTIVE is allowed (e.g., user only wanted to capture
        # a baseline, not run any continuous capture).
        store = CaseStore(root=tmp_path)
        store.create(_case())
        updated = store.transition("vm-1", CaseStatus.COMPLETED)
        assert updated.status == CaseStatus.COMPLETED
        assert updated.completed_at is not None

    def test_active_to_completed_allowed(self, tmp_path: Path) -> None:
        store = CaseStore(root=tmp_path)
        store.create(_case())
        store.transition("vm-1", CaseStatus.ACTIVE)
        updated = store.transition("vm-1", CaseStatus.COMPLETED)
        assert updated.status == CaseStatus.COMPLETED

    def test_completed_to_archived_allowed(self, tmp_path: Path) -> None:
        store = CaseStore(root=tmp_path)
        store.create(_case())
        store.transition("vm-1", CaseStatus.COMPLETED)
        updated = store.transition("vm-1", CaseStatus.ARCHIVED)
        assert updated.status == CaseStatus.ARCHIVED

    def test_archived_to_anything_refused(self, tmp_path: Path) -> None:
        store = CaseStore(root=tmp_path)
        store.create(_case())
        store.transition("vm-1", CaseStatus.COMPLETED)
        store.transition("vm-1", CaseStatus.ARCHIVED)
        with pytest.raises(OperationError, match="terminal"):
            store.transition("vm-1", CaseStatus.ACTIVE)

    def test_completed_to_active_refused(self, tmp_path: Path) -> None:
        store = CaseStore(root=tmp_path)
        store.create(_case())
        store.transition("vm-1", CaseStatus.COMPLETED)
        with pytest.raises(OperationError, match="cannot transition"):
            store.transition("vm-1", CaseStatus.ACTIVE)

    def test_same_state_is_noop(self, tmp_path: Path) -> None:
        store = CaseStore(root=tmp_path)
        store.create(_case())
        updated = store.transition("vm-1", CaseStatus.PREPARING)
        assert updated.status == CaseStatus.PREPARING


# ---------------------------------------------------------------------------
# add_artifact
# ---------------------------------------------------------------------------


class TestAddArtifact:
    def test_appends_and_persists(self, tmp_path: Path) -> None:
        store = CaseStore(root=tmp_path)
        store.create(_case())
        a = _artifact()
        updated = store.add_artifact("vm-1", a)
        assert len(updated.artifacts) == 1
        # Reload and confirm the artifact landed on disk.
        reloaded = store.load("vm-1")
        assert len(reloaded.artifacts) == 1
        assert reloaded.artifacts[0].kind == a.kind
        assert reloaded.artifacts[0].sha256 == a.sha256
        assert reloaded.artifacts[0].metadata == {"bpf": "tcp port 80"}

    def test_multiple_artifacts_preserve_order(
        self,
        tmp_path: Path,
    ) -> None:
        store = CaseStore(root=tmp_path)
        store.create(_case())
        for kind in (
            ArtifactKind.NETWORK_PCAP,
            ArtifactKind.FS_BASELINE,
            ArtifactKind.MEMORY_DUMP,
        ):
            store.add_artifact(
                "vm-1",
                Artifact(
                    kind=kind,
                    path=Path(f"/tmp/{kind.value}"),
                    sha256="x",
                    size_bytes=1,
                    captured_at=datetime.now(UTC),
                ),
            )
        reloaded = store.load("vm-1")
        assert [a.kind for a in reloaded.artifacts] == [
            ArtifactKind.NETWORK_PCAP,
            ArtifactKind.FS_BASELINE,
            ArtifactKind.MEMORY_DUMP,
        ]
