from __future__ import annotations

import hashlib
import os
import platform
import re
import shutil
from dataclasses import dataclass
from enum import StrEnum
from pathlib import Path, PurePosixPath, PureWindowsPath
from typing import Final

import yaml

PROFILE_SCHEMA: Final = "missioncore.simulation-s0-profile/v1"
EVIDENCE_SCHEMA: Final = "missioncore.simulation-s0-evidence/v1"
DOCTOR_SCHEMA: Final = "missioncore.simulation-s0-doctor/v1"
MAX_MANIFEST_BYTES: Final = 256 * 1024

_SAFE_ID = re.compile(r"^[a-z0-9][a-z0-9-]{0,63}$")
_SAFE_TOOL = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
_SHA256 = re.compile(r"^[a-f0-9]{64}$")
_GIT_COMMIT = re.compile(r"^[a-f0-9]{40}$")


class S0ProfileError(ValueError):
    """The SIM S0 qualification contract is absent, unsafe, or inconsistent."""


class CheckStatus(StrEnum):
    PASS = "pass"
    FAIL = "fail"
    UNKNOWN = "unknown"


class DoctorVerdict(StrEnum):
    GO = "go"
    INCOMPLETE = "incomplete"
    BLOCKED = "blocked"


@dataclass(frozen=True, slots=True)
class TargetHost:
    windows_release: str
    virtualization: str
    wsl_distribution: str
    linux_distribution: str
    architecture: str
    container_runtime: str


@dataclass(frozen=True, slots=True)
class MutablePath:
    identifier: str
    windows_path: PureWindowsPath
    wsl_path: PurePosixPath


@dataclass(frozen=True, slots=True)
class StoragePolicy:
    windows_root: PureWindowsPath
    wsl_root: PurePosixPath
    minimum_free_gib: int
    stop_below_gib: int
    forbid_windows_system_drive: bool
    mutable_paths: tuple[MutablePath, ...]


@dataclass(frozen=True, slots=True)
class ClockPolicy:
    authority: str
    ros_use_sim_time: bool
    px4_uxrce_dds_sync_enabled: bool
    canonical_unit: str
    utc_role: str


@dataclass(frozen=True, slots=True)
class AuthorityPolicy:
    simulation_or_shadow_only: bool
    actuator_authority: bool
    navigation_or_safety_accepted: bool
    direct_actuator_setpoints_allowed: bool
    allowed_setpoint_profiles: tuple[str, ...]


@dataclass(frozen=True, slots=True)
class RosProfile:
    domain_id: int
    namespace: str


@dataclass(frozen=True, slots=True)
class RuntimeAcceptance:
    world: str
    model: str
    physics_step_ns: int
    pause_max_advance_ns: int
    speed_factors: tuple[int, ...]
    rtf_tolerance_percent: int
    measurement_window_seconds: int
    resource_sample_interval_milliseconds: int
    max_owned_rss_mib: int
    max_owned_cpu_percent: int
    minimum_available_memory_gib: int


@dataclass(frozen=True, slots=True)
class ComponentPin:
    identifier: str
    source_url: str
    requested_ref: str
    resolved_version: str | None
    resolved_commit: str | None
    license: str
    qualification_state: str


@dataclass(frozen=True, slots=True)
class PortBinding:
    identifier: str
    host_scope: str
    protocol: str
    bind: str
    port: int
    owner: str


@dataclass(frozen=True, slots=True)
class ProcessOwnership:
    identifier: str
    parent: str | None
    shutdown_order: int


@dataclass(frozen=True, slots=True)
class S0Profile:
    profile_id: str
    target_host: TargetHost
    storage: StoragePolicy
    clock: ClockPolicy
    authority: AuthorityPolicy
    ros: RosProfile
    runtime_acceptance: RuntimeAcceptance
    components: tuple[ComponentPin, ...]
    ports: tuple[PortBinding, ...]
    processes: tuple[ProcessOwnership, ...]
    required_tools: tuple[str, ...]
    required_evidence: tuple[str, ...]


@dataclass(frozen=True, slots=True)
class DoctorCheck:
    identifier: str
    status: CheckStatus
    detail: str

    def to_dict(self) -> dict[str, object]:
        return {
            "id": self.identifier,
            "status": self.status.value,
            "detail": self.detail,
        }


@dataclass(frozen=True, slots=True)
class S0DoctorReport:
    profile_id: str
    profile_sha256: str
    verdict: DoctorVerdict
    target_host_match: bool
    checks: tuple[DoctorCheck, ...]

    def to_dict(self) -> dict[str, object]:
        return {
            "schema_version": DOCTOR_SCHEMA,
            "profile_id": self.profile_id,
            "profile_sha256": self.profile_sha256,
            "verdict": self.verdict.value,
            "target_host_match": self.target_host_match,
            "authority": {
                "simulation_or_shadow_only": True,
                "actuator_authority": False,
                "navigation_or_safety_accepted": False,
                "direct_actuator_setpoints_allowed": False,
            },
            "host": {
                "system": platform.system(),
                "release": platform.release(),
                "machine": platform.machine(),
            },
            "checks": [check.to_dict() for check in self.checks],
        }


