"""Integrity-check manifest for a forensic case.

The manifest is a single JSON file at the case root that lists every
file under the case dir along with its SHA-256. After a fresh capture
adds a new artifact, the manifest is rebuilt from scratch (the file
walk is cheap and re-reading every artifact for hashing is what gives
the manifest its value).

A later integrity check re-walks the case dir + recomputes every
hash + compares against the manifest. Mismatch → :class:`OperationError`
with the offending file in the message.

Threat model (RA-06)
====================

This manifest catches **accidental corruption** — bit rot, a
truncated copy, a partially-applied edit, an artifact whose
``_refresh_manifest`` call was forgotten by a new CLI command. It
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`` passes :func:`verify_manifest` cleanly
(the manifest excludes itself from the walk — it has to, circular
hashing — and is not signed). True tamper-evidence needs a root of
trust the attacker can't reach (a detached signature stored
off-box). Phase 17 does not promise that bar today; if a
deployment needs it, ship a separate signed inventory.

Excludes the manifest itself + any `.tmp` files (atomic-write
in-progress).

Schema versioning + atomic write are delegated to
:class:`mgf.common.versioned.VersionedFileStore` (mgf-common 0.13+).
"""

from __future__ import annotations

import hashlib
import logging
from datetime import UTC, datetime
from typing import TYPE_CHECKING

from mgf.common.config import BaseModel, ConfigDict
from mgf.common.exceptions import AppConfigError, AppConfigErrorKind
from mgf.common.versioned import VersionedFileStore
from mgf.vm.core.exceptions import OperationError

if TYPE_CHECKING:
    from pathlib import Path

__all__ = [
    "MANIFEST_FILENAME",
    "MANIFEST_SCHEMA_VERSION",
    "Manifest",
    "ManifestEntry",
    "build_manifest",
    "load_manifest",
    "save_manifest",
    "verify_manifest",
]


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


MANIFEST_FILENAME = "manifest.json"
MANIFEST_SCHEMA_VERSION = 1


class ManifestEntry(BaseModel):
    """One row in the manifest — a path + its hash + size."""

    model_config = ConfigDict(extra="forbid", frozen=True)

    relative_path: str  # POSIX-style, relative to case dir
    sha256: str
    size_bytes: int


class Manifest(BaseModel):
    """Bundle of (entries + metadata) persisted as ``manifest.json``."""

    model_config = ConfigDict(extra="forbid", frozen=True)

    entries: tuple[ManifestEntry, ...] = ()
    generated_at: datetime
    schema_version: int = MANIFEST_SCHEMA_VERSION

    def by_path(self) -> dict[str, ManifestEntry]:
        """Return a dict lookup keyed by relative_path."""
        return {e.relative_path: e for e in self.entries}


# ---------------------------------------------------------------------------
# Build / save / load
# ---------------------------------------------------------------------------


def build_manifest(case_dir: Path, *, now: datetime | None = None) -> Manifest:
    """Walk ``case_dir`` and return a :class:`Manifest` for every file inside.

    Excludes ``manifest.json`` itself and any ``.tmp`` / ``.partial``
    in-flight files (atomic-write artifacts).

    The walk is sorted (``Path.rglob`` + ``sorted``) so the manifest's
    on-disk JSON is byte-stable across runs — important for diffing.
    """
    if not case_dir.exists():
        msg = f"case directory does not exist: {case_dir}"
        raise OperationError(msg)
    if not case_dir.is_dir():
        msg = f"case path is not a directory: {case_dir}"
        raise OperationError(msg)

    entries: list[ManifestEntry] = []
    for path in sorted(case_dir.rglob("*")):
        if not path.is_file():
            continue
        if path.name == MANIFEST_FILENAME:
            continue
        if path.suffix in {".tmp", ".partial"}:
            continue
        if path.name.startswith(".manifest-"):  # tempfile from save_manifest
            continue
        rel = path.relative_to(case_dir).as_posix()
        digest = _hash_file(path)
        entries.append(
            ManifestEntry(
                relative_path=rel,
                sha256=digest,
                size_bytes=path.stat().st_size,
            ),
        )
    return Manifest(
        entries=tuple(entries),
        generated_at=now or datetime.now(UTC),
    )


