"""Pure isolation analyzers — static + runtime (drained from the umbrella).

The umbrella's ``static_analyzer`` / ``runtime_analyzer`` intermixed
*collection* (parse untrusted XML via ``defusedxml``, shell out to read
``/proc``, query AppArmor, look up the qemu PID, dump libvirt network
XML) with *evaluation* (walk the parsed tree + injected facts, run the
registered ``@isolation_check`` functions, fold them into an
:class:`IsolationReport`).

This module lifts ONLY the pure evaluation half into the core:

- :func:`analyze_static` walks an already-parsed ``<domain>`` element
  through every static check.
- :func:`analyze_runtime` walks the element + an injected
  :class:`HostFacts` bundle through every check (static + runtime-only).

What stays at the adapter (the COLLECTION contract):

- Parsing the domain-XML string into an :class:`xml.etree.ElementTree.Element`
  (the adapter's ``parse_xml_string`` — ``defusedxml`` is forbidden in
  the pure core).
- Filling a :class:`HostFacts` instance: the qemu PID lookup, reading
  ``/proc/<pid>/attr/current`` for AppArmor state, scanning
  ``/proc/<pid>/fd`` for unsanctioned open paths, reading
  ``/sys/kernel/mm/ksm/run``, the libvirt URI, and dumping each
  attached network's XML to decide ``is_isolated``.

The pure core never touches ``/proc``, never shells out, never imports
``libvirt`` or ``defusedxml``. It evaluates injected data only.

See ``vm-vmanager-docs/component-specs/contracts.md`` and the umbrella
modules ``mgf.vm.security.isolation.{static_analyzer,runtime_analyzer}``
for the pre-drain shape.
"""

from __future__ import annotations

import xml.etree.ElementTree as ET
from dataclasses import dataclass
from datetime import UTC, datetime

from mgf.vm.core.domain import IsolationLevel
from mgf.vm.core.security.isolation.context import AnalysisContext
from mgf.vm.core.security.isolation.types import (
    IsolationReport,
    IsolationSummary,
    run_runtime_checks,
    run_static_checks,
)

# Side-effect import — populates the @isolation_check registry so the
# analyzers see every check even if a caller imports this module first.
from mgf.vm.core.security.isolation import checks as _checks  # noqa: F401  # isort: skip


__all__ = [
    "HostFacts",
    "analyze_runtime",
    "analyze_static",
]


# ---------------------------------------------------------------------------
# HostFacts — the injected runtime-collection contract
# ---------------------------------------------------------------------------


@dataclass(frozen=True)
class HostFacts:
    """Host-side facts the runtime analyzer needs, collected by the adapter.

    The umbrella's ``default_facts_collector`` produced these by reading
    ``/proc``, shelling out, and querying the libvirt provider. In the
    pure core they arrive pre-collected: the adapter fills this frozen
    bundle and hands it to :func:`analyze_runtime`, which only *evaluates*
    it.

    Field-to-collection mapping (what the adapter must fill):

    - ``libvirt_uri`` — ``provider.libvirt_uri`` (e.g. ``"qemu:///system"``).
    - ``qemu_pid`` — the qemu process PID for the domain (``0`` if
      unknowable). Carried for completeness / diagnostics; no check
      reads it directly — it is the *input* the adapter uses to derive
      the AppArmor / open-path facts below.
    - ``apparmor_profile_state`` — ``"enforce"`` / ``"complain"`` / ``""``,
      parsed from ``/proc/<pid>/attr/current``.
    - ``apparmor_profile_name`` — the profile name from the same file.
    - ``qemu_open_paths`` — host paths the qemu process has open that are
      NOT on the sanctioned allowlist (scanned from ``/proc/<pid>/fd``).
    - ``ksm_run`` — contents of ``/sys/kernel/mm/ksm/run`` (``"0"`` / ``"1"``
      / ``""``).
    - ``network_active_state`` — ``(network_name, is_isolated)`` per
      attached libvirt network, derived from each network's dumped XML
      (``<forward>`` absent ⇒ isolated).

    Frozen so the evaluation can't mutate the collected facts.
    """

    libvirt_uri: str = ""
    qemu_pid: int = 0
    apparmor_profile_state: str = ""
    apparmor_profile_name: str = ""
    qemu_open_paths: tuple[str, ...] = ()
    ksm_run: str = ""
    network_active_state: tuple[tuple[str, bool], ...] = ()

    def as_check_facts(self) -> dict[str, object]:
        """Project to the dotted-key ``host_facts`` dict the checks read.

        The pure ``@isolation_check`` functions read keys such as
        ``"apparmor.profile_state"`` and ``"network.active_state"`` from
        :attr:`AnalysisContext.host_facts`. This keeps that wire format
        as the single source of truth while letting callers construct a
        typed, frozen :class:`HostFacts` instead of an untyped dict.
        """
        return {
            "libvirt.uri": self.libvirt_uri,
            "qemu.pid": self.qemu_pid,
            "apparmor.profile_state": self.apparmor_profile_state,
            "apparmor.profile_name": self.apparmor_profile_name,
            # Checks do ``isinstance(..., list)`` narrowing on these two,
            # so project the tuples back to lists at the boundary.
            "qemu.open_paths": list(self.qemu_open_paths),
            "host.ksm_run": self.ksm_run,
            "network.active_state": [tuple(pair) for pair in self.network_active_state],
        }