def load_s0_profile(path: Path) -> S0Profile:
    """Load and fail closed on an unsafe or incomplete S0 profile."""

    document = _read_yaml_mapping(path)
    _expect_keys(
        document,
        required={
            "schema_version",
            "profile_id",
            "target_host",
            "storage",
            "clock",
            "authority",
            "ros",
            "runtime_acceptance",
            "components",
            "ports",
            "processes",
            "required_tools",
            "required_evidence",
        },
        context="profile",
    )
    if _string(document["schema_version"], "profile.schema_version") != PROFILE_SCHEMA:
        raise S0ProfileError("profile schema_version is incompatible")
    profile_id = _safe_id(document["profile_id"], "profile.profile_id")

    target_host = _parse_target_host(document["target_host"])
    storage = _parse_storage(document["storage"])
    clock = _parse_clock(document["clock"])
    authority = _parse_authority(document["authority"])
    ros = _parse_ros(document["ros"])
    runtime_acceptance = _parse_runtime_acceptance(document["runtime_acceptance"])
    components = _parse_components(document["components"])
    ports = _parse_ports(document["ports"])
    processes = _parse_processes(document["processes"])
    required_tools = _tool_list(document["required_tools"], "profile.required_tools")
    required_evidence = _safe_id_list(
        document["required_evidence"],
        "profile.required_evidence",
    )

    _validate_port_owners(ports, processes)
    _validate_required_processes(processes)
    if len(required_evidence) < 1:
        raise S0ProfileError("at least one factual S0 evidence requirement is mandatory")

    return S0Profile(
        profile_id=profile_id,
        target_host=target_host,
        storage=storage,
        clock=clock,
        authority=authority,
        ros=ros,
        runtime_acceptance=runtime_acceptance,
        components=components,
        ports=ports,
        processes=processes,
        required_tools=required_tools,
        required_evidence=required_evidence,
    )


def run_s0_doctor(
    profile_path: Path,
    *,
    evidence_path: Path | None = None,
) -> S0DoctorReport:
    """Inspect the local host without installing, starting, or mutating anything."""

    profile_bytes = _read_bounded_file(profile_path)
    profile = load_s0_profile(profile_path)
    profile_sha256 = hashlib.sha256(profile_bytes).hexdigest()
    target_match, target_detail = _target_host_match(profile.target_host)

    checks: list[DoctorCheck] = [
        DoctorCheck("profile-contract", CheckStatus.PASS, "strict profile validation passed"),
        DoctorCheck(
            "authority-contract",
            CheckStatus.PASS,
            "simulation/shadow only; real and direct actuator authority disabled",
        ),
        DoctorCheck(
            "clock-contract",
            CheckStatus.PASS,
            "Gazebo /clock, ROS use_sim_time and disabled PX4 DDS time sync are explicit",
        ),
        DoctorCheck(
            "port-registry",
            CheckStatus.PASS,
            f"{len(profile.ports)} unique scoped bindings validated",
        ),
        DoctorCheck(
            "process-registry",
            CheckStatus.PASS,
            f"{len(profile.processes)} owned processes have deterministic shutdown order",
        ),
        DoctorCheck(
            "target-host",
            CheckStatus.PASS if target_match else CheckStatus.UNKNOWN,
            target_detail,
        ),
    ]
    checks.extend(_storage_checks(profile.storage, target_match))
    checks.append(_component_check(profile.components))
    checks.append(_tool_check(profile.required_tools, target_match))
    checks.append(
        _evidence_check(
            profile,
            profile_sha256=profile_sha256,
            evidence_path=evidence_path,
        )
    )

    statuses = {check.status for check in checks}
    if CheckStatus.FAIL in statuses:
        verdict = DoctorVerdict.BLOCKED
    elif target_match and statuses == {CheckStatus.PASS}:
        verdict = DoctorVerdict.GO
    else:
        verdict = DoctorVerdict.INCOMPLETE
    return S0DoctorReport(
        profile_id=profile.profile_id,
        profile_sha256=profile_sha256,
        verdict=verdict,
        target_host_match=target_match,
        checks=tuple(checks),
    )


