"""Strict configuration models for the edge-AI pipeline runtime.

The models in this module intentionally do not import the component registry or
the runtime.  This keeps configuration validation usable by command-line tools,
tests, and deployment checks without initializing any transports or models.
"""

from __future__ import annotations

import re
from typing import Any, Literal

from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator


_IDENTIFIER_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_-]*$")
_PORT_REFERENCE_PATTERN = re.compile(
    r"^(?P<node>[A-Za-z_][A-Za-z0-9_-]*)\." r"(?P<port>[A-Za-z_][A-Za-z0-9_-]*)$"
)


class StrictConfigModel(BaseModel):
    """Base class shared by all public configuration models."""

    model_config = ConfigDict(
        extra="forbid",
        populate_by_name=True,
        validate_default=True,
    )


class RuntimeConfig(StrictConfigModel):
    """Process-wide limits and lifecycle settings for the local runtime."""

    thread_workers: int = Field(default=4, ge=1, le=256)
    max_processes: int = Field(default=1, ge=0, le=64)
    process_start_method: Literal["spawn", "forkserver", "fork"] = "spawn"
    shutdown_timeout_s: float = Field(default=10.0, gt=0, le=300.0)
    health_bind: str | None = None
    reserved_memory_mb: int = Field(default=0, ge=0)

    @field_validator("health_bind")
    @classmethod
    def validate_health_bind(cls, value: str | None) -> str | None:
        if value is not None and not value.strip():
            raise ValueError("health_bind must not be empty")
        return value


class EndpointConfig(StrictConfigModel):
    """A named external endpoint used by one or more connector nodes.

    Transport-specific settings belong under ``options`` so that adding QUIC or
    a vendor-specific protocol does not require weakening top-level validation.
    """

    transport: str = Field(min_length=1)
    target: str | None = None
    base_url: str | None = None
    bind: str | None = None
    tls: bool = False
    timeout_s: float = Field(default=5.0, gt=0, le=3600.0)
    metadata: dict[str, str] = Field(default_factory=dict)
    options: dict[str, Any] = Field(default_factory=dict)

    @field_validator("transport")
    @classmethod
    def normalize_transport(cls, value: str) -> str:
        value = value.strip().lower()
        if not value:
            raise ValueError("transport must not be empty")
        return value

    @field_validator("target", "base_url", "bind")
    @classmethod
    def reject_empty_endpoint_values(cls, value: str | None) -> str | None:
        if value is not None and not value.strip():
            raise ValueError("endpoint address must not be empty")
        return value

    @model_validator(mode="after")
    def validate_transport_address(self) -> EndpointConfig:
        """Validate the transports implemented by the built-in connectors.

        Unknown transport names remain available to third-party plugins.  Once
        a transport is one of the built-ins, however, accepting an incomplete
        endpoint here would only defer a deterministic error until startup.
        """

        if self.transport == "grpc":
            if self.target is None:
                raise ValueError("gRPC endpoint requires target")
            if self.base_url is not None:
                raise ValueError("gRPC endpoint must not set base_url")
            channel_options = self.options.get("channel_options", {})
            if not isinstance(channel_options, dict):
                raise ValueError("gRPC options.channel_options must be a mapping")
            invalid_options = sorted(
                str(key)
                for key, value in channel_options.items()
                if not isinstance(key, str)
                or isinstance(value, bool)
                or not isinstance(value, (int, str))
            )
            if invalid_options:
                raise ValueError(
                    "gRPC channel option values must be int or str: "
                    + ", ".join(invalid_options)
                )
        elif self.transport == "http":
            if self.base_url is None:
                raise ValueError("HTTP endpoint requires base_url")
            if not self.base_url.startswith(("http://", "https://")):
                raise ValueError("HTTP base_url must start with http:// or https://")
            if self.target is not None:
                raise ValueError("HTTP endpoint must not set target")
            if "tls" in self.model_fields_set:
                raise ValueError(
                    "HTTP endpoint must not set tls; use an https:// base_url and "
                    "options.verify_tls (default true)"
                )
            verify_tls = self.options.get("verify_tls", True)
            if not isinstance(verify_tls, bool):
                raise ValueError("HTTP options.verify_tls must be boolean")
        return self


