from __future__ import annotations

import re
from dataclasses import dataclass
from enum import StrEnum
from typing import Any, Final

from k1link.simulation.contracts import ControlProfile

PROVIDER_PROFILE_SCHEMA: Final = "missioncore.simulation-provider-profile/v1"
IDENTIFIER_PATTERN: Final = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$")
CAPABILITY_PATTERN: Final = re.compile(r"^[a-z0-9][a-z0-9._/-]{0,127}$")
CLOCK_DOMAIN_PATTERN: Final = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$")
COMMAND_CAPABILITY_BY_PROFILE: Final = {
    ControlProfile.ROVER_SPEED_STEERING_V1: "command.rover-speed-steering/v1",
    ControlProfile.ROVER_SPEED_YAW_RATE_V1: "command.rover-speed-yaw-rate/v1",
}


class SimulationProviderContractError(ValueError):
    """A simulation provider profile violates the admitted v1 contract."""


class ProviderRole(StrEnum):
    WORLD = "world"
    PHYSICS = "physics"
    STATE = "state"
    CONTROLLER = "controller"
    TRANSPORT = "transport"
    SENSOR = "sensor"
    TRAFFIC = "traffic"


@dataclass(frozen=True, slots=True)
class SimulationProviderDescriptor:
    provider_id: str
    roles: tuple[ProviderRole, ...]
    capabilities: tuple[str, ...]

    def __post_init__(self) -> None:
        _identifier(self.provider_id, "provider id")
        if not self.roles:
            raise SimulationProviderContractError("provider roles must not be empty")
        if any(not isinstance(role, ProviderRole) for role in self.roles):
            raise SimulationProviderContractError("provider role is unknown")
        if len(self.roles) != len(set(self.roles)):
            raise SimulationProviderContractError("provider roles must be unique")
        if not self.capabilities:
            raise SimulationProviderContractError("provider capabilities must not be empty")
        if len(self.capabilities) != len(set(self.capabilities)):
            raise SimulationProviderContractError("provider capabilities must be unique")
        for capability in self.capabilities:
            _capability(capability)

    def to_dict(self) -> dict[str, object]:
        return {
            "provider_id": self.provider_id,
            "roles": [role.value for role in self.roles],
            "capabilities": list(self.capabilities),
        }

    @classmethod
    def from_dict(cls, value: object) -> SimulationProviderDescriptor:
        document = _object(value, "provider descriptor")
        _exact_keys(document, {"provider_id", "roles", "capabilities"}, "provider descriptor")
        roles = _array(document, "roles")
        capabilities = _array(document, "capabilities")
        try:
            parsed_roles = tuple(
                ProviderRole(_string_value(role, "provider role")) for role in roles
            )
        except ValueError as exc:
            raise SimulationProviderContractError("provider role is unknown") from exc
        return cls(
            provider_id=_string(document, "provider_id"),
            roles=parsed_roles,
            capabilities=tuple(
                _string_value(capability, "provider capability") for capability in capabilities
            ),
        )


@dataclass(frozen=True, slots=True)
class SimulationClockDescriptor:
    provider_id: str
    domain: str
    unit: str = "nanoseconds"
    mode: str = "simulation"

    def __post_init__(self) -> None:
        _identifier(self.provider_id, "clock provider id")
        if not CLOCK_DOMAIN_PATTERN.fullmatch(self.domain):
            raise SimulationProviderContractError("clock domain is not safe")
        if self.unit != "nanoseconds" or self.mode != "simulation":
            raise SimulationProviderContractError(
                "v1 provider clocks must use simulation nanoseconds"
            )

    def to_dict(self) -> dict[str, object]:
        return {
            "provider_id": self.provider_id,
            "domain": self.domain,
            "unit": self.unit,
            "mode": self.mode,
        }

    @classmethod
    def from_dict(cls, value: object) -> SimulationClockDescriptor:
        document = _object(value, "simulation clock")
        _exact_keys(
            document,
            {"provider_id", "domain", "unit", "mode"},
            "simulation clock",
        )
        return cls(
            provider_id=_string(document, "provider_id"),
            domain=_string(document, "domain"),
            unit=_string(document, "unit"),
            mode=_string(document, "mode"),
        )