def _parse_target_host(value: object) -> TargetHost:
    data = _mapping(value, "target_host")
    _expect_keys(
        data,
        required={
            "windows_release",
            "virtualization",
            "wsl_distribution",
            "linux_distribution",
            "architecture",
            "container_runtime",
        },
        context="target_host",
    )
    target = TargetHost(
        windows_release=_string(data["windows_release"], "target_host.windows_release"),
        virtualization=_string(data["virtualization"], "target_host.virtualization"),
        wsl_distribution=_string(
            data["wsl_distribution"],
            "target_host.wsl_distribution",
        ),
        linux_distribution=_string(
            data["linux_distribution"],
            "target_host.linux_distribution",
        ),
        architecture=_string(data["architecture"], "target_host.architecture"),
        container_runtime=_string(
            data["container_runtime"],
            "target_host.container_runtime",
        ),
    )
    if target.windows_release != "windows-11" or target.virtualization != "wsl2":
        raise S0ProfileError("S0 target must remain the reviewed Windows 11 WSL2 worker")
    if target.wsl_distribution != "MissionCore-Sim" or target.container_runtime != "none":
        raise S0ProfileError("S0 must use the dedicated Docker-free MissionCore-Sim distro")
    if target.linux_distribution != "ubuntu-24.04" or target.architecture != "x86_64":
        raise S0ProfileError("S0 target Linux distribution or architecture drifted")
    return target


def _parse_storage(value: object) -> StoragePolicy:
    data = _mapping(value, "storage")
    _expect_keys(
        data,
        required={
            "windows_root",
            "wsl_root",
            "minimum_free_gib",
            "stop_below_gib",
            "forbid_windows_system_drive",
            "mutable_paths",
        },
        context="storage",
    )
    windows_root = PureWindowsPath(_string(data["windows_root"], "storage.windows_root"))
    wsl_root = PurePosixPath(_string(data["wsl_root"], "storage.wsl_root"))
    minimum_free_gib = _integer(data["minimum_free_gib"], "storage.minimum_free_gib")
    stop_below_gib = _integer(data["stop_below_gib"], "storage.stop_below_gib")
    forbid_system = _boolean(
        data["forbid_windows_system_drive"],
        "storage.forbid_windows_system_drive",
    )
    if windows_root != PureWindowsPath(r"D:\NDC_MISSIONCORE"):
        raise S0ProfileError("storage.windows_root must be D:\\NDC_MISSIONCORE")
    if wsl_root != PurePosixPath("/mnt/d/NDC_MISSIONCORE"):
        raise S0ProfileError("storage.wsl_root must be /mnt/d/NDC_MISSIONCORE")
    if not forbid_system:
        raise S0ProfileError("Windows system-drive mutation must remain forbidden")
    if minimum_free_gib <= stop_below_gib or stop_below_gib < 1:
        raise S0ProfileError("storage free-space thresholds are inconsistent")

    mutable: list[MutablePath] = []
    seen: set[str] = set()
    for index, item in enumerate(_sequence(data["mutable_paths"], "storage.mutable_paths")):
        entry = _mapping(item, f"storage.mutable_paths[{index}]")
        _expect_keys(
            entry,
            required={"id", "windows_path", "wsl_path"},
            context=f"storage.mutable_paths[{index}]",
        )
        identifier = _safe_id(entry["id"], f"storage.mutable_paths[{index}].id")
        if identifier in seen:
            raise S0ProfileError("storage mutable path ids must be unique")
        seen.add(identifier)
        windows_path = PureWindowsPath(
            _string(
                entry["windows_path"],
                f"storage.mutable_paths[{index}].windows_path",
            )
        )
        wsl_path = PurePosixPath(
            _string(entry["wsl_path"], f"storage.mutable_paths[{index}].wsl_path")
        )
        if (
            not windows_path.is_absolute()
            or windows_path == windows_root
            or not windows_path.is_relative_to(windows_root)
            or windows_path.drive.upper() != "D:"
            or ".." in windows_path.parts
        ):
            raise S0ProfileError(f"mutable Windows path {identifier!r} escapes D-only root")
        if (
            not wsl_path.is_absolute()
            or wsl_path == wsl_root
            or not wsl_path.is_relative_to(wsl_root)
            or ".." in wsl_path.parts
        ):
            raise S0ProfileError(f"mutable WSL path {identifier!r} escapes D-only root")
        mutable.append(MutablePath(identifier, windows_path, wsl_path))
    if len(mutable) < 1:
        raise S0ProfileError("storage.mutable_paths must not be empty")
    return StoragePolicy(
        windows_root=windows_root,
        wsl_root=wsl_root,
        minimum_free_gib=minimum_free_gib,
        stop_below_gib=stop_below_gib,
        forbid_windows_system_drive=forbid_system,
        mutable_paths=tuple(mutable),
    )


def _parse_clock(value: object) -> ClockPolicy:
    data = _mapping(value, "clock")
    _expect_keys(
        data,
        required={
            "authority",
            "ros_use_sim_time",
            "px4_uxrce_dds_sync_enabled",
            "canonical_unit",
            "utc_role",
        },
        context="clock",
    )
    clock = ClockPolicy(
        authority=_string(data["authority"], "clock.authority"),
        ros_use_sim_time=_boolean(data["ros_use_sim_time"], "clock.ros_use_sim_time"),
        px4_uxrce_dds_sync_enabled=_boolean(
            data["px4_uxrce_dds_sync_enabled"],
            "clock.px4_uxrce_dds_sync_enabled",
        ),
        canonical_unit=_string(data["canonical_unit"], "clock.canonical_unit"),
        utc_role=_string(data["utc_role"], "clock.utc_role"),
    )
    if (
        clock.authority != "gazebo:/clock"
        or not clock.ros_use_sim_time
        or clock.px4_uxrce_dds_sync_enabled
        or clock.canonical_unit != "nanoseconds"
        or clock.utc_role != "provenance-only"
    ):
        raise S0ProfileError("clock policy violates the canonical simulation-time contract")
    return clock


