from __future__ import annotations

import json
import re
from pathlib import Path
from typing import Annotated, Any, Literal

from pydantic import (
    AfterValidator,
    BaseModel,
    ConfigDict,
    Field,
    ValidationError,
    model_validator,
)

from k1link.web.plugin_runtime import STATE_READ_ACTION_ID

PLUGIN_API_VERSION = "missioncore.nodedc/v1alpha2"
SUPPORTED_PLUGIN_API_VERSIONS = frozenset(
    {
        "missioncore.nodedc/v1alpha1",
        PLUGIN_API_VERSION,
    }
)
_V1ALPHA2_IDENTIFIER = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/-]*$")


def _reject_blank(value: str) -> str:
    if not value.strip():
        raise ValueError("must contain a non-whitespace character")
    return value


def _validate_v1alpha2_identifier(value: str, path: str) -> None:
    if _V1ALPHA2_IDENTIFIER.fullmatch(value) is None:
        raise ValueError(f"{path} must be a v1alpha2 identifier")


ShortText = Annotated[
    str,
    Field(min_length=1, max_length=160),
    AfterValidator(_reject_blank),
]
DescriptionText = Annotated[
    str,
    Field(min_length=1, max_length=1024),
    AfterValidator(_reject_blank),
]
EntrypointText = Annotated[
    str,
    Field(min_length=1, max_length=256),
    AfterValidator(_reject_blank),
]
ProfilePathText = Annotated[
    str,
    Field(min_length=1, max_length=512),
    AfterValidator(_reject_blank),
]


class CapabilityManifest(BaseModel):
    model_config = ConfigDict(extra="forbid")

    id: ShortText
    label: ShortText


class UiContributionManifest(BaseModel):
    model_config = ConfigDict(extra="forbid")

    slot: Literal["device.connection"]
    componentKey: ShortText


class DeviceModelManifest(BaseModel):
    model_config = ConfigDict(extra="forbid")

    id: ShortText
    vendor: ShortText
    displayName: ShortText
    category: ShortText
    description: DescriptionText
    verified: bool
    capabilities: list[CapabilityManifest]
    ui: UiContributionManifest


class PluginRuntimeManifest(BaseModel):
    model_config = ConfigDict(extra="forbid")

    backendEntrypoint: EntrypointText
    isolation: Literal["transitional-in-process"]


class PluginActionManifest(BaseModel):
    model_config = ConfigDict(extra="forbid")

    id: ShortText
    mutating: bool
    secretFields: list[ShortText]


class CompatibilityProfileLinkManifest(BaseModel):
    model_config = ConfigDict(extra="forbid")

    profileId: ShortText
    path: ProfilePathText
    modelId: ShortText


class PluginMetadata(BaseModel):
    model_config = ConfigDict(extra="forbid")

    id: ShortText
    version: Annotated[
        str,
        Field(
            min_length=1,
            max_length=160,
            pattern=r"^[0-9]+\.[0-9]+\.[0-9]+(?:[-+][A-Za-z0-9.-]+)?$",
        ),
        AfterValidator(_reject_blank),
    ]
    displayName: ShortText


class PluginSpec(BaseModel):
    model_config = ConfigDict(extra="forbid")

    hostApiRange: Literal["v1alpha1", "v1alpha2"]
    runtime: PluginRuntimeManifest
    permissions: list[ShortText]
    actions: list[PluginActionManifest]
    models: list[DeviceModelManifest] = Field(min_length=1)
    compatibilityProfiles: list[CompatibilityProfileLinkManifest] | None = None


