"""Atomic CRUD over the on-disk case directory.

Each case lives under ``~/.local/share/vmanager/forensics/<case-name>/``
with this layout (see ``docs/FORENSICS.md`` §4.2):

    <case-name>/
    ├── case.yaml              # the Case dataclass, mode 0o600
    ├── network/               # pcaps + per-capture metadata
    ├── filesystem/            # baseline.json + diff.json
    ├── memory/                # memory.dump
    ├── screenshots/           # PNG files
    ├── reports/               # generated reports
    └── manifest.json          # SHA-256 integrity hashes (see manifest.py)

The store is the only module that touches ``case.yaml`` directly —
every mutation goes through atomic temp-file + ``Path.replace`` so
a crashed CLI never leaves a half-written case file.
"""

from __future__ import annotations

import logging
import os
import tempfile
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
from typing import TYPE_CHECKING, Any
from uuid import UUID

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

from mgf.vm.forensics.case import (
    CASE_SCHEMA_VERSION,
    Artifact,
    ArtifactKind,
    Case,
    CaseStatus,
)

if TYPE_CHECKING:
    from collections.abc import Mapping


__all__ = [
    "CASE_FILENAME",
    "CaseStore",
    "default_forensics_dir",
]


LOG = logging.getLogger("mgf.vm.forensics.store")

CASE_FILENAME = "case.yaml"

# Status transitions allowed by the store. Any other transition raises.
_ALLOWED_TRANSITIONS: Mapping[CaseStatus, frozenset[CaseStatus]] = {
    CaseStatus.PREPARING: frozenset({CaseStatus.ACTIVE, CaseStatus.COMPLETED}),
    CaseStatus.ACTIVE: frozenset({CaseStatus.COMPLETED}),
    CaseStatus.COMPLETED: frozenset({CaseStatus.ARCHIVED}),
    CaseStatus.ARCHIVED: frozenset(),
}


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


def default_forensics_dir() -> Path:
    """Return the canonical forensics root.

    Resolved at call time so per-test ``HOME`` monkeypatches work.
    """
    return Path("~/.local/share/vmanager/forensics").expanduser()


# ---------------------------------------------------------------------------
# CaseStore
# ---------------------------------------------------------------------------


