"""Tests for :mod:`mgf.vm.forensics.capture.filesystem` (Phase 17, FN-A3)."""

from __future__ import annotations

import contextlib
from typing import TYPE_CHECKING

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

from mgf.vm.forensics import (
    FS_BASELINE_SCHEMA_VERSION,
    FS_DIFF_SCHEMA_VERSION,
    ArtifactKind,
    capture_filesystem_baseline,
    capture_filesystem_diff,
    walk_tree,
)
from mgf.vm.forensics.capture.filesystem import load_baseline

if TYPE_CHECKING:
    from collections.abc import Iterator
    from pathlib import Path

# Post-D-G22 the filesystem capture takes ``vm_name`` + ``is_running`` +
# ``disk_paths`` directly (the app reads them off the SDK client), so these
# tests pass values instead of a ``VMProvider`` + handle.


def _populate_filesystem(root: Path, files: dict[str, bytes]) -> None:
    """Helper: write files into a fake mount root."""
    for rel, content in files.items():
        full = root / rel.lstrip("/")
        full.parent.mkdir(parents=True, exist_ok=True)
        full.write_bytes(content)


@contextlib.contextmanager
def _make_mount_fn(mount: Path) -> Iterator[Path]:
    """A fake mount-disk context manager that just yields a tmp path."""
    yield mount


# ---------------------------------------------------------------------------
# Schema versions
# ---------------------------------------------------------------------------


class TestSchemaVersions:
    def test_pinned(self) -> None:
        assert FS_BASELINE_SCHEMA_VERSION == 1
        assert FS_DIFF_SCHEMA_VERSION == 1


# ---------------------------------------------------------------------------
# walk_tree (pure function)
# ---------------------------------------------------------------------------


class TestWalkTree:
    def test_empty_dir_returns_empty(self, tmp_path: Path) -> None:
        assert walk_tree(tmp_path) == []

    def test_lists_files_recursively(self, tmp_path: Path) -> None:
        _populate_filesystem(tmp_path, {"a.txt": b"hi", "sub/b.bin": b"\x01\x02"})
        result = walk_tree(tmp_path)
        paths = {e.path for e in result}
        assert paths == {"/a.txt", "/sub/b.bin"}

    def test_records_size_and_hash(self, tmp_path: Path) -> None:
        _populate_filesystem(tmp_path, {"x.txt": b"abc"})
        result = walk_tree(tmp_path)
        assert result[0].size_bytes == 3
        # sha256("abc") starts with ba7816bf
        assert result[0].sha256.startswith("ba7816bf")

    def test_excludes_match_filename(self, tmp_path: Path) -> None:
        _populate_filesystem(tmp_path, {"keep.txt": b"x", "skip.log": b"y"})
        result = walk_tree(tmp_path, excludes=["*.log"])
        paths = {e.path for e in result}
        assert paths == {"/keep.txt"}

    def test_excludes_match_full_path(self, tmp_path: Path) -> None:
        _populate_filesystem(tmp_path, {"keep.txt": b"x", "var/log/skip.txt": b"y"})
        result = walk_tree(tmp_path, excludes=["/var/log/*"])
        paths = {e.path for e in result}
        assert paths == {"/keep.txt"}

    def test_includes_scope_walk(self, tmp_path: Path) -> None:
        _populate_filesystem(
            tmp_path,
            {"etc/hosts": b"x", "var/log/y.log": b"y", "home/u/z": b"z"},
        )
        result = walk_tree(tmp_path, includes=["/etc"])
        paths = {e.path for e in result}
        assert paths == {"/etc/hosts"}

    def test_does_not_follow_symlinks(self, tmp_path: Path) -> None:
        _populate_filesystem(tmp_path, {"real.txt": b"r"})
        # Create a symlink that would point to a hostile path.
        (tmp_path / "link").symlink_to("/etc/passwd")
        result = walk_tree(tmp_path)
        paths = {e.path for e in result}
        # Symlinks themselves are not regular files; they're skipped.
        assert paths == {"/real.txt"}

    def test_skips_special_files(self, tmp_path: Path) -> None:
        # FIFOs are non-regular and should not be walked.
        import os

        os.mkfifo(tmp_path / "fifo")
        _populate_filesystem(tmp_path, {"a.txt": b"x"})
        result = walk_tree(tmp_path)
        assert {e.path for e in result} == {"/a.txt"}

    def test_output_sorted_by_path(self, tmp_path: Path) -> None:
        _populate_filesystem(
            tmp_path,
            {"z.txt": b"z", "a.txt": b"a", "m.txt": b"m"},
        )
        result = walk_tree(tmp_path)
        paths = [e.path for e in result]
        assert paths == sorted(paths)


