from __future__ import annotations

import base64
import binascii
from collections import defaultdict
from collections.abc import Mapping
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
import json
import os
import re
from typing import Any

from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey

from govoplan_core.core.versioning import format_version_range, version_range_is_valid, version_satisfies_range
from govoplan_core.core.provider_governance import (
    external_provider_from_mapping,
    module_architecture_from_mapping,
    module_architecture_issues,
)
from govoplan_core.security.http_fetch import fetch_http_text, is_http_url

_INTERFACE_NAME_RE = re.compile(r"^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+$")
CATALOG_MIGRATION_SAFETY = ("automatic", "requires_review", "forward_only", "destructive")
CATALOG_MIGRATION_TASK_PHASES = (
    "pre_migration_check",
    "pre_migration_prepare",
    "post_migration_backfill",
    "post_migration_verify",
)


@dataclass(frozen=True, slots=True)
class _CatalogValidationState:
    modules: tuple[dict[str, object], ...]
    channel: str | None
    sequence: int | None
    generated_at: str | None
    not_before: str | None
    expires_at: str | None
    signature_state: dict[str, object]
    freshness: dict[str, object]
    replay: dict[str, object]
    read_state: dict[str, object]


def module_package_catalog(
    path: Path | str | None = None,
    *,
    require_trusted: bool | None = None,
    approved_channels: tuple[str, ...] | None = None,
    trusted_keys: dict[str, str] | None = None,
) -> tuple[dict[str, object], ...]:
    catalog_source = _catalog_source(path)
    if catalog_source is None or not _catalog_source_exists(catalog_source):
        return ()
    validation = validate_module_package_catalog(
        catalog_source,
        require_trusted=require_trusted,
        approved_channels=approved_channels,
        trusted_keys=trusted_keys,
    )
    if not validation["valid"]:
        raise ValueError(str(validation["error"] or "Module package catalog is invalid."))
    return tuple(item for item in validation["modules"] if isinstance(item, dict))


def validate_module_package_catalog(
    path: Path | str | None = None,
    *,
    require_trusted: bool | None = None,
    approved_channels: tuple[str, ...] | None = None,
    trusted_keys: dict[str, str] | None = None,
) -> dict[str, object]:
    catalog_source = _catalog_source(path)
    effective_require_trusted = _configured_require_signature() if require_trusted is None else require_trusted
    effective_approved_channels = _configured_approved_channels() if approved_channels is None else approved_channels
    effective_trusted_keys = trusted_keys if trusted_keys is not None else _configured_trusted_keys()
    if catalog_source is not None and not _catalog_source_exists(catalog_source):
        return _catalog_error_result(catalog_source, error=f"Module package catalog does not exist: {catalog_source}")
    try:
        state = _catalog_validation_state(catalog_source, trusted_keys=effective_trusted_keys)
    except Exception as exc:
        return _catalog_error_result(catalog_source, error=str(exc))
    policy_error = _catalog_policy_error(
        catalog_source,
        state,
        require_trusted=effective_require_trusted,
        approved_channels=effective_approved_channels,
    )
    if policy_error is not None:
        return policy_error
    return _valid_catalog_result(catalog_source, state)


def _catalog_validation_state(
    source: Path | str | None,
    *,
    trusted_keys: dict[str, str],
) -> _CatalogValidationState:
    payload, read_state = _read_catalog_payload_with_metadata(source)
    modules = _normalize_catalog_modules(payload)
    channel = _catalog_channel(payload)
    sequence = _catalog_sequence(payload)
    return _CatalogValidationState(
        modules=modules,
        channel=channel,
        sequence=sequence,
        generated_at=_catalog_optional_text(payload, "generated_at"),
        not_before=_catalog_optional_text(payload, "not_before"),
        expires_at=_catalog_optional_text(payload, "expires_at"),
        signature_state=_catalog_signature_state(payload, trusted_keys=trusted_keys),
        freshness=_catalog_freshness_state(payload),
        replay=_catalog_replay_state(channel=channel, sequence=sequence),
        read_state=read_state,
    )


def _catalog_policy_error(
    source: Path | str | None,
    state: _CatalogValidationState,
    *,
    require_trusted: bool,
    approved_channels: tuple[str, ...],
) -> dict[str, object] | None:
    if state.signature_state.get("fatal"):
        return _invalid_catalog_state_result(source, state, error=str(state.signature_state["error"] or "Module package catalog signature is invalid."))
    if approved_channels and state.channel not in approved_channels:
        return _invalid_catalog_state_result(
            source,
            state,
            error=f"Module package catalog channel {state.channel!r} is not approved. Approved channels: {', '.join(approved_channels)}.",
        )
    if require_trusted and not state.signature_state["trusted"]:
        return _invalid_catalog_state_result(source, state, error=str(state.signature_state["error"] or "Module package catalog must be signed by a trusted key."))
    if not state.freshness["valid"]:
        return _invalid_catalog_state_result(source, state, error=str(state.freshness["error"]))
    if not state.replay["valid"]:
        return _invalid_catalog_state_result(source, state, error=str(state.replay["error"]))
    return None