def _parse_authority(value: object) -> AuthorityPolicy:
    data = _mapping(value, "authority")
    _expect_keys(
        data,
        required={
            "simulation_or_shadow_only",
            "actuator_authority",
            "navigation_or_safety_accepted",
            "direct_actuator_setpoints_allowed",
            "allowed_setpoint_profiles",
        },
        context="authority",
    )
    authority = AuthorityPolicy(
        simulation_or_shadow_only=_boolean(
            data["simulation_or_shadow_only"],
            "authority.simulation_or_shadow_only",
        ),
        actuator_authority=_boolean(
            data["actuator_authority"],
            "authority.actuator_authority",
        ),
        navigation_or_safety_accepted=_boolean(
            data["navigation_or_safety_accepted"],
            "authority.navigation_or_safety_accepted",
        ),
        direct_actuator_setpoints_allowed=_boolean(
            data["direct_actuator_setpoints_allowed"],
            "authority.direct_actuator_setpoints_allowed",
        ),
        allowed_setpoint_profiles=tuple(
            _string_list(
                data["allowed_setpoint_profiles"],
                "authority.allowed_setpoint_profiles",
            )
        ),
    )
    if (
        not authority.simulation_or_shadow_only
        or authority.actuator_authority
        or authority.navigation_or_safety_accepted
        or authority.direct_actuator_setpoints_allowed
    ):
        raise S0ProfileError("SIM S0 cannot grant real, safety, or direct actuator authority")
    if set(authority.allowed_setpoint_profiles) != {
        "rover-speed-steering/v1",
        "rover-speed-yaw-rate/v1",
    }:
        raise S0ProfileError("SIM S0 setpoint profiles drifted from the reviewed rover boundary")
    return authority


def _parse_ros(value: object) -> RosProfile:
    data = _mapping(value, "ros")
    _expect_keys(data, required={"domain_id", "namespace"}, context="ros")
    domain_id = _integer(data["domain_id"], "ros.domain_id")
    namespace = _string(data["namespace"], "ros.namespace")
    if not 0 <= domain_id <= 232:
        raise S0ProfileError("ROS domain id must be in the DDS-safe range 0..232")
    if not namespace.startswith("/") or "//" in namespace or namespace.endswith("/"):
        raise S0ProfileError("ROS namespace must be canonical and absolute")
    return RosProfile(domain_id, namespace)


def _parse_runtime_acceptance(value: object) -> RuntimeAcceptance:
    data = _mapping(value, "runtime_acceptance")
    _expect_keys(
        data,
        required={
            "world",
            "model",
            "physics_step_ns",
            "pause_max_advance_ns",
            "speed_factors",
            "rtf_tolerance_percent",
            "measurement_window_seconds",
            "resource_sample_interval_milliseconds",
            "max_owned_rss_mib",
            "max_owned_cpu_percent",
            "minimum_available_memory_gib",
        },
        context="runtime_acceptance",
    )
    speed_factors = tuple(
        _integer(item, f"runtime_acceptance.speed_factors[{index}]")
        for index, item in enumerate(
            _sequence(
                data["speed_factors"],
                "runtime_acceptance.speed_factors",
            )
        )
    )
    acceptance = RuntimeAcceptance(
        world=_safe_id(data["world"], "runtime_acceptance.world"),
        model=_string(data["model"], "runtime_acceptance.model"),
        physics_step_ns=_integer(
            data["physics_step_ns"],
            "runtime_acceptance.physics_step_ns",
        ),
        pause_max_advance_ns=_integer(
            data["pause_max_advance_ns"],
            "runtime_acceptance.pause_max_advance_ns",
        ),
        speed_factors=speed_factors,
        rtf_tolerance_percent=_integer(
            data["rtf_tolerance_percent"],
            "runtime_acceptance.rtf_tolerance_percent",
        ),
        measurement_window_seconds=_integer(
            data["measurement_window_seconds"],
            "runtime_acceptance.measurement_window_seconds",
        ),
        resource_sample_interval_milliseconds=_integer(
            data["resource_sample_interval_milliseconds"],
            "runtime_acceptance.resource_sample_interval_milliseconds",
        ),
        max_owned_rss_mib=_integer(
            data["max_owned_rss_mib"],
            "runtime_acceptance.max_owned_rss_mib",
        ),
        max_owned_cpu_percent=_integer(
            data["max_owned_cpu_percent"],
            "runtime_acceptance.max_owned_cpu_percent",
        ),
        minimum_available_memory_gib=_integer(
            data["minimum_available_memory_gib"],
            "runtime_acceptance.minimum_available_memory_gib",
        ),
    )
    if acceptance.world != "rover" or acceptance.model != "rover_ackermann_0":
        raise S0ProfileError("S0 runtime acceptance must remain on the stock Ackermann rover")
    if acceptance.physics_step_ns != 2_000_000:
        raise S0ProfileError("S0 physics step must match the pinned rover world")
    if acceptance.pause_max_advance_ns != 0:
        raise S0ProfileError("S0 pause acceptance must require a frozen simulation clock")
    if acceptance.speed_factors != (1, 2):
        raise S0ProfileError("S0 must qualify exactly the 1x and 2x speed factors")
    if not 1 <= acceptance.rtf_tolerance_percent <= 30:
        raise S0ProfileError("S0 RTF tolerance must be within 1..30 percent")
    if not 3 <= acceptance.measurement_window_seconds <= 30:
        raise S0ProfileError("S0 RTF measurement window must be within 3..30 seconds")
    if not 100 <= acceptance.resource_sample_interval_milliseconds <= 2_000:
        raise S0ProfileError("S0 resource sample interval must be within 100..2000 ms")
    if not 256 <= acceptance.max_owned_rss_mib <= 8_192:
        raise S0ProfileError("S0 owned RSS budget must be within 256..8192 MiB")
    if not 100 <= acceptance.max_owned_cpu_percent <= 3_200:
        raise S0ProfileError("S0 owned CPU budget must be within 100..3200 percent")
    if not 1 <= acceptance.minimum_available_memory_gib <= 64:
        raise S0ProfileError("S0 available-memory floor must be within 1..64 GiB")
    return acceptance