# ---------------------------------------------------------------------------
# capture_filesystem_baseline
# ---------------------------------------------------------------------------


class TestCaptureBaseline:
    def test_refuses_running_vm_by_default(self, tmp_path: Path) -> None:
        disk = tmp_path / "disk.qcow2"
        disk.write_bytes(b"")
        case_dir = tmp_path / "case"
        case_dir.mkdir()

        with pytest.raises(OperationError, match="running"):
            capture_filesystem_baseline(
                "vm",
                is_running=True,
                disk_paths=(disk,),
                case_dir=case_dir,
                mount_disk_fn=_make_mount_fn,
            )

    def test_allow_running_kwarg_bypasses_refusal(self, tmp_path: Path) -> None:
        disk = tmp_path / "disk.qcow2"
        disk.write_bytes(b"")
        case_dir = tmp_path / "case"
        case_dir.mkdir()
        mount = tmp_path / "mount"
        mount.mkdir()

        artifact = capture_filesystem_baseline(
            "vm",
            is_running=True,
            disk_paths=(disk,),
            case_dir=case_dir,
            mount_disk_fn=lambda _d: _make_mount_fn(mount),
            allow_running=True,
        )
        assert artifact.kind == ArtifactKind.FS_BASELINE
        # Tagged inconsistent because VM was running.
        assert artifact.metadata["disk_view"] == "inconsistent"
        assert artifact.metadata["vm_state"] == "running"

    def test_stopped_vm_baseline_marked_consistent(
        self,
        tmp_path: Path,
    ) -> None:
        disk = tmp_path / "disk.qcow2"
        disk.write_bytes(b"")
        case_dir = tmp_path / "case"
        case_dir.mkdir()
        mount = tmp_path / "mount"
        _populate_filesystem(mount, {"etc/hosts": b"127.0.0.1 localhost\n"})

        artifact = capture_filesystem_baseline(
            "vm",
            is_running=False,
            disk_paths=(disk,),
            case_dir=case_dir,
            mount_disk_fn=lambda _d: _make_mount_fn(mount),
        )
        assert artifact.metadata["disk_view"] == "consistent"
        assert artifact.metadata["vm_state"] == "stopped"
        assert artifact.metadata["entry_count"] == 1

    def test_baseline_json_round_trips(self, tmp_path: Path) -> None:
        disk = tmp_path / "disk.qcow2"
        disk.write_bytes(b"")
        case_dir = tmp_path / "case"
        case_dir.mkdir()
        mount = tmp_path / "mount"
        _populate_filesystem(mount, {"a": b"alpha", "b": b"beta"})

        artifact = capture_filesystem_baseline(
            "vm",
            is_running=False,
            disk_paths=(disk,),
            case_dir=case_dir,
            mount_disk_fn=lambda _d: _make_mount_fn(mount),
        )
        loaded = load_baseline(artifact.path)
        paths = {e.path for e in loaded.entries}
        assert paths == {"/a", "/b"}
        assert loaded.disk_view == "consistent"

    def test_no_disks_raises(self, tmp_path: Path) -> None:
        case_dir = tmp_path / "case"
        case_dir.mkdir()

        with pytest.raises(OperationError, match="no disks"):
            capture_filesystem_baseline(
                "vm",
                is_running=False,
                disk_paths=(),
                case_dir=case_dir,
                mount_disk_fn=_make_mount_fn,
            )


# ---------------------------------------------------------------------------
# capture_filesystem_diff
# ---------------------------------------------------------------------------


