"""Types + registries for VM isolation (Phase 16, MA-A1).

Three frozen-dataclass shapes (:class:`IsolationFinding`,
:class:`IsolationSummary`, :class:`IsolationReport`) plus three
StrEnum classifications (:class:`IsolationLevel`,
:class:`IsolationCategory`, :class:`FixKind`) plus two decorator
registries (``@isolation_check``, ``@isolation_fix``).

The registry plumbing lives here so per-category check modules
(e.g., :mod:`.checks.disk`) can register at import time without a
circular dependency on the analyzers.

See :doc:`/MALWARE_ANALYSIS` sections 4.2 through 4.4 for the
design contract.

Ported to ``mgf.vm.core`` (Wave 1) — pure-domain subset. The
``VMSpec``-vs-:class:`IsolationLevel` conflict detector
(``assert_spec_consistent_with_isolation`` / ``_spec_uses_passthrough``)
was DEFERRED: it depends on a ``spec.isolation`` attribute that the
new frozen :class:`mgf.vm.core.contracts.VmSpec` does not yet carry,
so it cannot be reconciled here without mutating the core spec.
"""

from __future__ import annotations

from collections.abc import Callable, Iterable, Mapping
from dataclasses import dataclass, field
from enum import StrEnum
from typing import TYPE_CHECKING, Any, Protocol, overload, runtime_checkable

from mgf.vm.core.domain import IsolationLevel

if TYPE_CHECKING:
    from datetime import datetime

    from mgf.vm.core.common.progress import ProgressReporter


__all__ = [
    "ISOLATION_REPORT_SCHEMA_VERSION",
    "FixKind",
    "IsolationCategory",
    "IsolationCheckFn",
    "IsolationFinding",
    "IsolationFix",
    "IsolationLevel",
    "IsolationReport",
    "IsolationSummary",
    "all_checks",
    "all_fixes",
    "get_fix",
    "isolation_check",
    "isolation_fix",
    "run_runtime_checks",
    "run_static_checks",
    "runtime_only_checks",
    "static_checks",
]


# ---------------------------------------------------------------------------
# Enums
# ---------------------------------------------------------------------------


class IsolationCategory(StrEnum):
    """Severity tier for an :class:`IsolationFinding`.

    Mirrors Phase 15's BLOCKING / ADVISORY / INFO model:

    - ``BLOCKING`` — leakage path; the VM cannot be considered
      isolated until this finding passes. Refusal-default for any
      apply pipeline that requests "must be clean".
    - ``ADVISORY`` — recommended but not strictly required. Examples:
      Secure Boot, balloon disabled, password-protected display.
    - ``INFO`` — informational, often host-level. Surfaced for
      awareness; the fix lives in another subsystem (typically
      ``host doctor``).
    """

    BLOCKING = "blocking"
    ADVISORY = "advisory"
    INFO = "info"


class FixKind(StrEnum):
    """How an :class:`IsolationFinding`'s remediation works.

    - ``AUTO_FIX`` — VManager can patch the spec/XML in place. The
      finding's ``fix_executor`` field names a registered
      :class:`IsolationFix` that performs the patch.
    - ``MANUAL`` — needs user judgement (e.g., choosing where to
      copy a disk into the quarantine pool). The CLI prints
      step-by-step guidance; no executor runs.
    - ``HOST_LEVEL`` — fix lives in another subsystem
      (``host doctor`` for KSM / CPU pinning / swap-on-VM-memory).
      Surfaced INFO-only so the user knows where to look.
    """

    AUTO_FIX = "auto_fix"
    MANUAL = "manual"
    HOST_LEVEL = "host_level"


# ---------------------------------------------------------------------------
# Frozen dataclasses
# ---------------------------------------------------------------------------


ISOLATION_REPORT_SCHEMA_VERSION = 1
"""Bump on any breaking shape change to :class:`IsolationReport`.

Loaders that don't recognise the schema_version refuse to proceed
with the actionable "Re-run vmanager vm security analyze to
regenerate" hint — same convention as Phase 15's
``REPORT_SCHEMA_VERSION``.
"""


@dataclass(frozen=True)
class IsolationFinding:
    """One row in an :class:`IsolationReport`.

    A finding is the result of one check function. ``passing=True``
    means the host's state matches the desired isolation posture;
    ``passing=False`` means there's a leakage path to fix.

    The ``id`` is stable (``<category>.<subject>.<aspect>``) so
    consumers can build dictionaries keyed on it. ``title`` and
    ``summary`` are user-facing copy. ``details`` carries structured
    data the report can show in an expansion panel — e.g., the actual
    set of `<filesystem>` source paths the analyzer detected.
    """

    id: str
    title: str
    summary: str
    category: IsolationCategory
    fix_kind: FixKind
    passing: bool
    details: Mapping[str, Any] = field(default_factory=dict)
    fix_executor: str = ""


