"""Compile strict YAML configuration into executable pipeline runtimes."""

from __future__ import annotations

from collections.abc import Mapping
from dataclasses import dataclass
from typing import Any

from cmvr_edge_ai.config import AppConfig, PipelineConfig
from cmvr_edge_ai.core import Component, Edge, Operator, OverflowPolicy, Sink, Source
from cmvr_edge_ai.core.runtime import PipelineRuntime
from cmvr_edge_ai.plugins import (
    PluginKind,
    PluginRegistry,
    PluginSpec,
    schemas_compatible,
)


SUPPORTED_API_VERSION = "cmvr.edge.ai/v1"


class PipelineCompileError(ValueError):
    """Raised when a valid YAML document cannot form an executable graph."""


@dataclass(frozen=True, slots=True)
class CompiledPipeline:
    pipeline_id: str
    runtime: PipelineRuntime
    plugin_specs: Mapping[str, PluginSpec]


def compile_pipeline(
    config: AppConfig,
    pipeline_id: str,
    registry: PluginRegistry,
    *,
    metadata: Mapping[str, Any] | None = None,
) -> CompiledPipeline:
    if config.api_version != SUPPORTED_API_VERSION:
        raise PipelineCompileError(
            f"unsupported api_version {config.api_version!r}; "
            f"expected {SUPPORTED_API_VERSION!r}"
        )
    try:
        pipeline = config.pipelines[pipeline_id]
    except KeyError as exc:
        raise PipelineCompileError(f"unknown pipeline: {pipeline_id}") from exc
    if not pipeline.enabled:
        raise PipelineCompileError(f"pipeline is disabled: {pipeline_id}")

    _validate_supported_runtime_config(config)
    _validate_supported_pipeline_config(pipeline_id, pipeline)

    components: dict[str, Component] = {}
    specs: dict[str, PluginSpec] = {}
    for node_id, node_config in pipeline.nodes.items():
        if not node_config.enabled:
            raise PipelineCompileError(
                f"active pipeline {pipeline_id!r} contains disabled node {node_id!r}; "
                "disable the whole pipeline or remove its edges"
            )
        _validate_supported_node_config(node_id, node_config)
        try:
            spec = registry.resolve(node_config.uses)
            component = registry.create(
                node_config.uses, node_id=node_id, params=node_config.params
            )
        except Exception as exc:
            raise PipelineCompileError(
                f"failed to construct node {node_id!r} from {node_config.uses!r}: {exc}"
            ) from exc
        _validate_component_kind(node_id, component, spec)
        _validate_endpoint_reference(config, node_id, node_config.params, spec)
        components[node_id] = component
        specs[node_id] = spec

    runtime_edges: list[Edge] = []
    for index, edge_config in enumerate(pipeline.edges):
        source_node, source_port = edge_config.source.split(".", maxsplit=1)
        target_node, target_port = edge_config.target.split(".", maxsplit=1)
        source_spec = specs[source_node]
        target_spec = specs[target_node]

        source_schema = _port_schema(
            source_spec.outputs, source_port, edge_config.source, "output"
        )
        target_schema = _port_schema(
            target_spec.inputs, target_port, edge_config.target, "input"
        )
        if not schemas_compatible(source_schema, target_schema):
            raise PipelineCompileError(
                f"schema mismatch on edge {edge_config.source!r} -> "
                f"{edge_config.target!r}: {source_schema!r} is not compatible "
                f"with {target_schema!r}"
            )

        if edge_config.qos.max_age_ms is not None:
            raise PipelineCompileError(
                f"edge {edge_config.source!r} -> {edge_config.target!r} sets "
                "max_age_ms, which is reserved for the deadline-aware runtime"
            )
        if edge_config.qos.put_timeout_ms is not None:
            raise PipelineCompileError(
                f"edge {edge_config.source!r} -> {edge_config.target!r} sets "
                "put_timeout_ms, which is not implemented in runtime v1"
            )
        _validate_qos_profile(edge_config.source, edge_config.target, edge_config.qos)

        overflow = edge_config.qos.overflow
        if overflow == "error":
            overflow = OverflowPolicy.REJECT.value
        runtime_edges.append(
            Edge(
                source_node=source_node,
                source_port=source_port,
                target_node=target_node,
                target_port=target_port,
                capacity=edge_config.qos.capacity,
                overflow=overflow,
                name=f"{pipeline_id}:{index}:{edge_config.source}->{edge_config.target}",
            )
        )

    _validate_detection_rule_paths(pipeline, specs, components)
    _validate_actuator_paths(pipeline, specs)
    runtime_metadata = dict(metadata or {})
    runtime_metadata.setdefault("endpoints", config.endpoints)
    runtime_metadata.setdefault("node_configs", pipeline.nodes)
    runtime = PipelineRuntime(
        components,
        runtime_edges,
        pipeline_id=pipeline_id,
        shutdown_timeout_s=config.runtime.shutdown_timeout_s,
        metadata=runtime_metadata,
    )
    return CompiledPipeline(pipeline_id, runtime, specs)