class DevicePluginManifest(BaseModel):
    model_config = ConfigDict(extra="forbid")

    apiVersion: Literal[
        "missioncore.nodedc/v1alpha1",
        "missioncore.nodedc/v1alpha2",
    ]
    kind: Literal["DevicePlugin"]
    metadata: PluginMetadata
    spec: PluginSpec

    @model_validator(mode="after")
    def validate_contract_version(self) -> DevicePluginManifest:
        expected_host_range = self.apiVersion.rsplit("/", maxsplit=1)[-1]
        if self.spec.hostApiRange != expected_host_range:
            raise ValueError("apiVersion and hostApiRange must declare the same contract")
        if self.apiVersion == "missioncore.nodedc/v1alpha1":
            if len(self.spec.models) != 1:
                raise ValueError("v1alpha1 must declare exactly one device model")
            if self.spec.compatibilityProfiles is not None:
                raise ValueError("v1alpha1 must not declare compatibilityProfiles")
        else:
            if not self.spec.compatibilityProfiles:
                raise ValueError("v1alpha2 must declare at least one compatibility profile")
            identifiers: list[tuple[str, str]] = [
                ("metadata.id", self.metadata.id),
                *(
                    (f"spec.permissions[{index}]", permission)
                    for index, permission in enumerate(self.spec.permissions)
                ),
            ]
            for action_index, action in enumerate(self.spec.actions):
                identifiers.append((f"spec.actions[{action_index}].id", action.id))
                identifiers.extend(
                    (
                        f"spec.actions[{action_index}].secretFields[{field_index}]",
                        field,
                    )
                    for field_index, field in enumerate(action.secretFields)
                )
            for model_index, model in enumerate(self.spec.models):
                identifiers.append((f"spec.models[{model_index}].id", model.id))
                identifiers.extend(
                    (
                        f"spec.models[{model_index}].capabilities[{capability_index}].id",
                        capability.id,
                    )
                    for capability_index, capability in enumerate(model.capabilities)
                )
            for profile_index, profile in enumerate(self.spec.compatibilityProfiles):
                identifiers.extend(
                    (
                        (
                            f"spec.compatibilityProfiles[{profile_index}].profileId",
                            profile.profileId,
                        ),
                        (
                            f"spec.compatibilityProfiles[{profile_index}].modelId",
                            profile.modelId,
                        ),
                    )
                )
            for path, value in identifiers:
                _validate_v1alpha2_identifier(value, path)
        return self


class PluginCatalogError(RuntimeError):
    """An installed manifest is invalid or conflicts with another manifest."""


def _reject_duplicate_profile_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
    result: dict[str, Any] = {}
    for key, value in pairs:
        if key in result:
            raise PluginCatalogError(f"Duplicate JSON key in compatibility profile: {key}")
        result[key] = value
    return result


def _validate_profile_links(
    manifest: DevicePluginManifest,
    manifest_path: Path,
) -> None:
    links = manifest.spec.compatibilityProfiles
    if links is None:
        return

    plugin_directory = manifest_path.parent.resolve()
    model_ids = {model.id for model in manifest.spec.models}
    linked_model_ids: set[str] = set()
    profile_ids: set[str] = set()
    profile_paths: set[Path] = set()

    for link in links:
        if link.profileId in profile_ids:
            raise PluginCatalogError(
                f"Duplicate compatibility profile id in {manifest.metadata.id}: {link.profileId}"
            )
        profile_ids.add(link.profileId)

        if link.modelId not in model_ids:
            raise PluginCatalogError(
                f"Compatibility profile {link.profileId} references unknown model {link.modelId}"
            )
        linked_model_ids.add(link.modelId)

        relative_path = Path(link.path)
        if relative_path.is_absolute():
            raise PluginCatalogError(
                f"Compatibility profile path must be plugin-relative: {link.path}"
            )
        profile_path = (plugin_directory / relative_path).resolve()
        try:
            profile_path.relative_to(plugin_directory)
        except ValueError as exc:
            raise PluginCatalogError(
                f"Compatibility profile path escapes plugin directory: {link.path}"
            ) from exc
        if profile_path in profile_paths:
            raise PluginCatalogError(
                f"Duplicate compatibility profile path in {manifest.metadata.id}: {link.path}"
            )
        profile_paths.add(profile_path)
        if not profile_path.is_file():
            raise PluginCatalogError(f"Compatibility profile does not exist: {link.path}")

        try:
            profile_document = json.loads(
                profile_path.read_text(encoding="utf-8"),
                object_pairs_hook=_reject_duplicate_profile_keys,
            )
        except (OSError, json.JSONDecodeError) as exc:
            raise PluginCatalogError(
                f"Invalid compatibility profile {profile_path}: {exc}"
            ) from exc
        if not isinstance(profile_document, dict):
            raise PluginCatalogError(
                f"Compatibility profile must contain a JSON object: {link.path}"
            )
        if profile_document.get("profile_id") != link.profileId:
            raise PluginCatalogError(
                f"Compatibility profile id mismatch for {link.path}: expected {link.profileId}"
            )

    if linked_model_ids != model_ids:
        missing = ", ".join(sorted(model_ids - linked_model_ids))
        raise PluginCatalogError(f"Device models without compatibility profiles: {missing}")