@dataclass
class CaseStore:
    """Manages the case archive directory.

    Construct with an explicit ``root`` for tests, or pass ``None``
    to resolve the canonical default at call time (CF-04).
    """

    root: Path | None = None

    def root_path(self) -> Path:
        """Return the resolved root directory (may not yet exist)."""
        return self.root if self.root is not None else default_forensics_dir()

    def case_dir(self, name: str) -> Path:
        """Return the absolute case directory for ``name``."""
        if not name:
            msg = "case name must be non-empty"
            raise OperationError(msg)
        if "/" in name or "\\" in name or name in {".", ".."}:
            msg = f"case name {name!r} contains illegal characters"
            raise OperationError(msg)
        root = self.root_path()
        candidate = root / name
        # SC-15: defence-in-depth beyond the string check — resolve + containment.
        if not candidate.resolve().is_relative_to(root.resolve()):
            msg = f"case name {name!r} escapes the forensics root"
            raise OperationError(msg)
        return candidate

    def case_yaml(self, name: str) -> Path:
        return self.case_dir(name) / CASE_FILENAME

    # ------------------------------------------------------------------
    # Create / load / save
    # ------------------------------------------------------------------

    def create(self, case: Case) -> Path:
        """Initialise a new case directory + write the initial case.yaml.

        Refuses if a case with that name already exists. Creates the
        canonical subdirectory layout (network/ filesystem/ memory/
        screenshots/ reports/) so subsequent captures don't have to.
        """
        target = self.case_dir(case.name)
        if target.exists():
            msg = f"case {case.name!r} already exists at {target}"
            raise OperationError(msg)
        target.mkdir(parents=True, exist_ok=False)
        target.chmod(0o700)
        for sub in ("network", "filesystem", "memory", "screenshots", "reports"):
            (target / sub).mkdir(exist_ok=True)
        self._save_yaml(case)
        return target

    def load(self, name: str) -> Case:
        """Load a case by name. Raises :class:`OperationError` if missing or malformed."""
        path = self.case_yaml(name)
        if not path.exists():
            msg = f"no case named {name!r} (looked at {path})"
            raise OperationError(msg)
        try:
            raw = yaml.safe_load(path.read_text(encoding="utf-8"))
        except (OSError, yaml.YAMLError) as e:
            msg = f"case file {path} is unreadable: {e}"
            raise OperationError(msg) from e
        if not isinstance(raw, dict):
            msg = f"case file {path} is not a YAML mapping"
            raise OperationError(msg)
        version = raw.get("schema_version")
        if version != CASE_SCHEMA_VERSION:
            msg = (
                f"case file {path} has schema_version {version!r}; "
                f"this build expects {CASE_SCHEMA_VERSION}."
            )
            raise OperationError(msg)
        try:
            return _from_dict(raw)
        except (KeyError, TypeError, ValueError) as e:
            msg = f"case file {path} is malformed: {e}"
            raise OperationError(msg) from e

    def save(self, case: Case) -> Path:
        """Atomically persist ``case`` to its case.yaml."""
        if not self.case_dir(case.name).exists():
            msg = (
                f"cannot save case {case.name!r}: directory does not exist. "
                f"Use CaseStore.create() to initialise a new case."
            )
            raise OperationError(msg)
        return self._save_yaml(case)

    def list(self) -> tuple[Case, ...]:
        """Return every case under the root, sorted by ``created_at`` ASC.

        Skips dirs that don't have a valid case.yaml (logged WARNING).
        """
        root = self.root_path()
        if not root.exists():
            return ()
        out: list[Case] = []
        for entry in sorted(root.iterdir()):
            if not entry.is_dir():
                continue
            yaml_path = entry / CASE_FILENAME
            if not yaml_path.exists():
                continue
            try:
                out.append(self.load(entry.name))
            except OperationError:
                LOG.warning(
                    "skipping unreadable case directory",
                    extra={"path": str(entry)},
                    exc_info=True,
                )
                continue
        out.sort(key=lambda c: c.created_at)
        return tuple(out)

    def delete(self, name: str, *, purge: bool = False) -> None:
        """Remove a case from the store.

        ``purge=True`` recursively wipes the case directory + every
        artifact. Without ``purge``, refuses if the case has artifacts
        — the caller is expected to ``archive`` first to bundle them
        out.
        """
        target = self.case_dir(name)
        if not target.exists():
            msg = f"no case named {name!r}"
            raise OperationError(msg)
        case = self.load(name)
        if case.artifacts and not purge:
            msg = (
                f"case {name!r} has {len(case.artifacts)} artifact(s); "
                f"pass --purge to wipe them, or `case archive` first."
            )
            raise OperationError(msg)
        # Recursive rmtree — same operational gravity as `rm -rf`.
        # Acceptable because the path is bounded by case_dir() which
        # rejects illegal names + path traversal.
        import shutil

        shutil.rmtree(target)

    # ------------------------------------------------------------------
    # Mutations that drive the status state machine
    # ------------------------------------------------------------------

    def transition(self, name: str, new_status: CaseStatus) -> Case:
        """Move case ``name`` to ``new_status``; refuse invalid transitions."""
        case = self.load(name)
        if new_status == case.status:
            return case
        allowed = _ALLOWED_TRANSITIONS.get(case.status, frozenset())
        if new_status not in allowed:
            msg = (
                f"case {name!r} cannot transition {case.status.value} → "
                f"{new_status.value} (allowed: "
                f"{sorted(s.value for s in allowed) or 'none — terminal state'})"
            )
            raise OperationError(msg)
        completed_at = (
            datetime.now(UTC) if new_status == CaseStatus.COMPLETED else case.completed_at
        )
        updated = case.with_status(new_status, completed_at=completed_at)
        self._save_yaml(updated)
        return updated

    def add_artifact(self, name: str, artifact: Artifact) -> Case:
        """Append an artifact to a case + re-save the case YAML."""
        case = self.load(name)
        updated = case.with_artifact(artifact)
        self._save_yaml(updated)
        return updated

    # ------------------------------------------------------------------
    # Internals
    # ------------------------------------------------------------------

    def _save_yaml(self, case: Case) -> Path:
        target = self.case_yaml(case.name)
        payload = _to_dict(case)
        fd, tmp_str = tempfile.mkstemp(
            prefix=".case-",
            suffix=".yaml",
            dir=str(target.parent),
        )
        tmp = Path(tmp_str)
        try:
            with os.fdopen(fd, "w", encoding="utf-8") as f:
                yaml.safe_dump(
                    payload,
                    f,
                    sort_keys=False,
                    default_flow_style=False,
                )
            tmp.chmod(0o600)
            tmp.replace(target)
        except OSError:
            tmp.unlink(missing_ok=True)
            raise
        return target