def _catalog_error_result(source: Path | str | None, *, error: str) -> dict[str, object]:
    return _invalid_catalog_result(
        source,
        modules=(),
        channel=None,
        sequence=None,
        generated_at=None,
        not_before=None,
        expires_at=None,
        signature_state=_unsigned_catalog_signature_state(),
        read_state=_default_catalog_read_state(),
        error=error,
    )


def _invalid_catalog_state_result(source: Path | str | None, state: _CatalogValidationState, *, error: str) -> dict[str, object]:
    return _invalid_catalog_result(
        source,
        modules=(),
        channel=state.channel,
        sequence=state.sequence,
        generated_at=state.generated_at,
        not_before=state.not_before,
        expires_at=state.expires_at,
        signature_state=state.signature_state,
        read_state=state.read_state,
        error=error,
    )


def _valid_catalog_result(source: Path | str | None, state: _CatalogValidationState) -> dict[str, object]:
    return {
        "valid": True,
        "configured": source is not None and _catalog_source_exists(source),
        "path": str(source) if source is not None else None,
        "source": str(source) if source is not None else None,
        "source_type": _catalog_source_type(source),
        "cache_used": bool(state.read_state.get("cache_used")),
        "cache_path": state.read_state.get("cache_path") if isinstance(state.read_state.get("cache_path"), str) else None,
        "modules": list(state.modules),
        "channel": state.channel,
        "sequence": state.sequence,
        "generated_at": state.generated_at,
        "not_before": state.not_before,
        "expires_at": state.expires_at,
        "signed": state.signature_state["signed"],
        "trusted": state.signature_state["trusted"],
        "key_id": state.signature_state["key_id"],
        "warnings": _catalog_validation_warnings(state),
        "error": None,
    }


def _catalog_validation_warnings(state: _CatalogValidationState) -> list[str]:
    warnings: list[str] = []
    warnings.extend(str(item) for item in state.freshness.get("warnings", ()) if item)
    warnings.extend(str(item) for item in state.replay.get("warnings", ()) if item)
    warnings.extend(_catalog_interface_warnings(state.modules))
    if not state.signature_state["signed"]:
        warnings.append("Catalog is unsigned; use only for local development unless signature enforcement is disabled intentionally.")
    elif not state.signature_state["trusted"]:
        warnings.append(str(state.signature_state["error"] or "Catalog signature could not be verified against a trusted key."))
    return warnings


def _default_catalog_read_state() -> dict[str, object]:
    cache_path = _configured_catalog_cache_path()
    return {"cache_used": False, "cache_path": str(cache_path) if cache_path is not None else None}


def _unsigned_catalog_signature_state() -> dict[str, object]:
    return {"signed": False, "trusted": False, "key_id": None}