# ---------------------------------------------------------------------------
# analyze_static
# ---------------------------------------------------------------------------


def analyze_static(
    element: ET.Element,
    *,
    vm_name: str | None = None,
    declared_level: IsolationLevel = IsolationLevel.STRICT,
    now: datetime | None = None,
) -> IsolationReport:
    """Run every static isolation check against a parsed ``<domain>`` element.

    The caller (adapter / app) is responsible for parsing the domain-XML
    string into ``element`` — typically via the adapter's
    ``parse_xml_string`` — because untrusted-XML parsing (``defusedxml``)
    is forbidden in the pure core. The pre-create flow's spec→XML
    synthesis likewise stays caller-side; this function only evaluates an
    already-built tree.

    Per-check exceptions are caught by :func:`run_static_checks` and
    converted to SKIP-shaped INFO findings, so a single buggy check never
    aborts the whole analysis.

    ``vm_name`` defaults to the ``<name>`` text of the element (or
    ``"<unnamed>"`` if absent). ``runtime_audit`` is always ``False`` for
    a static analysis.
    """
    derived_name = vm_name or element.findtext("name") or "<unnamed>"
    ctx = AnalysisContext(
        domain_xml=ET.ElementTree(element),
        vm_name=derived_name,
        declared_level=declared_level,
        runtime_audit=False,
    )
    findings = run_static_checks(ctx)
    return IsolationReport(
        generated_at=now or datetime.now(UTC),
        vm_name=derived_name,
        declared_level=declared_level,
        findings=tuple(findings),
        summary=IsolationSummary.from_findings(findings),
        runtime_audit=False,
    )


# ---------------------------------------------------------------------------
# analyze_runtime
# ---------------------------------------------------------------------------


def analyze_runtime(
    element: ET.Element,
    facts: HostFacts,
    *,
    vm_name: str | None = None,
    declared_level: IsolationLevel = IsolationLevel.STRICT,
    now: datetime | None = None,
) -> IsolationReport:
    """Audit a live VM: run every check (static + runtime-only) against the
    parsed ``<domain>`` element plus the injected :class:`HostFacts`.

    The umbrella's ``analyze_runtime`` pulled the domain XML from the
    provider and shelled out to collect host facts before evaluating. In
    the pure core both arrive pre-collected: the adapter parses
    ``element`` and fills ``facts`` (see :class:`HostFacts`); this
    function only evaluates them.

    Returns an :class:`IsolationReport` with ``runtime_audit=True``.
    Per-check exceptions are caught by :func:`run_runtime_checks` and
    converted to SKIP-shaped INFO findings.
    """
    derived_name = vm_name or element.findtext("name") or "<unnamed>"
    ctx = AnalysisContext(
        domain_xml=ET.ElementTree(element),
        vm_name=derived_name,
        declared_level=declared_level,
        runtime_audit=True,
        host_facts=facts.as_check_facts(),
    )
    findings = run_runtime_checks(ctx)
    return IsolationReport(
        generated_at=now or datetime.now(UTC),
        vm_name=derived_name,
        declared_level=declared_level,
        findings=tuple(findings),
        summary=IsolationSummary.from_findings(findings),
        runtime_audit=True,
    )