class ExecutionConfig(StrictConfigModel):
    """How a node is scheduled by the runtime."""

    mode: Literal["async", "inline", "thread", "process", "model_worker"] = "async"
    concurrency: int = Field(default=1, ge=1, le=1024)
    max_in_flight: int | None = Field(default=None, ge=1, le=65536)
    timeout_s: float | None = Field(default=None, gt=0, le=86400.0)
    ordered: bool = True


class ResourceConfig(StrictConfigModel):
    """Optional resource budget declared by a node."""

    cpu_cores: float | None = Field(default=None, gt=0)
    memory_mb: int | None = Field(default=None, gt=0)
    gpu_device: int | str | None = None
    gpu_memory_mb: int | None = Field(default=None, gt=0)
    exclusive_gpu: bool = False

    @field_validator("gpu_device")
    @classmethod
    def validate_gpu_device(cls, value: int | str | None) -> int | str | None:
        if isinstance(value, int) and value < 0:
            raise ValueError("gpu_device must be non-negative")
        if isinstance(value, str) and not value.strip():
            raise ValueError("gpu_device must not be empty")
        return value


class QoSConfig(StrictConfigModel):
    """Bounded-queue policy for a single directed edge."""

    # Lossless backpressure is the safe generic default.  Real-time sensor
    # pipelines must opt into frame dropping explicitly per edge.
    profile: str = Field(default="request", min_length=1)
    capacity: int = Field(default=1, ge=1, le=1_000_000)
    overflow: Literal["drop_oldest", "drop_newest", "block", "reject", "error"] = (
        "block"
    )
    max_age_ms: float | None = Field(default=None, gt=0)
    put_timeout_ms: float | None = Field(default=None, ge=0)

    @field_validator("profile")
    @classmethod
    def validate_profile(cls, value: str) -> str:
        value = value.strip()
        if not value:
            raise ValueError("qos profile must not be empty")
        return value


class NodeConfig(StrictConfigModel):
    """A configured component instance in a pipeline graph."""

    uses: str = Field(min_length=1)
    params: dict[str, Any] = Field(default_factory=dict, alias="with")
    execution: ExecutionConfig = Field(default_factory=ExecutionConfig)
    resources: ResourceConfig = Field(default_factory=ResourceConfig)
    enabled: bool = True

    @field_validator("uses")
    @classmethod
    def validate_component_id(cls, value: str) -> str:
        value = value.strip()
        if not value:
            raise ValueError("uses must not be empty")
        return value


class EdgeConfig(StrictConfigModel):
    """A connection from one ``node.port`` to another ``node.port``."""

    source: str = Field(alias="from")
    target: str = Field(alias="to")
    qos: QoSConfig = Field(default_factory=QoSConfig)

    @field_validator("source", "target")
    @classmethod
    def validate_port_reference(cls, value: str) -> str:
        if not _PORT_REFERENCE_PATTERN.fullmatch(value):
            raise ValueError(
                "port reference must use 'node.port' format; node and port may "
                "contain letters, digits, '_' or '-'"
            )
        return value

    @property
    def source_node(self) -> str:
        return self.source.split(".", maxsplit=1)[0]

    @property
    def target_node(self) -> str:
        return self.target.split(".", maxsplit=1)[0]


PipelinePriority = Literal["low", "normal", "high"] | int