class TestCaptureDiff:
    def _setup(self, tmp_path: Path) -> tuple[Path, Path, Path]:
        disk = tmp_path / "disk.qcow2"
        disk.write_bytes(b"")
        case_dir = tmp_path / "case"
        case_dir.mkdir()
        mount = tmp_path / "mount"
        return disk, case_dir, mount

    def test_no_baseline_raises(self, tmp_path: Path) -> None:
        disk, case_dir, mount = self._setup(tmp_path)
        with pytest.raises(OperationError, match="no baseline"):
            capture_filesystem_diff(
                "vm",
                is_running=False,
                disk_paths=(disk,),
                case_dir=case_dir,
                mount_disk_fn=lambda _d: _make_mount_fn(mount),
            )

    def test_diff_shows_added_modified_deleted(
        self,
        tmp_path: Path,
    ) -> None:
        disk, case_dir, mount = self._setup(tmp_path)
        # Baseline state: a.txt, b.txt
        _populate_filesystem(mount, {"a.txt": b"alpha", "b.txt": b"beta"})
        capture_filesystem_baseline(
            "vm",
            is_running=False,
            disk_paths=(disk,),
            case_dir=case_dir,
            mount_disk_fn=lambda _d: _make_mount_fn(mount),
        )
        # Mutate: modify a, delete b, add c.
        (mount / "a.txt").write_bytes(b"alpha-MUTATED")
        (mount / "b.txt").unlink()
        (mount / "c.txt").write_bytes(b"new")

        artifact = capture_filesystem_diff(
            "vm",
            is_running=False,
            disk_paths=(disk,),
            case_dir=case_dir,
            mount_disk_fn=lambda _d: _make_mount_fn(mount),
        )
        assert artifact.kind == ArtifactKind.FS_DIFF
        assert artifact.metadata["added_count"] == 1
        assert artifact.metadata["modified_count"] == 1
        assert artifact.metadata["deleted_count"] == 1

    def test_diff_clean_when_no_changes(self, tmp_path: Path) -> None:
        disk, case_dir, mount = self._setup(tmp_path)
        _populate_filesystem(mount, {"a.txt": b"alpha"})
        capture_filesystem_baseline(
            "vm",
            is_running=False,
            disk_paths=(disk,),
            case_dir=case_dir,
            mount_disk_fn=lambda _d: _make_mount_fn(mount),
        )
        artifact = capture_filesystem_diff(
            "vm",
            is_running=False,
            disk_paths=(disk,),
            case_dir=case_dir,
            mount_disk_fn=lambda _d: _make_mount_fn(mount),
        )
        assert artifact.metadata["added_count"] == 0
        assert artifact.metadata["modified_count"] == 0
        assert artifact.metadata["deleted_count"] == 0

    def test_diff_on_running_vm_marks_inconsistent(
        self,
        tmp_path: Path,
    ) -> None:
        disk, case_dir, mount = self._setup(tmp_path)
        # First baseline while stopped.
        _populate_filesystem(mount, {"a.txt": b"alpha"})
        capture_filesystem_baseline(
            "vm",
            is_running=False,
            disk_paths=(disk,),
            case_dir=case_dir,
            mount_disk_fn=lambda _d: _make_mount_fn(mount),
        )
        # Now diff with the VM running.
        artifact = capture_filesystem_diff(
            "vm",
            is_running=True,
            disk_paths=(disk,),
            case_dir=case_dir,
            mount_disk_fn=lambda _d: _make_mount_fn(mount),
        )
        assert artifact.metadata["disk_view"] == "inconsistent"
        assert artifact.metadata["vm_state"] == "running"


# ---------------------------------------------------------------------------
# load_baseline schema gate
# ---------------------------------------------------------------------------


class TestLoadBaseline:
    def test_unsupported_schema_raises(self, tmp_path: Path) -> None:
        path = tmp_path / "baseline.json"
        path.write_text(
            '{"schema_version": 99, "captured_at": "2026-04-26T00:00:00+00:00", '
            '"vm_state": "stopped", "disk_view": "consistent", '
            '"includes": [], "excludes": [], "entries": []}',
            encoding="utf-8",
        )
        with pytest.raises(OperationError, match="schema_version"):
            load_baseline(path)

    def test_corrupt_json_raises(self, tmp_path: Path) -> None:
        path = tmp_path / "baseline.json"
        path.write_text("not json", encoding="utf-8")
        with pytest.raises(OperationError, match="unreadable"):
            load_baseline(path)