def _store_for(case_dir: Path) -> VersionedFileStore[Manifest]:
    return VersionedFileStore(
        case_dir / MANIFEST_FILENAME,
        schema=Manifest,
        current_version=MANIFEST_SCHEMA_VERSION,
        version_field="schema_version",
        file_format="json",
    )


def save_manifest(case_dir: Path, manifest: Manifest) -> Path:
    """Atomically write ``manifest`` to ``<case_dir>/manifest.json``."""
    target = case_dir / MANIFEST_FILENAME
    _store_for(case_dir).save(manifest)
    return target


def load_manifest(case_dir: Path) -> Manifest:
    """Read the persisted manifest. Raise :class:`OperationError` if absent or malformed."""
    target = case_dir / MANIFEST_FILENAME
    if not target.exists():
        msg = (
            f"no manifest at {target}. Run `vmanager forensics case show "
            f"{case_dir.name} --rebuild-manifest` to regenerate."
        )
        raise OperationError(msg)
    try:
        return _store_for(case_dir).load()
    except AppConfigError as e:
        # mgf-common 0.15+ tags VFS errors with ``e.kind`` (closes
        # upstream PAPER-11). Test pins (``match="unreadable"`` /
        # ``match="schema_version"``) are preserved via the same
        # kind→legacy-wording map ``forensics/capture/filesystem.py
        # ::load_baseline`` uses.
        if e.kind in {AppConfigErrorKind.JSON_PARSE_FAILURE, AppConfigErrorKind.YAML_PARSE_FAILURE}:
            msg = f"manifest at {target} is unreadable: {e}"
        else:
            msg = (
                f"manifest at {target} has unsupported schema_version; "
                f"this build expects {MANIFEST_SCHEMA_VERSION}."
            )
        raise OperationError(msg) from e


# ---------------------------------------------------------------------------
# Verification
# ---------------------------------------------------------------------------


class VerifyResult(BaseModel):
    """Outcome of :func:`verify_manifest`.

    Empty :attr:`mismatches` + empty :attr:`missing` + empty
    :attr:`unexpected` means the manifest matches the case dir
    exactly.
    """

    model_config = ConfigDict(extra="forbid", frozen=True)

    mismatches: tuple[str, ...] = ()  # path → hash differs from manifest
    missing: tuple[str, ...] = ()  # in manifest but not on disk
    unexpected: tuple[str, ...] = ()  # on disk but not in manifest

    def is_clean(self) -> bool:
        return not (self.mismatches or self.missing or self.unexpected)


def verify_manifest(case_dir: Path, manifest: Manifest | None = None) -> VerifyResult:
    """Re-walk ``case_dir`` and compare against ``manifest``.

    If ``manifest`` is None, reads the persisted one first.
    """
    if manifest is None:
        manifest = load_manifest(case_dir)
    expected = manifest.by_path()
    current = build_manifest(case_dir).by_path()

    mismatches: list[str] = []
    missing: list[str] = []
    unexpected: list[str] = []

    for path, entry in expected.items():
        actual = current.get(path)
        if actual is None:
            missing.append(path)
            continue
        if actual.sha256 != entry.sha256:
            mismatches.append(path)
    for path in current:
        if path not in expected:
            unexpected.append(path)

    return VerifyResult(
        mismatches=tuple(sorted(mismatches)),
        missing=tuple(sorted(missing)),
        unexpected=tuple(sorted(unexpected)),
    )


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def _hash_file(path: Path, *, chunk_size: int = 1 << 16) -> str:
    """Return the hex SHA-256 of a file's contents, streaming."""
    h = hashlib.sha256()
    with path.open("rb") as f:
        while True:
            chunk = f.read(chunk_size)
            if not chunk:
                break
            h.update(chunk)
    return h.hexdigest()