@dataclass(frozen=True)
class IsolationSummary:
    """Aggregate counts across an :class:`IsolationReport`.

    Built in a single pass via :meth:`from_findings`. Exposes
    ``is_clean`` — True iff zero ``BLOCKING`` findings are failing —
    as the headline boolean every consumer uses to gate "is this VM
    safe to run as a malware-analysis VM?".
    """

    blocking_total: int = 0
    blocking_failing: int = 0
    advisory_total: int = 0
    advisory_failing: int = 0
    info_total: int = 0
    info_failing: int = 0
    auto_fixable: int = 0

    @classmethod
    def from_findings(cls, findings: Iterable[IsolationFinding]) -> IsolationSummary:
        b_total = b_fail = a_total = a_fail = i_total = i_fail = af = 0
        for f in findings:
            if f.category == IsolationCategory.BLOCKING:
                b_total += 1
                if not f.passing:
                    b_fail += 1
            elif f.category == IsolationCategory.ADVISORY:
                a_total += 1
                if not f.passing:
                    a_fail += 1
            elif f.category == IsolationCategory.INFO:
                i_total += 1
                if not f.passing:
                    i_fail += 1
            if not f.passing and f.fix_kind == FixKind.AUTO_FIX:
                af += 1
        return cls(
            blocking_total=b_total,
            blocking_failing=b_fail,
            advisory_total=a_total,
            advisory_failing=a_fail,
            info_total=i_total,
            info_failing=i_fail,
            auto_fixable=af,
        )

    @property
    def total_findings(self) -> int:
        return self.blocking_total + self.advisory_total + self.info_total

    @property
    def total_failing(self) -> int:
        return self.blocking_failing + self.advisory_failing + self.info_failing

    def is_clean(self) -> bool:
        """``True`` iff zero BLOCKING findings are failing.

        ADVISORY and INFO failures don't taint the "clean" verdict —
        they're surfaced for the user but don't block running the VM
        for malware analysis. This matches the design's
        BLOCKING-vs-ADVISORY split (§4.2).
        """
        return self.blocking_failing == 0


@dataclass(frozen=True)
class IsolationReport:
    """Bundle of findings + metadata for one analyze invocation.

    The ``runtime_audit`` flag distinguishes a static analysis (run
    against a ``VmSpec`` or domain XML before the VM launches) from
    a runtime audit (run against a live ``VMHandle``). The two are
    structurally identical but carry different sets of findings —
    runtime checks like AppArmor enforcement only apply once the
    qemu process is running.

    Frozen so a single instance can be safely shared across threads
    (the GUI worker hands it to the main thread via Qt signals).
    """

    generated_at: datetime
    vm_name: str
    declared_level: IsolationLevel
    findings: tuple[IsolationFinding, ...]
    summary: IsolationSummary
    runtime_audit: bool = False
    schema_version: int = field(
        default=ISOLATION_REPORT_SCHEMA_VERSION,
        kw_only=True,
    )


# ---------------------------------------------------------------------------
# Check registry (MA-A2 + MA-A3 populate this)
# ---------------------------------------------------------------------------


# A check is a pure function from analysis context to one finding.
# AnalysisContext is a forward-ref string here because it lives in the
# context module — keeping the import out of types.py avoids a cycle.
# Checks accept the context positionally.
IsolationCheckFn = Callable[..., IsolationFinding]


# Each registry entry is ``(fn, runtime_only)``. ``runtime_only=True``
# means the check requires data only available once the VM is running
# (host AppArmor mode, qemu process FDs, libvirt URI, etc.); the
# static analyzer skips these and the runtime analyzer runs them.
_CHECKS: list[tuple[IsolationCheckFn, bool]] = []


@overload
def isolation_check(func: IsolationCheckFn) -> IsolationCheckFn: ...
@overload
def isolation_check(
    func: None = None,
    *,
    runtime_only: bool = ...,
) -> Callable[[IsolationCheckFn], IsolationCheckFn]: ...
def isolation_check(
    func: IsolationCheckFn | None = None,
    *,
    runtime_only: bool = False,
) -> IsolationCheckFn | Callable[[IsolationCheckFn], IsolationCheckFn]:
    """Register ``func`` as an isolation check.

    Two call shapes:

    - ``@isolation_check`` (bare) — registers a static check that
      runs in both the static analyzer and the runtime analyzer.
    - ``@isolation_check(runtime_only=True)`` — registers a check
      that only fires from the runtime analyzer; the static
      analyzer skips it (the check would have nothing meaningful to
      observe pre-launch).

    The decorator does NOT wrap or modify the function — it just
    appends to the global registry at import time.

    Importing :mod:`mgf.vm.core.security.isolation.checks` triggers
    every per-category submodule import, which in turn registers
    every check; this is why the ``checks/__init__.py`` imports each
    submodule by name (the same side-effect-import pattern as Phase
    15's check registry).
    """

    def _register(fn: IsolationCheckFn) -> IsolationCheckFn:
        _CHECKS.append((fn, runtime_only))
        return fn

    if func is not None:
        return _register(func)
    return _register


