"""Small built-in plugins used for smoke tests and control boundaries."""

from __future__ import annotations

import asyncio
import logging
import math
from collections.abc import Mapping
from time import monotonic_ns, time_ns
from typing import Any

from cmvr_edge_ai.contracts import ApprovedRobotCommand, RobotCommand
from cmvr_edge_ai.core.component import Emission, HealthReport, Operator, Sink, Source
from cmvr_edge_ai.core.envelope import Envelope

from .registry import PluginKind, PluginRegistry, PluginSpec


class SequenceSource(Source):
    """Finite synthetic source useful for deployment and runtime smoke tests."""

    def __init__(self, node_id: str, params: Mapping[str, Any]) -> None:
        self._node_id = node_id
        items = params.get("items", ())
        if isinstance(items, (str, bytes, bytearray, Mapping)):
            raise ValueError("items must be a sequence, not a scalar or mapping")
        try:
            self._items = tuple(items)
        except TypeError as exc:
            raise ValueError("items must be an iterable sequence") from exc
        self._interval_s = float(params.get("interval_s", 0.0))
        self._schema_name = str(params.get("schema_name", "opaque"))
        self._schema_version = int(params.get("schema_version", 1))
        if self._interval_s < 0:
            raise ValueError("interval_s must not be negative")

    async def messages(self):  # type: ignore[no-untyped-def]
        for sequence, payload in enumerate(self._items):
            if self._interval_s:
                await asyncio.sleep(self._interval_s)
            yield Emission(
                "output",
                Envelope(
                    payload=payload,
                    schema_name=self._schema_name,
                    schema_version=self._schema_version,
                    source_id=self._node_id,
                    sequence=sequence,
                ),
            )


class PassthroughOperator(Operator):
    def __init__(self, node_id: str, params: Mapping[str, Any]) -> None:
        del node_id
        del params

    async def process(
        self, envelope: Envelope[Any], input_port: str = "input"
    ) -> Emission:
        del input_port
        return Emission("output", envelope)


class LogSink(Sink):
    def __init__(self, node_id: str, params: Mapping[str, Any]) -> None:
        logger_name = str(params.get("logger", "cmvr_edge_ai.sink"))
        self._logger = logging.getLogger(logger_name)
        self._node_id = node_id
        self._level = getattr(logging, str(params.get("level", "INFO")).upper())

    async def consume(self, envelope: Envelope[Any], input_port: str = "input") -> None:
        self._logger.log(
            self._level,
            "pipeline output node=%s port=%s schema=%s trace_id=%s payload=%r",
            self._node_id,
            input_port,
            envelope.schema,
            envelope.trace_id,
            envelope.payload,
        )


class CommandSafetyGate(Operator):
    """Fail-closed validation for transport-neutral robot commands."""

    def __init__(self, node_id: str, params: Mapping[str, Any]) -> None:
        self._node_id = node_id
        self._allowed_actions = frozenset(
            str(action) for action in params.get("allowed_actions", ())
        )
        self._allowed_devices = frozenset(
            str(device) for device in params.get("allowed_devices", ())
        )
        self._min_interval_ns = int(float(params.get("min_interval_ms", 0.0)) * 1e6)
        self._max_ttl_ns = int(float(params.get("max_ttl_ms", 5000.0)) * 1e6)
        self._argument_limits = _parse_argument_limits(
            params.get("argument_limits", {})
        )
        required_limits = params.get("require_limits_for", ())
        self._require_limits_for = frozenset(
            str(action) for action in required_limits
        ) | {"set_velocity"}
        self._enforce_sequence = _strict_bool(
            params, "enforce_monotonic_sequence", default=True
        )
        self._last_command_ns: dict[tuple[str, str], int] = {}
        self._last_sequence: dict[tuple[str, str], int] = {}
        self._rejected_count = 0
        self._logger = logging.getLogger(__name__)
        if not self._allowed_actions:
            raise ValueError("safety gate requires a non-empty allowed_actions list")
        if self._min_interval_ns < 0:
            raise ValueError("min_interval_ms must not be negative")
        if self._max_ttl_ns <= 0:
            raise ValueError("max_ttl_ms must be positive")
        missing_limits = (self._allowed_actions & self._require_limits_for) - set(
            self._argument_limits
        )
        if missing_limits:
            raise ValueError(
                "safety gate requires argument_limits for action(s): "
                + ", ".join(sorted(missing_limits))
            )
        if "set_velocity" in self._allowed_actions:
            velocity_fields = set(self._argument_limits["set_velocity"])
            missing_fields = {"vx", "vy", "wz"} - velocity_fields
            if missing_fields:
                raise ValueError(
                    "argument_limits.set_velocity must define vx, vy and wz; "
                    f"missing: {', '.join(sorted(missing_fields))}"
                )

    async def process(
        self, envelope: Envelope[Any], input_port: str = "input"
    ) -> Emission | None:
        del input_port
        command = envelope.payload
        if not isinstance(command, RobotCommand):
            raise TypeError(
                f"{self._node_id} expected RobotCommand, got {type(command).__name__}"
            )
        now_wall_ns = time_ns()
        if envelope.is_expired(now_wall_ns) or command.valid_until_ns <= now_wall_ns:
            return self._reject(command, "expired")
        if command.valid_until_ns - now_wall_ns > self._max_ttl_ns:
            return self._reject(command, "TTL exceeds safety-gate maximum")
        if command.action not in self._allowed_actions:
            return self._reject(command, "action is not allowlisted")
        if self._allowed_devices and command.device_id not in self._allowed_devices:
            return self._reject(command, "device is not allowlisted")

        for argument, (minimum, maximum) in self._argument_limits.get(
            command.action, {}
        ).items():
            if argument not in command.arguments:
                return self._reject(
                    command, f"required argument {argument!r} is missing"
                )
            try:
                value = float(command.arguments[argument])
            except (TypeError, ValueError):
                return self._reject(command, f"argument {argument!r} is not numeric")
            if not math.isfinite(value) or not minimum <= value <= maximum:
                return self._reject(
                    command,
                    f"argument {argument!r}={value!r} is outside "
                    f"[{minimum}, {maximum}]",
                )

        key = (command.device_id, command.action)
        previous_sequence = self._last_sequence.get(key)
        if (
            self._enforce_sequence
            and previous_sequence is not None
            and command.sequence <= previous_sequence
        ):
            return self._reject(command, "sequence is stale or duplicated")
        self._last_sequence[key] = command.sequence
        now = monotonic_ns()
        previous = self._last_command_ns.get(key)
        if previous is not None and now - previous < self._min_interval_ns:
            return self._reject(command, "rate limit exceeded")
        self._last_command_ns[key] = now
        approved = ApprovedRobotCommand(command=command, approved_by=self._node_id)
        return Emission(
            "commands",
            envelope.with_payload(
                approved,
                schema_name="ApprovedRobotCommand",
                schema_version=1,
                source_id=self._node_id,
            ),
        )

    async def health(self) -> HealthReport:
        return HealthReport(True, f"rejected_commands={self._rejected_count}")

    def _reject(self, command: RobotCommand, reason: str) -> Emission | None:
        self._rejected_count += 1
        self._logger.warning(
            "robot command rejected gate=%s command_id=%s device=%s action=%s "
            "reason=%s",
            self._node_id,
            command.command_id,
            command.device_id,
            command.action,
            reason,
        )
        return None