def enabled_pipeline_ids(config: AppConfig) -> tuple[str, ...]:
    return tuple(
        pipeline_id
        for pipeline_id, pipeline in config.pipelines.items()
        if pipeline.enabled
    )


def _validate_component_kind(node_id: str, component: Any, spec: PluginSpec) -> None:
    expected_type: type[Component]
    if spec.kind is PluginKind.SOURCE:
        expected_type = Source
    elif spec.kind is PluginKind.OPERATOR:
        expected_type = Operator
    else:
        expected_type = Sink
    if not isinstance(component, expected_type):
        raise PipelineCompileError(
            f"plugin {spec.plugin_id!r} declares {spec.kind.value} but node "
            f"{node_id!r} created {type(component).__name__}"
        )


def _port_schema(
    ports: Mapping[str, str], port: str, reference: str, direction: str
) -> str:
    try:
        return ports[port]
    except KeyError as exc:
        available = ", ".join(sorted(ports)) or "<none>"
        raise PipelineCompileError(
            f"unknown {direction} port {reference!r}; available: {available}"
        ) from exc


def _validate_endpoint_reference(
    config: AppConfig,
    node_id: str,
    params: Mapping[str, Any],
    spec: PluginSpec,
) -> None:
    endpoint_id = params.get("endpoint")
    transport_tags = sorted(
        tag.removeprefix("transport:")
        for tag in spec.tags
        if tag.startswith("transport:")
    )
    if endpoint_id is None:
        if transport_tags:
            raise PipelineCompileError(
                f"connector node {node_id!r} requires a named endpoint"
            )
        return
    if not isinstance(endpoint_id, str) or endpoint_id not in config.endpoints:
        raise PipelineCompileError(
            f"node {node_id!r} references unknown endpoint {endpoint_id!r}"
        )
    endpoint = config.endpoints[endpoint_id]
    if transport_tags and endpoint.transport not in transport_tags:
        raise PipelineCompileError(
            f"node {node_id!r} requires transport {transport_tags}, but endpoint "
            f"{endpoint_id!r} uses {endpoint.transport!r}"
        )


def _validate_actuator_paths(
    pipeline: PipelineConfig, specs: Mapping[str, PluginSpec]
) -> None:
    """Require a trusted safety gate immediately before every actuator.

    Requiring only *some* earlier gate lets an arbitrary transform mutate the
    approved command after validation.  The immediate-boundary rule also makes
    the trust boundary visible in the graph.
    """

    predecessors: dict[str, set[str]] = {node_id: set() for node_id in pipeline.nodes}
    for edge in pipeline.edges:
        predecessors[edge.target_node].add(edge.source_node)

    unsafe = sorted(
        node_id
        for node_id, spec in specs.items()
        if "actuator" in spec.tags
        and (
            not predecessors[node_id]
            or any(
                "safety_gate" not in specs[parent].tags
                for parent in predecessors[node_id]
            )
        )
    )
    if unsafe:
        raise PipelineCompileError(
            "every actuator input must pass through a safety gate directly; "
            f"unsafe actuator node(s): {', '.join(unsafe)}"
        )


def _validate_supported_runtime_config(config: AppConfig) -> None:
    reserved = {
        "max_processes",
        "process_start_method",
        "health_bind",
        "reserved_memory_mb",
    } & config.runtime.model_fields_set
    if reserved:
        raise PipelineCompileError(
            "runtime field(s) are reserved but not enforced in v1; remove them: "
            + ", ".join(sorted(reserved))
        )


def _validate_supported_pipeline_config(
    pipeline_id: str, pipeline: PipelineConfig
) -> None:
    if pipeline.priority != "normal":
        raise PipelineCompileError(
            f"pipeline {pipeline_id!r} sets priority={pipeline.priority!r}, but "
            "runtime v1 has no priority scheduler"
        )