def _parse_components(value: object) -> tuple[ComponentPin, ...]:
    items: list[ComponentPin] = []
    seen: set[str] = set()
    for index, item in enumerate(_sequence(value, "components")):
        data = _mapping(item, f"components[{index}]")
        _expect_keys(
            data,
            required={
                "id",
                "source_url",
                "requested_ref",
                "resolved_version",
                "resolved_commit",
                "license",
                "qualification_state",
            },
            context=f"components[{index}]",
        )
        identifier = _safe_id(data["id"], f"components[{index}].id")
        if identifier in seen:
            raise S0ProfileError("component ids must be unique")
        seen.add(identifier)
        resolved_commit = _optional_string(
            data["resolved_commit"],
            f"components[{index}].resolved_commit",
        )
        if resolved_commit is not None and _GIT_COMMIT.fullmatch(resolved_commit) is None:
            raise S0ProfileError(f"component {identifier!r} has an invalid Git commit")
        state = _string(data["qualification_state"], f"components[{index}].qualification_state")
        if state not in {"candidate", "pending-worker-resolution", "accepted"}:
            raise S0ProfileError(f"component {identifier!r} has an invalid qualification state")
        items.append(
            ComponentPin(
                identifier=identifier,
                source_url=_https_url(data["source_url"], f"components[{index}].source_url"),
                requested_ref=_string(
                    data["requested_ref"],
                    f"components[{index}].requested_ref",
                ),
                resolved_version=_optional_string(
                    data["resolved_version"],
                    f"components[{index}].resolved_version",
                ),
                resolved_commit=resolved_commit,
                license=_string(data["license"], f"components[{index}].license"),
                qualification_state=state,
            )
        )
    if len(items) < 1:
        raise S0ProfileError("components must not be empty")
    return tuple(items)


def _parse_ports(value: object) -> tuple[PortBinding, ...]:
    items: list[PortBinding] = []
    identities: set[str] = set()
    bindings: set[tuple[str, str, str, int]] = set()
    for index, item in enumerate(_sequence(value, "ports")):
        data = _mapping(item, f"ports[{index}]")
        _expect_keys(
            data,
            required={"id", "host_scope", "protocol", "bind", "port", "owner"},
            context=f"ports[{index}]",
        )
        identifier = _safe_id(data["id"], f"ports[{index}].id")
        if identifier in identities:
            raise S0ProfileError("port ids must be unique")
        identities.add(identifier)
        host_scope = _safe_id(data["host_scope"], f"ports[{index}].host_scope")
        protocol = _string(data["protocol"], f"ports[{index}].protocol")
        bind = _string(data["bind"], f"ports[{index}].bind")
        port = _integer(data["port"], f"ports[{index}].port")
        owner = _safe_id(data["owner"], f"ports[{index}].owner")
        if protocol not in {"tcp", "udp"}:
            raise S0ProfileError(f"port {identifier!r} has unsupported protocol")
        if bind == "0.0.0.0":
            if host_scope != "loopback-only-netns":
                raise S0ProfileError(
                    f"port {identifier!r} wildcard bind requires loopback-only-netns scope"
                )
        elif bind != "127.0.0.1":
            raise S0ProfileError(f"port {identifier!r} must remain loopback-only in S0")
        if not 1 <= port <= 65535:
            raise S0ProfileError(f"port {identifier!r} is outside 1..65535")
        binding = (host_scope, protocol, bind, port)
        if binding in bindings:
            raise S0ProfileError(f"port {identifier!r} duplicates a scoped binding")
        bindings.add(binding)
        items.append(PortBinding(identifier, host_scope, protocol, bind, port, owner))
    if len(items) < 1:
        raise S0ProfileError("ports must not be empty")
    return tuple(items)