def _parse_argument_limits(value: Any) -> dict[str, dict[str, tuple[float, float]]]:
    if not isinstance(value, Mapping):
        raise ValueError("argument_limits must be a mapping")
    parsed: dict[str, dict[str, tuple[float, float]]] = {}
    for action, arguments in value.items():
        if not isinstance(arguments, Mapping):
            raise ValueError(f"argument_limits.{action} must be a mapping")
        parsed_arguments: dict[str, tuple[float, float]] = {}
        for name, bounds in arguments.items():
            if (
                not isinstance(bounds, Mapping)
                or "min" not in bounds
                or "max" not in bounds
            ):
                raise ValueError(
                    f"argument_limits.{action}.{name} requires min and max"
                )
            minimum = float(bounds["min"])
            maximum = float(bounds["max"])
            if not math.isfinite(minimum) or not math.isfinite(maximum):
                raise ValueError("argument limits must be finite")
            if minimum > maximum:
                raise ValueError(f"argument_limits.{action}.{name} min exceeds max")
            parsed_arguments[str(name)] = (minimum, maximum)
        parsed[str(action)] = parsed_arguments
    return parsed


def _strict_bool(params: Mapping[str, Any], name: str, *, default: bool) -> bool:
    value = params.get(name, default)
    if type(value) is not bool:
        raise ValueError(f"{name} must be a boolean, not {value!r}")
    return value


def register_builtin_plugins(registry: PluginRegistry) -> None:
    registry.register(
        PluginSpec(
            plugin_id="core.sequence_source@1",
            kind=PluginKind.SOURCE,
            factory=SequenceSource,
            outputs={"output": "*"},
            description="Finite synthetic source for smoke tests",
        )
    )
    registry.register(
        PluginSpec(
            plugin_id="core.passthrough@1",
            kind=PluginKind.OPERATOR,
            factory=PassthroughOperator,
            inputs={"input": "*"},
            outputs={"output": "*"},
            description="Protocol-neutral pass-through operator",
        )
    )
    registry.register(
        PluginSpec(
            plugin_id="core.log_sink@1",
            kind=PluginKind.SINK,
            factory=LogSink,
            inputs={"input": "*"},
            description="Structured log sink",
        )
    )
    registry.register(
        PluginSpec(
            plugin_id="safety.robot_command_gate@1",
            kind=PluginKind.OPERATOR,
            factory=CommandSafetyGate,
            inputs={"input": "RobotCommand/v1"},
            outputs={"commands": "ApprovedRobotCommand/v1"},
            description="Fail-closed allowlist and rate gate for robot commands",
            tags=frozenset({"safety_gate"}),
        )
    )


__all__ = [
    "CommandSafetyGate",
    "LogSink",
    "PassthroughOperator",
    "SequenceSource",
    "register_builtin_plugins",
]