# ---------------------------------------------------------------------------
# Case <-> dict round-trip
# ---------------------------------------------------------------------------
#
# Kept module-private; call sites use CaseStore.load / .save. We
# round-trip via dict (not pyyaml's object hooks) so the YAML is
# human-readable + version migrations stay cheap.


def _to_dict(case: Case) -> dict[str, Any]:
    return {
        "schema_version": case.schema_version,
        "name": case.name,
        "case_id": str(case.case_id),
        "vm_name": case.vm_name,
        "sample_path": str(case.sample_path) if case.sample_path else None,
        "sample_sha256": case.sample_sha256,
        "description": case.description,
        "status": case.status.value,
        "created_at": case.created_at.isoformat(),
        "completed_at": case.completed_at.isoformat() if case.completed_at else None,
        "vmanager_version": case.vmanager_version,
        "qemu_version": case.qemu_version,
        "host_kernel": case.host_kernel,
        # IsolationReport is not round-tripped here because it's a
        # large frozen tree; the case stores it under
        # <case_dir>/isolation-snapshot.json on its own (Phase 17-A
        # expansion). For now, the field is None on save/load.
        "isolation_report_present": case.isolation_report is not None,
        "artifacts": [_artifact_to_dict(a) for a in case.artifacts],
    }


def _from_dict(raw: Mapping[str, Any]) -> Case:
    sample_path_raw = raw.get("sample_path")
    completed_at_raw = raw.get("completed_at")
    return Case(
        name=str(raw["name"]),
        case_id=UUID(str(raw["case_id"])),
        vm_name=str(raw["vm_name"]),
        sample_path=Path(sample_path_raw) if sample_path_raw else None,
        sample_sha256=str(raw.get("sample_sha256", "")),
        description=str(raw.get("description", "")),
        status=CaseStatus(raw.get("status", CaseStatus.PREPARING.value)),
        created_at=datetime.fromisoformat(str(raw["created_at"])),
        completed_at=(datetime.fromisoformat(str(completed_at_raw)) if completed_at_raw else None),
        vmanager_version=str(raw.get("vmanager_version", "")),
        qemu_version=str(raw.get("qemu_version", "")),
        host_kernel=str(raw.get("host_kernel", "")),
        isolation_report=None,
        artifacts=tuple(_artifact_from_dict(a) for a in raw.get("artifacts", [])),
    )


def _artifact_to_dict(a: Artifact) -> dict[str, Any]:
    return {
        "kind": a.kind.value,
        "path": str(a.path),
        "sha256": a.sha256,
        "size_bytes": a.size_bytes,
        "captured_at": a.captured_at.isoformat(),
        "captured_by": a.captured_by,
        "metadata": dict(a.metadata),
    }


def _artifact_from_dict(raw: Mapping[str, Any]) -> Artifact:
    return Artifact(
        kind=ArtifactKind(raw["kind"]),
        path=Path(str(raw["path"])),
        sha256=str(raw.get("sha256", "")),
        size_bytes=int(raw.get("size_bytes", 0)),
        captured_at=datetime.fromisoformat(str(raw["captured_at"])),
        captured_by=str(raw.get("captured_by", "")),
        metadata=dict(raw.get("metadata", {})),
    )