def _parse_processes(value: object) -> tuple[ProcessOwnership, ...]:
    items: list[ProcessOwnership] = []
    identities: set[str] = set()
    orders: set[int] = set()
    for index, item in enumerate(_sequence(value, "processes")):
        data = _mapping(item, f"processes[{index}]")
        _expect_keys(
            data,
            required={"id", "parent", "shutdown_order"},
            context=f"processes[{index}]",
        )
        identifier = _safe_id(data["id"], f"processes[{index}].id")
        parent = _optional_safe_id(data["parent"], f"processes[{index}].parent")
        shutdown_order = _integer(
            data["shutdown_order"],
            f"processes[{index}].shutdown_order",
        )
        if identifier in identities or shutdown_order in orders:
            raise S0ProfileError("process ids and shutdown_order values must be unique")
        identities.add(identifier)
        orders.add(shutdown_order)
        items.append(ProcessOwnership(identifier, parent, shutdown_order))
    roots = [item for item in items if item.parent is None]
    if len(roots) != 1 or roots[0].identifier != "simulation-orchestrator":
        raise S0ProfileError("simulation-orchestrator must be the only process root")
    process_ids = {item.identifier for item in items}
    for item in items:
        if item.parent is not None and item.parent not in process_ids:
            raise S0ProfileError(f"process {item.identifier!r} has an unknown parent")
    root_order = roots[0].shutdown_order
    if any(item.shutdown_order >= root_order for item in items if item.parent is not None):
        raise S0ProfileError("the orchestrator must be the final process in shutdown order")
    return tuple(items)


def _validate_port_owners(
    ports: tuple[PortBinding, ...],
    processes: tuple[ProcessOwnership, ...],
) -> None:
    owners = {process.identifier for process in processes}
    for binding in ports:
        if binding.owner not in owners:
            raise S0ProfileError(f"port {binding.identifier!r} has an unowned process")


def _validate_required_processes(processes: tuple[ProcessOwnership, ...]) -> None:
    present = {process.identifier for process in processes}
    required = {
        "simulation-orchestrator",
        "gazebo-server",
        "px4-sitl",
        "micro-xrce-dds-agent",
        "ros2-bridge",
        "nav2",
    }
    if present != required:
        raise S0ProfileError("process registry must enumerate the complete reviewed S0 graph")


def _target_host_match(target: TargetHost) -> tuple[bool, str]:
    system = platform.system().lower()
    machine = platform.machine().lower()
    release = _read_optional_text(Path("/proc/sys/kernel/osrelease")).lower()
    os_release = _linux_os_release()
    is_wsl2 = system == "linux" and "microsoft" in release and "wsl2" in release
    distribution_match = (
        os_release.get("id") == "ubuntu" and os_release.get("version_id") == "24.04"
    )
    architecture_match = machine in {"x86_64", "amd64"}
    distro_name_match = os.environ.get("WSL_DISTRO_NAME") == target.wsl_distribution
    matched = is_wsl2 and distribution_match and architecture_match and distro_name_match
    if matched:
        return True, (
            f"matched {target.windows_release}/{target.virtualization}/"
            f"{target.wsl_distribution}/{target.linux_distribution}/{target.architecture}"
        )
    return False, (
        "doctor is not running inside the reviewed Windows 11 WSL2 Ubuntu 24.04 "
        f"x86_64 MissionCore-Sim target (observed system={system!r}, machine={machine!r}, "
        f"wsl_distribution={os.environ.get('WSL_DISTRO_NAME')!r})"
    )


def _storage_checks(policy: StoragePolicy, target_match: bool) -> list[DoctorCheck]:
    if not target_match:
        return [
            DoctorCheck(
                "d-only-storage",
                CheckStatus.UNKNOWN,
                "physical D-drive placement can only be attested on the target worker",
            ),
            DoctorCheck(
                "disk-budget",
                CheckStatus.UNKNOWN,
                "target D-drive free space was not inspected on this host",
            ),
        ]
    root = Path(policy.wsl_root.as_posix())
    if not root.is_dir():
        return [
            DoctorCheck(
                "d-only-storage",
                CheckStatus.FAIL,
                f"required WSL D-drive root is absent: {root}",
            ),
            DoctorCheck(
                "disk-budget",
                CheckStatus.FAIL,
                "disk budget cannot be measured without the D-drive root",
            ),
        ]
    missing = [
        item.identifier
        for item in policy.mutable_paths
        if not Path(item.wsl_path.as_posix()).is_dir()
    ]
    storage_check = DoctorCheck(
        "d-only-storage",
        CheckStatus.PASS if not missing else CheckStatus.FAIL,
        (
            f"all {len(policy.mutable_paths)} mutable roots exist under {root}"
            if not missing
            else f"missing D-only mutable roots: {', '.join(missing)}"
        ),
    )
    free_gib = shutil.disk_usage(root).free / (1024**3)
    disk_check = DoctorCheck(
        "disk-budget",
        CheckStatus.PASS if free_gib >= policy.minimum_free_gib else CheckStatus.FAIL,
        (
            f"{free_gib:.2f} GiB free; minimum={policy.minimum_free_gib} GiB; "
            f"stop-below={policy.stop_below_gib} GiB"
        ),
    )
    return [storage_check, disk_check]