def all_checks() -> tuple[IsolationCheckFn, ...]:
    """Return a snapshot of every registered check (static + runtime)."""
    return tuple(fn for fn, _ in _CHECKS)


def static_checks() -> tuple[IsolationCheckFn, ...]:
    """Return only the checks that run in the static analyzer."""
    return tuple(fn for fn, runtime_only in _CHECKS if not runtime_only)


def runtime_only_checks() -> tuple[IsolationCheckFn, ...]:
    """Return the checks that ONLY run in the runtime analyzer."""
    return tuple(fn for fn, runtime_only in _CHECKS if runtime_only)


def run_static_checks(ctx: object) -> list[IsolationFinding]:
    """Run every static-mode check against ``ctx``.

    Runtime-only checks are skipped — see :func:`run_runtime_checks`
    for the runtime path.

    Per-check exceptions are caught here and converted to a SKIP-like
    INFO finding (``passing=False``, ``fix_kind=HOST_LEVEL``,
    ``id=runtime.check_failed.<name>``) so a single buggy check can
    never abort the whole analysis run. The exception is logged via
    ``LOG.exception`` so the traceback isn't lost.
    """
    return _run(ctx, include_runtime_only=False)


def run_runtime_checks(ctx: object) -> list[IsolationFinding]:
    """Run every check (static + runtime-only) against ``ctx``.

    The runtime analyzer (MA-A3) builds an ``AnalysisContext`` with
    ``runtime_audit=True`` and pre-collected ``host_facts``, then
    calls this function. Same per-check exception isolation as
    :func:`run_static_checks`.
    """
    return _run(ctx, include_runtime_only=True)


def _run(ctx: object, *, include_runtime_only: bool) -> list[IsolationFinding]:
    import logging

    log = logging.getLogger("mgf.vm.core.security.isolation.types")
    out: list[IsolationFinding] = []
    for fn, runtime_only in _CHECKS:
        if runtime_only and not include_runtime_only:
            continue
        try:
            out.append(fn(ctx))
        except Exception as e:
            name = getattr(fn, "__name__", "unknown_check")
            log.exception(
                "isolation check raised; converting to SKIP finding",
                extra={"check": name, "error_class": type(e).__name__},
            )
            out.append(
                IsolationFinding(
                    id=f"runtime.check_failed.{name}",
                    title=f"isolation check {name!r} crashed",
                    summary=str(e),
                    category=IsolationCategory.INFO,
                    fix_kind=FixKind.HOST_LEVEL,
                    passing=False,
                ),
            )
    return out


# ---------------------------------------------------------------------------
# Fix registry (MA-A4 populates this)
# ---------------------------------------------------------------------------


@runtime_checkable
class IsolationFix(Protocol):
    """Callable contract for an auto-fix executor.

    Every fix takes:

    - ``target`` — typically an :class:`xml.etree.ElementTree.ElementTree`
      (parsed domain XML) for an existing VM, or a ``VmSpec`` instance
      for a pre-create patch. The fix decides which it expects via
      type-narrowing on its first kwarg; the orchestrator routes both.
    - ``reporter`` — a :class:`ProgressReporter` for live status lines.
    - ``dry_run`` — when True, the fix logs the change but does not
      mutate ``target``.
    - any per-fix kwargs needed (e.g., ``network_name`` for the
      network swap fix).

    Idempotent by contract — running twice on a satisfied target is
    a no-op the second time and logs ``"already satisfied"`` at INFO.

    Returns ``None``. Failures raise a typed
    :class:`mgf.vm.core.exceptions.OperationError` subclass; never bare
    ``subprocess.CalledProcessError`` or similar.
    """

    def __call__(
        self,
        *,
        target: Any,
        reporter: ProgressReporter,
        dry_run: bool = False,
        **kwargs: Any,
    ) -> None: ...


_FIXES: dict[str, IsolationFix] = {}


def isolation_fix(name: str) -> Callable[[IsolationFix], IsolationFix]:
    """Register ``func`` as the auto-fix executor for ``name``.

    ``name`` is the stable lookup key (e.g., ``"strip.filesystem"``).
    Findings reference fixes by this name in their ``fix_executor``
    field; the orchestrator resolves via :func:`get_fix`.

    Re-registering the same name raises :class:`ValueError` so a
    typo or copy-paste collision surfaces at import time, not at
    apply time.
    """

    def _decorator(func: IsolationFix) -> IsolationFix:
        if name in _FIXES:
            msg = (
                f"isolation fix {name!r} already registered "
                f"(by {_FIXES[name]!r}); refusing to overwrite."
            )
            raise ValueError(msg)
        _FIXES[name] = func
        return func

    return _decorator


def get_fix(name: str) -> IsolationFix | None:
    """Return the registered fix executor for ``name``, or ``None``."""
    return _FIXES.get(name)


def all_fixes() -> Mapping[str, IsolationFix]:
    """Return a read-only view of every registered fix."""
    return dict(_FIXES)