def _validate_supported_node_config(node_id: str, node: Any) -> None:
    execution = node.execution
    unsupported_execution: list[str] = []
    if execution.mode not in {"async", "inline"}:
        unsupported_execution.append(f"mode={execution.mode!r}")
    if execution.concurrency != 1:
        unsupported_execution.append(f"concurrency={execution.concurrency}")
    if execution.max_in_flight is not None:
        unsupported_execution.append(f"max_in_flight={execution.max_in_flight}")
    if execution.timeout_s is not None:
        unsupported_execution.append(f"timeout_s={execution.timeout_s}")
    if not execution.ordered:
        unsupported_execution.append("ordered=false")
    if unsupported_execution:
        raise PipelineCompileError(
            f"node {node_id!r} requests execution settings not implemented by "
            "runtime v1: " + ", ".join(unsupported_execution)
        )

    resources = node.resources
    declared_resources = [
        name
        for name, value in resources.model_dump().items()
        if (name == "exclusive_gpu" and value is True)
        or (name != "exclusive_gpu" and value is not None)
    ]
    if declared_resources:
        raise PipelineCompileError(
            f"node {node_id!r} declares resource limits that runtime v1 cannot "
            "enforce: " + ", ".join(declared_resources)
        )


def _validate_qos_profile(source: str, target: str, qos: Any) -> None:
    allowed_overflow = {
        "realtime_latest": {"drop_oldest", "drop_newest"},
        "audio_contiguous": {"block", "reject", "error"},
        # Inter-frame codecs cannot silently lose arbitrary packets before
        # decode.  Dropping is safe only after complete images are produced.
        "video_contiguous": {"block", "reject", "error"},
        "request": {"block", "reject", "error"},
        "telemetry": {"block", "drop_oldest", "drop_newest", "reject", "error"},
    }
    try:
        allowed = allowed_overflow[qos.profile]
    except KeyError as exc:
        raise PipelineCompileError(
            f"edge {source!r} -> {target!r} uses unknown qos profile "
            f"{qos.profile!r}; supported profiles: "
            + ", ".join(sorted(allowed_overflow))
        ) from exc
    if qos.overflow not in allowed:
        raise PipelineCompileError(
            f"edge {source!r} -> {target!r} profile {qos.profile!r} does not "
            f"allow overflow={qos.overflow!r}; allowed: {', '.join(sorted(allowed))}"
        )


def _validate_detection_rule_paths(
    pipeline: PipelineConfig,
    specs: Mapping[str, PluginSpec],
    components: Mapping[str, Component],
) -> None:
    """Reject rules that can never match their directly connected detector.

    The generic detector validates labels against model registration metadata.
    This edge-level check closes the remaining configuration gap between its
    selected output labels and a repeat gate's rules without coupling the core
    configuration schema to one model backend.
    """

    for edge in pipeline.edges:
        source_id = edge.source_node
        target_id = edge.target_node
        if specs[source_id].plugin_id != "detection.model@1":
            continue
        if specs[target_id].plugin_id != "detection.repeat_gate@1":
            continue
        selected = frozenset(
            str(label)
            for label in getattr(components[source_id], "selected_labels", ())
        )
        rules = getattr(components[target_id], "rules", ())
        if getattr(components[target_id], "alert_image_enabled", False) and not getattr(
            components[source_id], "attach_frame", False
        ):
            raise PipelineCompileError(
                f"detection rule node {target_id!r} enables alert_image, but "
                f"detector {source_id!r} does not set attach_frame=true"
            )
        unknown_by_rule = {
            str(rule.rule_id): sorted(set(rule.labels) - selected)
            for rule in rules
            if set(rule.labels) - selected
        }
        if unknown_by_rule:
            details = "; ".join(
                f"{rule_id}: {', '.join(labels)}"
                for rule_id, labels in sorted(unknown_by_rule.items())
            )
            raise PipelineCompileError(
                f"detection rule node {target_id!r} references labels not emitted "
                f"by detector {source_id!r}: {details}"
            )


__all__ = [
    "CompiledPipeline",
    "PipelineCompileError",
    "SUPPORTED_API_VERSION",
    "compile_pipeline",
    "enabled_pipeline_ids",
]