def _component_check(components: tuple[ComponentPin, ...]) -> DoctorCheck:
    unresolved = [
        item.identifier
        for item in components
        if item.qualification_state != "accepted"
        or (item.resolved_version is None and item.resolved_commit is None)
    ]
    if unresolved:
        return DoctorCheck(
            "version-lock",
            CheckStatus.UNKNOWN,
            "worker qualification still required for: " + ", ".join(unresolved),
        )
    return DoctorCheck(
        "version-lock",
        CheckStatus.PASS,
        f"all {len(components)} component pins are resolved and accepted",
    )


def _tool_check(required_tools: tuple[str, ...], target_match: bool) -> DoctorCheck:
    if not target_match:
        return DoctorCheck(
            "required-tools",
            CheckStatus.UNKNOWN,
            "target worker executables were not inspected on this host",
        )
    missing = [name for name in required_tools if shutil.which(name) is None]
    if missing:
        return DoctorCheck(
            "required-tools",
            CheckStatus.FAIL,
            "missing target executables: " + ", ".join(missing),
        )
    return DoctorCheck(
        "required-tools",
        CheckStatus.PASS,
        f"all {len(required_tools)} target executables are discoverable",
    )


def _evidence_check(
    profile: S0Profile,
    *,
    profile_sha256: str,
    evidence_path: Path | None,
) -> DoctorCheck:
    if evidence_path is None:
        return DoctorCheck(
            "factual-evidence",
            CheckStatus.UNKNOWN,
            f"{len(profile.required_evidence)} target-run evidence claims are required",
        )
    try:
        data = _read_yaml_mapping(evidence_path)
        _expect_keys(
            data,
            required={"schema_version", "profile_sha256", "checks"},
            context="evidence",
        )
        if _string(data["schema_version"], "evidence.schema_version") != EVIDENCE_SCHEMA:
            raise S0ProfileError("evidence schema_version is incompatible")
        if _string(data["profile_sha256"], "evidence.profile_sha256") != profile_sha256:
            raise S0ProfileError("evidence targets a different S0 profile generation")
        evidence_root = evidence_path.expanduser().resolve(strict=True).parent
        observed: set[str] = set()
        for index, item in enumerate(_sequence(data["checks"], "evidence.checks")):
            entry = _mapping(item, f"evidence.checks[{index}]")
            _expect_keys(
                entry,
                required={"id", "status", "artifact", "sha256", "note"},
                context=f"evidence.checks[{index}]",
            )
            identifier = _safe_id(entry["id"], f"evidence.checks[{index}].id")
            if identifier in observed:
                raise S0ProfileError("evidence check ids must be unique")
            observed.add(identifier)
            if _string(entry["status"], f"evidence.checks[{index}].status") != "pass":
                raise S0ProfileError(f"evidence check {identifier!r} is not a pass")
            artifact_text = _string(
                entry["artifact"],
                f"evidence.checks[{index}].artifact",
            )
            artifact_relative = PurePosixPath(artifact_text)
            if artifact_relative.is_absolute() or ".." in artifact_relative.parts:
                raise S0ProfileError(f"evidence artifact {identifier!r} is not confined")
            artifact_candidate = evidence_root / Path(*artifact_relative.parts)
            if artifact_candidate.is_symlink():
                raise S0ProfileError(f"evidence artifact {identifier!r} cannot be a symlink")
            artifact = artifact_candidate.resolve(strict=True)
            if not artifact.is_relative_to(evidence_root) or not artifact.is_file():
                raise S0ProfileError(f"evidence artifact {identifier!r} is not a regular file")
            expected_sha256 = _string(
                entry["sha256"],
                f"evidence.checks[{index}].sha256",
            )
            if (
                _SHA256.fullmatch(expected_sha256) is None
                or _sha256_file(artifact) != expected_sha256
            ):
                raise S0ProfileError(f"evidence artifact {identifier!r} digest changed")
            _string(entry["note"], f"evidence.checks[{index}].note")
        if observed != set(profile.required_evidence):
            missing = sorted(set(profile.required_evidence) - observed)
            extra = sorted(observed - set(profile.required_evidence))
            raise S0ProfileError(
                f"evidence coverage mismatch; missing={missing!r}; extra={extra!r}"
            )
    except (OSError, S0ProfileError) as exc:
        return DoctorCheck("factual-evidence", CheckStatus.FAIL, str(exc))
    return DoctorCheck(
        "factual-evidence",
        CheckStatus.PASS,
        f"all {len(profile.required_evidence)} digest-bound evidence claims validated",
    )