@dataclass(frozen=True, slots=True)
class SimulationProviderProfile:
    profile_id: str
    providers: tuple[SimulationProviderDescriptor, ...]
    clock: SimulationClockDescriptor
    control_profiles: tuple[ControlProfile, ...]
    world_frame: str = "map_enu"
    body_frame: str = "base_link_flu"

    def __post_init__(self) -> None:
        _identifier(self.profile_id, "provider profile id")
        if not self.providers:
            raise SimulationProviderContractError("provider profile must declare providers")
        provider_ids = [provider.provider_id for provider in self.providers]
        if len(provider_ids) != len(set(provider_ids)):
            raise SimulationProviderContractError("provider ids must be unique")
        providers_by_id = {provider.provider_id: provider for provider in self.providers}
        clock_provider = providers_by_id.get(self.clock.provider_id)
        if clock_provider is None or "clock.simulation" not in clock_provider.capabilities:
            raise SimulationProviderContractError("clock provider must declare clock.simulation")
        if not any(
            ProviderRole.STATE in provider.roles and "state.vehicle-pose" in provider.capabilities
            for provider in self.providers
        ):
            raise SimulationProviderContractError(
                "provider profile must expose canonical vehicle pose"
            )
        if not self.control_profiles:
            raise SimulationProviderContractError("control profiles must not be empty")
        if any(not isinstance(profile, ControlProfile) for profile in self.control_profiles):
            raise SimulationProviderContractError("control profile is unknown")
        if len(self.control_profiles) != len(set(self.control_profiles)):
            raise SimulationProviderContractError("control profiles must be unique")
        controller_capabilities = {
            capability
            for provider in self.providers
            if ProviderRole.CONTROLLER in provider.roles
            for capability in provider.capabilities
        }
        if any(
            COMMAND_CAPABILITY_BY_PROFILE[profile] not in controller_capabilities
            for profile in self.control_profiles
        ):
            raise SimulationProviderContractError(
                "controller providers do not satisfy the declared control profiles"
            )
        if self.world_frame != "map_enu" or self.body_frame != "base_link_flu":
            raise SimulationProviderContractError(
                "v1 provider profiles must expose map_enu and base_link_flu"
            )

    def to_dict(self) -> dict[str, object]:
        return {
            "schema_version": PROVIDER_PROFILE_SCHEMA,
            "profile_id": self.profile_id,
            "providers": [provider.to_dict() for provider in self.providers],
            "clock": self.clock.to_dict(),
            "control_profiles": [profile.value for profile in self.control_profiles],
            "canonical_frames": {
                "world": self.world_frame,
                "body": self.body_frame,
            },
        }

    @classmethod
    def from_dict(cls, value: object) -> SimulationProviderProfile:
        document = _object(value, "simulation provider profile")
        _exact_keys(
            document,
            {
                "schema_version",
                "profile_id",
                "providers",
                "clock",
                "control_profiles",
                "canonical_frames",
            },
            "simulation provider profile",
        )
        if document.get("schema_version") != PROVIDER_PROFILE_SCHEMA:
            raise SimulationProviderContractError("provider profile schema is incompatible")
        providers = _array(document, "providers")
        control_profiles = _array(document, "control_profiles")
        frames = _object(document.get("canonical_frames"), "canonical frames")
        _exact_keys(frames, {"world", "body"}, "canonical frames")
        try:
            parsed_control_profiles = tuple(
                ControlProfile(_string_value(profile, "control profile"))
                for profile in control_profiles
            )
        except ValueError as exc:
            raise SimulationProviderContractError("control profile is unknown") from exc
        return cls(
            profile_id=_string(document, "profile_id"),
            providers=tuple(
                SimulationProviderDescriptor.from_dict(provider) for provider in providers
            ),
            clock=SimulationClockDescriptor.from_dict(document.get("clock")),
            control_profiles=parsed_control_profiles,
            world_frame=_string(frames, "world"),
            body_frame=_string(frames, "body"),
        )


def _identifier(value: str, label: str) -> str:
    if not IDENTIFIER_PATTERN.fullmatch(value):
        raise SimulationProviderContractError(f"{label} is not a safe identifier")
    return value


def _capability(value: str) -> str:
    if not CAPABILITY_PATTERN.fullmatch(value):
        raise SimulationProviderContractError("provider capability is not safe")
    return value


def _object(value: object, label: str) -> dict[str, Any]:
    if not isinstance(value, dict) or any(not isinstance(key, str) for key in value):
        raise SimulationProviderContractError(f"{label} must be an object")
    return value


def _array(document: dict[str, Any], key: str) -> list[object]:
    value = document.get(key)
    if not isinstance(value, list):
        raise SimulationProviderContractError(f"{key} must be an array")
    return value


def _string(document: dict[str, Any], key: str) -> str:
    return _string_value(document.get(key), key)


def _string_value(value: object, label: str) -> str:
    if not isinstance(value, str) or not value:
        raise SimulationProviderContractError(f"{label} must be a nonempty string")
    return value


def _exact_keys(value: dict[str, Any], expected: set[str], label: str) -> None:
    if set(value) != expected:
        raise SimulationProviderContractError(f"{label} keys do not match v1")