def sign_module_package_catalog(
    *,
    path: Path,
    key_id: str,
    private_key_path: Path,
    output_path: Path | None = None,
) -> Path:
    payload = _read_catalog_payload(path)
    if not isinstance(payload, dict):
        raise ValueError("Only object-style module package catalogs can be signed.")
    signature_payload = dict(payload)
    signature_payload.pop("signature", None)
    signature_payload.pop("signatures", None)
    private_key = _load_private_key(private_key_path)
    signature = private_key.sign(_canonical_catalog_bytes(signature_payload))
    signature_payload["signature"] = {
        "algorithm": "ed25519",
        "key_id": key_id,
        "value": base64.b64encode(signature).decode("ascii"),
    }
    target = output_path or path
    target.write_text(json.dumps(signature_payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
    return target


def record_module_package_catalog_acceptance(validation: dict[str, object]) -> None:
    state_path = _configured_sequence_state_path()
    if state_path is None or validation.get("valid") is not True:
        return
    channel = validation.get("channel")
    sequence = validation.get("sequence")
    if not isinstance(channel, str) or not isinstance(sequence, int):
        return
    try:
        state = json.loads(state_path.read_text(encoding="utf-8")) if state_path.exists() else {}
    except json.JSONDecodeError:
        state = {}
    if not isinstance(state, dict):
        state = {}
    channels = state.get("channels")
    if not isinstance(channels, dict):
        channels = {}
    channel_state = channels.get(channel)
    if not isinstance(channel_state, dict):
        channel_state = {}
    channel_state["last_sequence"] = max(int(channel_state.get("last_sequence") or 0), sequence)
    channel_state["accepted_at"] = datetime.now(tz=UTC).isoformat().replace("+00:00", "Z")
    channel_state["key_id"] = validation.get("key_id")
    channel_state["source"] = validation.get("source") or validation.get("path")
    channels[channel] = channel_state
    state["channels"] = channels
    state_path.parent.mkdir(parents=True, exist_ok=True)
    state_path.write_text(json.dumps(state, indent=2, sort_keys=True) + "\n", encoding="utf-8")


def _catalog_source(path: Path | str | None) -> Path | str | None:
    if path is not None:
        return path if isinstance(path, str) and _is_http_url(path) else Path(path).expanduser()
    url = os.getenv("GOVOPLAN_MODULE_PACKAGE_CATALOG_URL", "").strip()
    if url:
        return url
    return _configured_catalog_path()


def _configured_catalog_path() -> Path | None:
    value = os.getenv("GOVOPLAN_MODULE_PACKAGE_CATALOG", "").strip()
    return Path(value).expanduser() if value else None


def _configured_catalog_cache_path() -> Path | None:
    value = os.getenv("GOVOPLAN_MODULE_PACKAGE_CATALOG_CACHE", "").strip()
    return Path(value).expanduser() if value else None


def _configured_sequence_state_path() -> Path | None:
    value = os.getenv("GOVOPLAN_MODULE_PACKAGE_CATALOG_SEQUENCE_STATE", "").strip()
    return Path(value).expanduser() if value else None


def _configured_enforce_sequence() -> bool:
    return os.getenv("GOVOPLAN_MODULE_PACKAGE_CATALOG_ENFORCE_SEQUENCE", "").strip().lower() in {"1", "true", "yes", "on"}


def _configured_require_signature() -> bool:
    value = os.getenv("GOVOPLAN_MODULE_PACKAGE_CATALOG_REQUIRE_SIGNATURE")
    if value is not None and value.strip():
        return value.strip().lower() in {"1", "true", "yes", "on"}
    return os.getenv("APP_ENV", "").strip().lower() in {"prod", "production"}


def _configured_approved_channels() -> tuple[str, ...]:
    value = os.getenv("GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNELS", "").strip()
    if not value:
        return ()
    return tuple(item.strip() for item in value.split(",") if item.strip())


def _configured_trusted_keys() -> dict[str, str]:
    file_value = os.getenv("GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE", "").strip()
    if file_value:
        path = Path(file_value).expanduser()
        if not path.exists():
            raise ValueError(f"Trusted catalog key file does not exist: {path}")
        return _parse_trusted_keys(path.read_text(encoding="utf-8"))
    url_value = os.getenv("GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_URL", "").strip()
    if url_value:
        return _parse_trusted_keys(_read_trusted_keys_url(url_value))
    value = os.getenv("GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS", "").strip()
    return _parse_trusted_keys(value) if value else {}


def _configured_trusted_keys_cache_path() -> Path | None:
    value = os.getenv("GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_CACHE", "").strip()
    return Path(value).expanduser() if value else None


def _read_trusted_keys_url(url: str) -> str:
    cache_path = _configured_trusted_keys_cache_path()
    try:
        body = fetch_http_text(url, timeout=15, label="Trusted catalog key URL")
        if cache_path is not None:
            cache_path.parent.mkdir(parents=True, exist_ok=True)
            cache_path.write_text(body, encoding="utf-8")
        return body
    except OSError:
        if cache_path is not None and cache_path.exists():
            return cache_path.read_text(encoding="utf-8")
        raise


def _parse_trusted_keys(value: str) -> dict[str, str]:
    payload = json.loads(value)
    if isinstance(payload, dict) and isinstance(payload.get("keys"), list):
        keys: dict[str, str] = {}
        now = datetime.now(tz=UTC)
        for item in payload["keys"]:
            if not isinstance(item, dict):
                continue
            status = str(item.get("status") or "active").strip().lower()
            if status in {"revoked", "disabled", "retired"}:
                continue
            not_before = _parse_datetime(item.get("not_before"))
            not_after = _parse_datetime(item.get("not_after"))
            if not_before is not None and now < not_before:
                continue
            if not_after is not None and now > not_after:
                continue
            key_id = str(item.get("key_id") or "").strip()
            public_key = str(item.get("public_key") or item.get("public_key_base64") or "").strip()
            if key_id and public_key:
                keys[key_id] = public_key
        return keys
    if not isinstance(payload, dict):
        raise ValueError("Trusted catalog keys must be a JSON object mapping key ids to base64 public keys.")
    return {str(key): str(item) for key, item in payload.items() if str(key).strip() and str(item).strip()}


def _read_catalog_payload(source: Path | str | None) -> object:
    payload, _metadata = _read_catalog_payload_with_metadata(source)
    return payload


def _read_catalog_payload_with_metadata(source: Path | str | None) -> tuple[object, dict[str, object]]:
    cache_path = _configured_catalog_cache_path()
    metadata: dict[str, object] = {
        "cache_used": False,
        "cache_path": str(cache_path) if cache_path is not None else None,
    }
    if source is None:
        return {"modules": []}, metadata
    if isinstance(source, str) and _is_http_url(source):
        body, cache_used = _read_catalog_url_with_metadata(source)
        metadata["cache_used"] = cache_used
        return json.loads(body), metadata
    return json.loads(Path(source).read_text(encoding="utf-8")), metadata


def _read_catalog_url(url: str) -> str:
    body, _cache_used = _read_catalog_url_with_metadata(url)
    return body


def _read_catalog_url_with_metadata(url: str) -> tuple[str, bool]:
    cache_path = _configured_catalog_cache_path()
    try:
        body = fetch_http_text(url, timeout=15, label="Module package catalog URL")
        if cache_path is not None:
            cache_path.parent.mkdir(parents=True, exist_ok=True)
            cache_path.write_text(body, encoding="utf-8")
        return body, False
    except OSError:
        if cache_path is not None and cache_path.exists():
            return cache_path.read_text(encoding="utf-8"), True
        raise


def _normalize_catalog_modules(payload: object) -> tuple[dict[str, object], ...]:
    raw_items = payload.get("modules") if isinstance(payload, dict) else payload
    if not isinstance(raw_items, list):
        raise ValueError("Module package catalog must be a list or an object with a 'modules' list.")
    return tuple(_normalize_catalog_item(item) for item in raw_items)


def _catalog_channel(payload: object) -> str | None:
    if not isinstance(payload, dict):
        return None
    channel = payload.get("channel")
    if channel is None:
        return None
    text = str(channel).strip()
    return text or None


def _catalog_optional_text(payload: object, key: str) -> str | None:
    if not isinstance(payload, dict):
        return None
    value = payload.get(key)
    if value is None:
        return None
    text = str(value).strip()
    return text or None


def _catalog_sequence(payload: object) -> int | None:
    if not isinstance(payload, dict):
        return None
    value = payload.get("sequence")
    if value is None:
        return None
    try:
        sequence = int(value)
    except (TypeError, ValueError):
        raise ValueError("Catalog sequence must be an integer.") from None
    if sequence < 0:
        raise ValueError("Catalog sequence must not be negative.")
    return sequence


def _catalog_signature_state(payload: object, *, trusted_keys: dict[str, str]) -> dict[str, object]:
    if not isinstance(payload, dict):
        return {"signed": False, "trusted": False, "key_id": None, "error": "List-style catalogs cannot be signed.", "fatal": False}
    signatures = _catalog_signatures(payload)
    if not signatures:
        return {"signed": False, "trusted": False, "key_id": None, "error": "Catalog is unsigned.", "fatal": False}
    errors: list[str] = []
    first_key_id: str | None = None
    for signature in signatures:
        result = _verify_catalog_signature(payload, signature, trusted_keys=trusted_keys)
        key_id = result.get("key_id")
        if first_key_id is None and isinstance(key_id, str):
            first_key_id = key_id
        if result.get("trusted"):
            return {"signed": True, "trusted": True, "key_id": key_id, "error": None, "fatal": False}
        if result.get("fatal"):
            return {"signed": True, "trusted": False, "key_id": key_id, "error": result.get("error"), "fatal": True}
        if result.get("error"):
            errors.append(str(result["error"]))
    return {
        "signed": True,
        "trusted": False,
        "key_id": first_key_id,
        "error": "; ".join(errors) if errors else "No catalog signature was trusted.",
        "fatal": False,
    }


def _catalog_signatures(payload: dict[str, object]) -> list[object]:
    signatures: list[object] = []
    signature = payload.get("signature")
    if signature is not None:
        signatures.append(signature)
    raw_signatures = payload.get("signatures")
    if isinstance(raw_signatures, list):
        signatures.extend(raw_signatures)
    elif raw_signatures is not None:
        signatures.append(raw_signatures)
    return signatures


def _verify_catalog_signature(payload: dict[str, object], signature: object, *, trusted_keys: dict[str, str]) -> dict[str, object]:
    if not isinstance(signature, dict):
        return {"signed": True, "trusted": False, "key_id": None, "error": "Catalog signature must be an object.", "fatal": True}
    algorithm = str(signature.get("algorithm") or "").strip().lower()
    key_id = str(signature.get("key_id") or "").strip()
    value = str(signature.get("value") or "").strip()
    if algorithm != "ed25519":
        return {"signed": True, "trusted": False, "key_id": key_id or None, "error": f"Unsupported catalog signature algorithm: {algorithm!r}", "fatal": True}
    if not key_id or not value:
        return {"signed": True, "trusted": False, "key_id": key_id or None, "error": "Catalog signature requires key_id and value.", "fatal": True}
    trusted_key = trusted_keys.get(key_id)
    if not trusted_key:
        return {"signed": True, "trusted": False, "key_id": key_id, "error": f"Catalog signature key is not trusted: {key_id}", "fatal": False}
    try:
        public_key = Ed25519PublicKey.from_public_bytes(base64.b64decode(trusted_key, validate=True))
        signed_payload = dict(payload)
        signed_payload.pop("signature", None)
        signed_payload.pop("signatures", None)
        public_key.verify(base64.b64decode(value, validate=True), _canonical_catalog_bytes(signed_payload))
    except (ValueError, binascii.Error, InvalidSignature) as exc:
        return {"signed": True, "trusted": False, "key_id": key_id, "error": f"Catalog signature verification failed: {exc}", "fatal": True}
    return {"signed": True, "trusted": True, "key_id": key_id, "error": None, "fatal": False}


def _catalog_freshness_state(payload: object) -> dict[str, object]:
    if not isinstance(payload, dict):
        return {"valid": True, "warnings": ()}
    now = datetime.now(tz=UTC)
    not_before = _parse_datetime(payload.get("not_before"))
    generated_at = _parse_datetime(payload.get("generated_at"))
    expires_at = _parse_datetime(payload.get("expires_at"))
    if not_before is not None and now < not_before:
        return {"valid": False, "error": f"Catalog is not valid before {not_before.isoformat()}."}
    if expires_at is not None and now > expires_at:
        return {"valid": False, "error": f"Catalog expired at {expires_at.isoformat()}."}
    warnings: list[str] = []
    if generated_at is None:
        warnings.append("Catalog has no generated_at timestamp.")
    if expires_at is None:
        warnings.append("Catalog has no expires_at timestamp; production catalogs should expire.")
    return {"valid": True, "warnings": tuple(warnings)}


def _catalog_replay_state(*, channel: str | None, sequence: int | None) -> dict[str, object]:
    state_path = _configured_sequence_state_path()
    if state_path is None:
        return {"valid": True, "warnings": ()}
    if not channel:
        return {"valid": False, "error": "Catalog sequence replay protection needs a channel."}
    if sequence is None:
        return {"valid": False, "error": "Catalog sequence replay protection needs a sequence."}
    if not state_path.exists():
        return {"valid": True, "warnings": ()}
    try:
        state = json.loads(state_path.read_text(encoding="utf-8"))
    except json.JSONDecodeError:
        return {"valid": False, "error": f"Catalog sequence state file is not valid JSON: {state_path}"}
    channels = state.get("channels") if isinstance(state, dict) else None
    channel_state = channels.get(channel) if isinstance(channels, dict) else None
    last_sequence = channel_state.get("last_sequence") if isinstance(channel_state, dict) else None
    if last_sequence is None:
        return {"valid": True, "warnings": ()}
    try:
        last = int(last_sequence)
    except (TypeError, ValueError):
        return {"valid": False, "error": f"Catalog sequence state for channel {channel!r} is invalid."}
    if sequence < last:
        return {"valid": False, "error": f"Catalog sequence {sequence} is older than accepted sequence {last} for channel {channel!r}."}
    if sequence == last and _configured_enforce_sequence():
        return {"valid": False, "error": f"Catalog sequence {sequence} was already accepted for channel {channel!r}."}
    return {"valid": True, "warnings": ()}


def _canonical_catalog_bytes(payload: object) -> bytes:
    return json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")


def _load_private_key(path: Path) -> Ed25519PrivateKey:
    private_key = serialization.load_pem_private_key(path.read_bytes(), password=None)
    if not isinstance(private_key, Ed25519PrivateKey):
        raise ValueError("Catalog signing private key must be an Ed25519 PEM private key.")
    return private_key


def _normalize_catalog_item(value: Any) -> dict[str, object]:
    if not isinstance(value, dict):
        raise ValueError("Module package catalog entries must be objects.")
    module_id = _required_str(value, "module_id")
    action = str(value.get("action") or "install")
    if action not in {"install", "update", "uninstall"}:
        raise ValueError(f"Unsupported catalog action for {module_id!r}: {action!r}")
    item = {
        "module_id": module_id,
        "name": str(value.get("name") or module_id),
        "description": _optional_str(value, "description"),
        "version": _optional_str(value, "version"),
        "action": action,
        "dependencies": _string_list(value.get("dependencies")),
        "optional_dependencies": _string_list(value.get("optional_dependencies")),
        "migration_safety": _catalog_migration_safety(value.get("migration_safety"), module_id=module_id),
        "migration_notes": _optional_str(value, "migration_notes"),
        "migration_after": _string_list(value.get("migration_after")),
        "migration_before": _string_list(value.get("migration_before")),
        "migration_tasks": _normalize_catalog_migration_tasks(value.get("migration_tasks"), module_id=module_id),
        "current_version_min": _optional_str(value, "current_version_min"),
        "current_version_max_exclusive": _optional_str(value, "current_version_max_exclusive"),
        "bridge_release": _optional_bool(value, "bridge_release"),
        "bridge_notes": _optional_str(value, "bridge_notes"),
        "allow_downgrade": _optional_bool(value, "allow_downgrade"),
        "allow_same_version": _optional_bool(value, "allow_same_version"),
        "recovery_tested": _optional_bool(value, "recovery_tested"),
        "recovery_notes": _optional_str(value, "recovery_notes"),
        "python_package": _optional_str(value, "python_package"),
        "python_ref": _optional_str(value, "python_ref"),
        "webui_package": _optional_str(value, "webui_package"),
        "webui_ref": _optional_str(value, "webui_ref"),
        "license_features": _string_list(value.get("license_features")),
        "provides_interfaces": _normalize_catalog_interface_providers(value.get("provides_interfaces"), module_id=module_id),
        "requires_interfaces": _normalize_catalog_interface_requirements(value.get("requires_interfaces"), module_id=module_id),
        "notes": _optional_str(value, "notes"),
        "tags": _string_list(value.get("tags")),
    }
    raw_architecture = value.get("architecture")
    if raw_architecture is not None:
        if not isinstance(raw_architecture, Mapping):
            raise ValueError(
                f"Module package catalog architecture for {module_id!r} must be an object."
            )
        architecture = module_architecture_from_mapping(raw_architecture)
        issues = module_architecture_issues(
            architecture,
            has_migrations=bool(item["migration_tasks"])
            or bool(item["migration_notes"]),
        )
        if issues:
            raise ValueError(
                f"Module package catalog architecture for {module_id!r} is invalid: "
                + "; ".join(issues)
            )
        item["architecture"] = architecture.to_dict()
    raw_providers = value.get("external_providers")
    if raw_providers is not None:
        if not isinstance(raw_providers, list):
            raise ValueError(
                f"Module package catalog external_providers for {module_id!r} must be a list."
            )
        providers = []
        seen_provider_ids: set[str] = set()
        for raw_provider in raw_providers:
            if not isinstance(raw_provider, Mapping):
                raise ValueError(
                    f"Module package catalog external provider entries for {module_id!r} must be objects."
                )
            provider = external_provider_from_mapping(raw_provider)
            if provider.module_id != module_id:
                raise ValueError(
                    f"Module package catalog provider {provider.id!r} belongs to "
                    f"{provider.module_id!r}, not {module_id!r}."
                )
            if provider.id in seen_provider_ids:
                raise ValueError(
                    f"Module package catalog has duplicate provider {provider.id!r}."
                )
            seen_provider_ids.add(provider.id)
            providers.append(provider)
        if providers and "architecture" not in item:
            raise ValueError(
                f"Module package catalog {module_id!r} declares external providers without architecture metadata."
            )
        if providers:
            architecture_payload = item["architecture"]
            if not isinstance(architecture_payload, Mapping):
                raise ValueError(
                    f"Module package catalog {module_id!r} has invalid architecture metadata."
                )
            architecture_modes = set(
                _string_list(architecture_payload.get("supported_authority_modes"))
            )
            provider_modes = {
                mode for provider in providers for mode in provider.authority_modes
            }
            missing_modes = provider_modes - architecture_modes
            if missing_modes:
                raise ValueError(
                    f"Module package catalog {module_id!r} provider modes are missing from architecture metadata: "
                    + ", ".join(sorted(missing_modes))
                )
        item["external_providers"] = [provider.to_dict() for provider in providers]
    if not version_range_is_valid(
        version_min=item["current_version_min"] if isinstance(item["current_version_min"], str) else None,
        version_max_exclusive=item["current_version_max_exclusive"] if isinstance(item["current_version_max_exclusive"], str) else None,
    ):
        version_range = format_version_range(
            version_min=item["current_version_min"] if isinstance(item["current_version_min"], str) else None,
            version_max_exclusive=item["current_version_max_exclusive"] if isinstance(item["current_version_max_exclusive"], str) else None,
        )
        raise ValueError(f"Module package catalog entry {module_id!r} has invalid current-version range {version_range!r}.")
    artifact_integrity = _normalize_artifact_integrity(value.get("artifact_integrity"))
    if artifact_integrity:
        item["artifact_integrity"] = artifact_integrity
    return item


def _catalog_migration_safety(value: Any, *, module_id: str) -> str:
    if value is None:
        return "automatic"
    safety = str(value).strip()
    if safety not in CATALOG_MIGRATION_SAFETY:
        allowed = ", ".join(CATALOG_MIGRATION_SAFETY)
        raise ValueError(f"Unsupported migration_safety for {module_id!r}: {safety!r}; expected one of {allowed}.")
    return safety


def _normalize_catalog_migration_tasks(value: Any, *, module_id: str) -> list[dict[str, object]]:
    if value is None:
        return []
    if not isinstance(value, list):
        raise ValueError(f"Module package catalog migration_tasks for {module_id!r} must be a list.")
    normalized: list[dict[str, object]] = []
    seen: set[str] = set()
    for raw in value:
        if not isinstance(raw, dict):
            raise ValueError(f"Module package catalog migration_tasks entries for {module_id!r} must be objects.")
        task_id = _catalog_task_required_str(raw, "task_id", module_id=module_id)
        if task_id in seen:
            raise ValueError(f"Module package catalog entry {module_id!r} declares migration task {task_id!r} more than once.")
        seen.add(task_id)
        phase = _catalog_task_required_str(raw, "phase", module_id=module_id)
        if phase not in CATALOG_MIGRATION_TASK_PHASES:
            allowed = ", ".join(CATALOG_MIGRATION_TASK_PHASES)
            raise ValueError(f"Unsupported migration task phase for {module_id!r}/{task_id!r}: {phase!r}; expected one of {allowed}.")
        summary = _catalog_task_required_str(raw, "summary", module_id=module_id)
        safety = _catalog_migration_safety(raw.get("safety"), module_id=module_id)
        item: dict[str, object] = {
            "task_id": task_id,
            "phase": phase,
            "summary": summary,
            "task_version": _optional_str(raw, "task_version") or "1",
            "safety": safety,
            "idempotent": _catalog_optional_bool_default(raw, "idempotent", default=True),
        }
        timeout_seconds = _catalog_optional_positive_int(raw, "timeout_seconds", module_id=module_id, task_id=task_id)
        if timeout_seconds is not None:
            item["timeout_seconds"] = timeout_seconds
        normalized.append(item)
    return normalized


def _catalog_task_required_str(value: dict[str, Any], key: str, *, module_id: str) -> str:
    item = _optional_str(value, key)
    if not item:
        raise ValueError(f"Module package catalog migration task for {module_id!r} is missing {key!r}.")
    return item


def _catalog_optional_bool_default(value: dict[str, Any], key: str, *, default: bool) -> bool:
    if key not in value:
        return default
    return _optional_bool(value, key)


def _catalog_optional_positive_int(value: dict[str, Any], key: str, *, module_id: str, task_id: str) -> int | None:
    if value.get(key) is None:
        return None
    try:
        integer = int(value[key])
    except (TypeError, ValueError) as exc:
        raise ValueError(f"Module package catalog migration task {module_id!r}/{task_id!r} has invalid {key!r}.") from exc
    if integer <= 0:
        raise ValueError(f"Module package catalog migration task {module_id!r}/{task_id!r} requires positive {key!r}.")
    return integer


def _required_str(value: dict[str, Any], key: str) -> str:
    item = _optional_str(value, key)
    if not item:
        raise ValueError(f"Module package catalog entry is missing {key!r}.")
    return item


def _optional_str(value: dict[str, Any], key: str) -> str | None:
    item = value.get(key)
    if item is None:
        return None
    text = str(item).strip()
    return text or None


def _string_list(value: Any) -> list[str]:
    if value is None:
        return []
    if not isinstance(value, list):
        raise ValueError("Module package catalog list fields must be lists.")
    return [str(item).strip() for item in value if str(item).strip()]


def _normalize_catalog_interface_providers(value: Any, *, module_id: str) -> list[dict[str, object]]:
    if value is None:
        return []
    if not isinstance(value, list):
        raise ValueError(f"Module package catalog provides_interfaces for {module_id!r} must be a list.")
    normalized: list[dict[str, object]] = []
    seen: set[str] = set()
    for raw in value:
        if not isinstance(raw, dict):
            raise ValueError(f"Module package catalog provides_interfaces entries for {module_id!r} must be objects.")
        name = _catalog_interface_required_str(raw, "name", module_id=module_id, field="provides_interfaces")
        version = _catalog_interface_required_str(raw, "version", module_id=module_id, field="provides_interfaces")
        _validate_catalog_interface_name(name, module_id=module_id)
        if name in seen:
            raise ValueError(f"Module package catalog entry {module_id!r} provides interface {name!r} more than once.")
        seen.add(name)
        normalized.append({"name": name, "version": version})
    return normalized


def _normalize_catalog_interface_requirements(value: Any, *, module_id: str) -> list[dict[str, object]]:
    if value is None:
        return []
    if not isinstance(value, list):
        raise ValueError(f"Module package catalog requires_interfaces for {module_id!r} must be a list.")
    normalized: list[dict[str, object]] = []
    seen: set[str] = set()
    for raw in value:
        if not isinstance(raw, dict):
            raise ValueError(f"Module package catalog requires_interfaces entries for {module_id!r} must be objects.")
        name = _catalog_interface_required_str(raw, "name", module_id=module_id, field="requires_interfaces")
        _validate_catalog_interface_name(name, module_id=module_id)
        if name in seen:
            raise ValueError(f"Module package catalog entry {module_id!r} requires interface {name!r} more than once.")
        seen.add(name)
        version_min = _optional_str(raw, "version_min")
        version_max_exclusive = _optional_str(raw, "version_max_exclusive")
        if not version_range_is_valid(version_min=version_min, version_max_exclusive=version_max_exclusive):
            version_range = format_version_range(version_min=version_min, version_max_exclusive=version_max_exclusive)
            raise ValueError(f"Module package catalog entry {module_id!r} has invalid interface range {version_range!r}.")
        item: dict[str, object] = {
            "name": name,
            "optional": _optional_bool(raw, "optional"),
        }
        if version_min is not None:
            item["version_min"] = version_min
        if version_max_exclusive is not None:
            item["version_max_exclusive"] = version_max_exclusive
        normalized.append(item)
    return normalized


def _catalog_interface_warnings(modules: tuple[dict[str, object], ...]) -> list[str]:
    providers: dict[str, list[tuple[str, str]]] = defaultdict(list)
    for module in modules:
        module_id = str(module.get("module_id") or "")
        for raw in _catalog_interface_list(module.get("provides_interfaces")):
            name = str(raw.get("name") or "")
            version = str(raw.get("version") or "")
            providers[name].append((module_id, version))

    warnings: list[str] = []
    for module in modules:
        module_id = str(module.get("module_id") or "")
        for raw in _catalog_interface_list(module.get("requires_interfaces")):
            name = str(raw.get("name") or "")
            version_min = raw.get("version_min") if isinstance(raw.get("version_min"), str) else None
            version_max_exclusive = raw.get("version_max_exclusive") if isinstance(raw.get("version_max_exclusive"), str) else None
            optional = raw.get("optional") is True
            available = providers.get(name, [])
            matching = [
                (provider_module_id, version)
                for provider_module_id, version in available
                if version_satisfies_range(
                    version,
                    version_min=version_min,
                    version_max_exclusive=version_max_exclusive,
                )
            ]
            if matching:
                continue
            version_range = format_version_range(
                version_min=version_min,
                version_max_exclusive=version_max_exclusive,
            )
            if not available:
                if optional:
                    continue
                warnings.append(
                    f"Catalog module {module_id!r} requires interface {name!r} ({version_range}), "
                    "but no catalog entry provides it; activation may still succeed if an installed module provides it."
                )
                continue
            provider_summary = ", ".join(f"{provider_module_id}@{version}" for provider_module_id, version in available)
            warnings.append(
                f"Catalog module {module_id!r} requires interface {name!r} ({version_range}), "
                f"but catalog providers are {provider_summary}."
            )
    return warnings


def _catalog_interface_list(value: object) -> list[dict[str, object]]:
    if not isinstance(value, list):
        return []
    return [item for item in value if isinstance(item, dict)]


def _catalog_interface_required_str(value: dict[str, Any], key: str, *, module_id: str, field: str) -> str:
    item = _optional_str(value, key)
    if not item:
        raise ValueError(f"Module package catalog {field} entry for {module_id!r} is missing {key!r}.")
    return item


def _validate_catalog_interface_name(name: str, *, module_id: str) -> None:
    if not _INTERFACE_NAME_RE.match(name):
        raise ValueError(f"Module package catalog entry {module_id!r} has invalid interface name {name!r}.")


def _optional_bool(value: dict[str, Any], key: str) -> bool:
    item = value.get(key)
    if item is None:
        return False
    if not isinstance(item, bool):
        raise ValueError(f"Module package catalog boolean field {key!r} must be true or false.")
    return item


def _normalize_artifact_integrity(value: Any) -> dict[str, object]:
    if value is None:
        return {}
    if not isinstance(value, dict):
        raise ValueError("Module package catalog artifact_integrity must be an object.")
    normalized: dict[str, object] = {}
    for key in ("python", "webui"):
        raw = value.get(key)
        if raw is None:
            continue
        if not isinstance(raw, dict):
            raise ValueError(f"Module package catalog artifact_integrity.{key} must be an object.")
        clean = {
            field: text
            for field in (
                "ref",
                "path",
                "artifact_path",
                "sha256",
                "sbom_url",
                "provenance_url",
                "registry_identity",
                "git_ref",
            )
            if (text := _optional_str(raw, field))
        }
        if clean:
            normalized[key] = clean
    return normalized


def _parse_datetime(value: object) -> datetime | None:
    if value is None:
        return None
    text = str(value).strip()
    if not text:
        return None
    if text.endswith("Z"):
        text = text[:-1] + "+00:00"
    try:
        parsed = datetime.fromisoformat(text)
    except ValueError as exc:
        raise ValueError(f"Invalid datetime value: {value!r}") from exc
    if parsed.tzinfo is None:
        parsed = parsed.replace(tzinfo=UTC)
    return parsed.astimezone(UTC)


def _catalog_source_exists(source: Path | str) -> bool:
    if isinstance(source, str) and _is_http_url(source):
        return True
    return Path(source).exists()


def _catalog_source_type(source: Path | str | None) -> str | None:
    if source is None:
        return None
    return "url" if isinstance(source, str) and _is_http_url(source) else "file"


def _is_http_url(value: str) -> bool:
    return is_http_url(value)


def _invalid_catalog_result(
    source: Path | str | None,
    *,
    modules: tuple[dict[str, object], ...],
    channel: str | None,
    sequence: int | None,
    generated_at: str | None,
    not_before: str | None,
    expires_at: str | None,
    signature_state: dict[str, object],
    read_state: dict[str, object],
    error: str,
) -> dict[str, object]:
    return {
        "valid": False,
        "configured": source is not None,
        "path": str(source) if source is not None else None,
        "source": str(source) if source is not None else None,
        "source_type": _catalog_source_type(source),
        "cache_used": bool(read_state.get("cache_used")),
        "cache_path": read_state.get("cache_path") if isinstance(read_state.get("cache_path"), str) else None,
        "modules": list(modules),
        "channel": channel,
        "sequence": sequence,
        "generated_at": generated_at,
        "not_before": not_before,
        "expires_at": expires_at,
        "signed": signature_state["signed"],
        "trusted": signature_state["trusted"],
        "key_id": signature_state["key_id"],
        "warnings": [],
        "error": error,
    }