def _read_yaml_mapping(path: Path) -> dict[str, object]:
    payload = _read_bounded_file(path)
    try:
        loaded: object = yaml.safe_load(payload)
    except yaml.YAMLError as exc:
        raise S0ProfileError(f"invalid YAML in {path}") from exc
    return _mapping(loaded, str(path))


def _read_bounded_file(path: Path) -> bytes:
    candidate = path.expanduser()
    if candidate.is_symlink():
        raise S0ProfileError(f"manifest cannot be a symlink: {path}")
    resolved = candidate.resolve(strict=True)
    if not resolved.is_file():
        raise S0ProfileError(f"manifest is not a regular file: {path}")
    size = resolved.stat().st_size
    if size < 1 or size > MAX_MANIFEST_BYTES:
        raise S0ProfileError(f"manifest size is outside 1..{MAX_MANIFEST_BYTES} bytes")
    return resolved.read_bytes()


def _mapping(value: object, context: str) -> dict[str, object]:
    if not isinstance(value, dict):
        raise S0ProfileError(f"{context} must be a mapping")
    result: dict[str, object] = {}
    for key, item in value.items():
        if not isinstance(key, str):
            raise S0ProfileError(f"{context} keys must be strings")
        result[key] = item
    return result


def _sequence(value: object, context: str) -> list[object]:
    if not isinstance(value, list):
        raise S0ProfileError(f"{context} must be a list")
    return list(value)


def _expect_keys(
    data: dict[str, object],
    *,
    required: set[str],
    context: str,
) -> None:
    present = set(data)
    if present != required:
        raise S0ProfileError(
            f"{context} keys mismatch; missing={sorted(required - present)!r}; "
            f"extra={sorted(present - required)!r}"
        )


def _string(value: object, context: str) -> str:
    if not isinstance(value, str) or not value.strip() or value != value.strip():
        raise S0ProfileError(f"{context} must be a non-empty trimmed string")
    return value


def _optional_string(value: object, context: str) -> str | None:
    if value is None:
        return None
    return _string(value, context)


def _boolean(value: object, context: str) -> bool:
    if not isinstance(value, bool):
        raise S0ProfileError(f"{context} must be a boolean")
    return value


def _integer(value: object, context: str) -> int:
    if not isinstance(value, int) or isinstance(value, bool):
        raise S0ProfileError(f"{context} must be an integer")
    return value


def _safe_id(value: object, context: str) -> str:
    text = _string(value, context)
    if _SAFE_ID.fullmatch(text) is None:
        raise S0ProfileError(f"{context} is not a safe id")
    return text


def _optional_safe_id(value: object, context: str) -> str | None:
    if value is None:
        return None
    return _safe_id(value, context)


def _safe_id_list(value: object, context: str) -> tuple[str, ...]:
    result = tuple(
        _safe_id(item, f"{context}[{index}]")
        for index, item in enumerate(_sequence(value, context))
    )
    if len(set(result)) != len(result):
        raise S0ProfileError(f"{context} values must be unique")
    return result


def _tool_list(value: object, context: str) -> tuple[str, ...]:
    result: list[str] = []
    for index, item in enumerate(_sequence(value, context)):
        tool = _string(item, f"{context}[{index}]")
        if _SAFE_TOOL.fullmatch(tool) is None:
            raise S0ProfileError(f"{context}[{index}] is not a safe executable name")
        result.append(tool)
    if len(set(result)) != len(result):
        raise S0ProfileError(f"{context} values must be unique")
    return tuple(result)


def _string_list(value: object, context: str) -> tuple[str, ...]:
    result = tuple(
        _string(item, f"{context}[{index}]") for index, item in enumerate(_sequence(value, context))
    )
    if len(set(result)) != len(result):
        raise S0ProfileError(f"{context} values must be unique")
    return result


def _https_url(value: object, context: str) -> str:
    text = _string(value, context)
    if not text.startswith("https://"):
        raise S0ProfileError(f"{context} must use HTTPS")
    return text


def _read_optional_text(path: Path) -> str:
    try:
        return path.read_text(encoding="utf-8")
    except OSError:
        return ""


def _linux_os_release() -> dict[str, str]:
    result: dict[str, str] = {}
    for line in _read_optional_text(Path("/etc/os-release")).splitlines():
        key, separator, raw_value = line.partition("=")
        if not separator:
            continue
        result[key.lower()] = raw_value.strip().strip('"')
    return result


def _sha256_file(path: Path) -> str:
    digest = hashlib.sha256()
    with path.open("rb") as stream:
        while chunk := stream.read(1024 * 1024):
            digest.update(chunk)
    return digest.hexdigest()