class DevicePluginCatalog:
    """Read-only catalog of statically reviewed device-plugin manifests."""

    def __init__(self, repository_root: Path) -> None:
        self.repository_root = repository_root.resolve()
        self._validated_manifests: tuple[DevicePluginManifest, ...] | None = None

    def manifests(self) -> list[DevicePluginManifest]:
        if self._validated_manifests is not None:
            return list(self._validated_manifests)

        plugin_root = self.repository_root / "plugins"
        manifests: list[DevicePluginManifest] = []
        plugin_ids: set[str] = set()
        model_ids: set[str] = set()

        for path in sorted(plugin_root.glob("*/plugin.manifest.json")):
            try:
                manifest = DevicePluginManifest.model_validate_json(path.read_text("utf-8"))
            except (OSError, ValidationError) as exc:
                raise PluginCatalogError(f"Invalid device-plugin manifest {path}: {exc}") from exc

            plugin_id = manifest.metadata.id
            if plugin_id in plugin_ids:
                raise PluginCatalogError(f"Duplicate device-plugin id: {plugin_id}")
            plugin_ids.add(plugin_id)

            _validate_profile_links(manifest, path)

            action_ids: set[str] = set()
            for action in manifest.spec.actions:
                if action.id in action_ids:
                    raise PluginCatalogError(
                        f"Duplicate device-plugin action id in {plugin_id}: {action.id}"
                    )
                action_ids.add(action.id)
                if len(action.secretFields) != len(set(action.secretFields)):
                    raise PluginCatalogError(
                        f"Duplicate secret field in action {plugin_id}/{action.id}"
                    )

            state_read = next(
                (action for action in manifest.spec.actions if action.id == STATE_READ_ACTION_ID),
                None,
            )
            if state_read is None or state_read.mutating or bool(state_read.secretFields):
                raise PluginCatalogError(
                    f"Device plugin {plugin_id} must declare safe {STATE_READ_ACTION_ID}"
                )

            if len(manifest.spec.permissions) != len(set(manifest.spec.permissions)):
                raise PluginCatalogError(f"Duplicate device-plugin permission in {plugin_id}")

            for model in manifest.spec.models:
                if model.id in model_ids:
                    raise PluginCatalogError(f"Duplicate device-model id: {model.id}")
                model_ids.add(model.id)
                capability_ids = [capability.id for capability in model.capabilities]
                if len(capability_ids) != len(set(capability_ids)):
                    raise PluginCatalogError(f"Duplicate capability id in device model {model.id}")
            manifests.append(manifest)

        self._validated_manifests = tuple(manifests)
        return list(self._validated_manifests)

    def plugin_documents(self) -> list[dict[str, Any]]:
        return [
            manifest.model_dump(mode="json", exclude_none=True) for manifest in self.manifests()
        ]

    def model_documents(self) -> list[dict[str, Any]]:
        models: list[dict[str, Any]] = []
        for manifest in self.manifests():
            for model in manifest.spec.models:
                models.append(
                    {
                        "pluginId": manifest.metadata.id,
                        "pluginVersion": manifest.metadata.version,
                        **model.model_dump(mode="json"),
                    }
                )
        return models