class PipelineConfig(StrictConfigModel):
    """A validated directed acyclic graph of configured nodes."""

    enabled: bool = True
    priority: PipelinePriority = "normal"
    nodes: dict[str, NodeConfig]
    edges: list[EdgeConfig] = Field(default_factory=list)

    @field_validator("priority")
    @classmethod
    def validate_priority(cls, value: PipelinePriority) -> PipelinePriority:
        if isinstance(value, int) and not 0 <= value <= 100:
            raise ValueError("numeric priority must be between 0 and 100")
        return value

    @field_validator("nodes")
    @classmethod
    def validate_node_names(cls, value: dict[str, NodeConfig]) -> dict[str, NodeConfig]:
        if not value:
            raise ValueError("pipeline must contain at least one node")
        invalid = sorted(
            name for name in value if not _IDENTIFIER_PATTERN.fullmatch(name)
        )
        if invalid:
            raise ValueError(
                "invalid node name(s): "
                + ", ".join(invalid)
                + "; names may contain letters, digits, '_' or '-' and may not "
                "start with a digit"
            )
        return value

    @model_validator(mode="after")
    def validate_graph(self) -> PipelineConfig:
        node_names = set(self.nodes)
        adjacency: dict[str, set[str]] = {name: set() for name in node_names}
        indegree = {name: 0 for name in node_names}

        for edge in self.edges:
            missing = {
                name
                for name in (edge.source_node, edge.target_node)
                if name not in node_names
            }
            if missing:
                raise ValueError(
                    f"edge {edge.source!r} -> {edge.target!r} references unknown "
                    f"node(s): {', '.join(sorted(missing))}"
                )

            # Multiple ports may connect the same pair of nodes.  They represent
            # one dependency for cycle detection.
            if edge.target_node not in adjacency[edge.source_node]:
                adjacency[edge.source_node].add(edge.target_node)
                indegree[edge.target_node] += 1

        ready = [name for name, degree in indegree.items() if degree == 0]
        visited = 0
        while ready:
            current = ready.pop()
            visited += 1
            for target in adjacency[current]:
                indegree[target] -= 1
                if indegree[target] == 0:
                    ready.append(target)

        if visited != len(node_names):
            cycle_nodes = sorted(
                name for name, degree in indegree.items() if degree > 0
            )
            raise ValueError(
                "pipeline graph must be acyclic; cycle involves: "
                + ", ".join(cycle_nodes)
            )
        return self


class AppConfig(StrictConfigModel):
    """Root configuration document for a cmvr-edge-ai process."""

    api_version: str = Field(min_length=1)
    runtime: RuntimeConfig = Field(default_factory=RuntimeConfig)
    endpoints: dict[str, EndpointConfig] = Field(default_factory=dict)
    pipelines: dict[str, PipelineConfig]

    @field_validator("api_version")
    @classmethod
    def validate_api_version(cls, value: str) -> str:
        value = value.strip()
        if not value:
            raise ValueError("api_version must not be empty")
        return value

    @field_validator("endpoints")
    @classmethod
    def validate_endpoint_names(
        cls, value: dict[str, EndpointConfig]
    ) -> dict[str, EndpointConfig]:
        return _validate_named_mapping("endpoint", value)

    @field_validator("pipelines")
    @classmethod
    def validate_pipeline_names(
        cls, value: dict[str, PipelineConfig]
    ) -> dict[str, PipelineConfig]:
        if not value:
            raise ValueError("configuration must contain at least one pipeline")
        return _validate_named_mapping("pipeline", value)


def _validate_named_mapping(name: str, value: dict[str, Any]) -> dict[str, Any]:
    invalid = sorted(key for key in value if not _IDENTIFIER_PATTERN.fullmatch(key))
    if invalid:
        raise ValueError(
            f"invalid {name} name(s): {', '.join(invalid)}; names may contain "
            "letters, digits, '_' or '-' and may not start with a digit"
        )
    return value


# A descriptive alias for callers that prefer the project's full name.
EdgeAIConfig = AppConfig


__all__ = [
    "AppConfig",
    "EdgeAIConfig",
    "EdgeConfig",
    "EndpointConfig",
    "ExecutionConfig",
    "NodeConfig",
    "PipelineConfig",
    "PipelinePriority",
    "QoSConfig",
    "ResourceConfig",
    "RuntimeConfig",
    "StrictConfigModel",
]
