"""App Block canonical model (9B). Ontology only — no install tables or resolver rewrite.

YAML manifests remain the authored catalog. This module validates them into one
in-memory contract used by catalog load, then by planner/resolver via the registry.
It does not own Forms/Cloud runtime, Feature Pack persistence, or leftover /api/contact.
"""

from __future__ import annotations

from typing import Literal

from pydantic import BaseModel, ConfigDict, Field

from src.domain.app_blocks.catalogs import FEATURE_PACK_ALIASES, canonical_feature_pack
from src.domain.app_blocks.errors import InvalidAppBlockManifestError, UnknownAppBlockError
from src.domain.app_blocks.schemas import AppBlockManifest, DependencyRef, ProviderBinding
from src.domain.feature_packs.contract import RESOLVABLE_PRIMITIVES

APP_BLOCK_SCHEMA_VERSION = 1
APP_BLOCK_KIND = "app_block"

# Authored catalog: src/domain/app_blocks/manifests/*.v1.yaml
CANONICAL_BLOCK_IDS: frozenset[str] = frozenset(
    {
        "forms.contact",
        "forms.quote_request",
        "crm.lead",
        "crm.customer",
        "crm.pipeline",
        "case.management",
        "admin.shell",
        "admin.record_table",
        "admin.record_detail",
        "auth.sign_in",
        "auth.profile",
        "workflow.assignment",
        "task.management",
        "activity.timeline",
        "notifications.in_app",
        "files.attachments",
        "documents",
        "booking.appointment",
        "support.ticket",
        "catalog.product",
        "orders.order",
        "billing.invoice",
        "inventory.stock",
        "membership.member",
        "workflow.approval",
        "reporting.dashboard",
        "crm.organization",
        "crm.team",
        "crm.org_unit",
        "crm.quote",
        "collaboration.comment",
        "requirements.checklist",
        "collaboration.conversation",
        "governance.group",
        "customer_success.account",
        "contract.contract",
        "workforce.worker",
        "property.unit",
        "asset.serialized",
        "lab.sample",
        "lab.result_review",
        "data.import",
        "education.student",
        "content.publishing",
        "inventory.warehouse",
        "logistics.shipment",
        "manufacturing.work_order",
        "finance.loan_operations",
        "aviation.maintenance",
        "billing.subscription_operations",
        "billing.portal",
        "billing.tax",
        "education.course_delivery",
        "education.progress",
        "education.assessment",
        "education.certificate",
        "education.attendance",
        "payments.checkout",
        "visualization.chart",
        "calculation.calculator",
        "content.collection",
        "content.testimonials",
        "content.faq",
        "content.services",
        "content.team",
        "content.locations",
        "content.pricing",
        "content.gallery",
        "content.events",
    }
)

# Runtime identity for Forms. Never equal to block_id. Not a lookup key for YAML.
FORM_RUNTIME_BY_BLOCK: dict[str, str] = {
    "forms.contact": "contact",
    "forms.quote_request": "quote_request",
}

# Pack alias keys that are also a real block id (kind-scoped: feature_pack vs app_block).
DUAL_NAMESPACE_IDS: frozenset[str] = frozenset(
    {
        "crm.lead",
        "booking.appointment",
        "support.ticket",
        "workflow.approval",
        "payments.checkout",
    }
)

# email.transactional is not a second delivery verb. Authored YAML uses email.send.
# Keep the alias so older manifests and tests still canonicalize at this boundary.
LEGACY_CAPABILITY_ALIASES: dict[str, str] = {
    "email.transactional": "email.send",
    "payment.checkout.create": "payments.checkout.create",
    "payment.capture": "payments.order.capture",
    "payment.refund": "payments.refund.create",
    "payment.catalog.upsert": "payments.catalog.upsert",
    "notification.sms": "messaging.sms.send",
}
LEGACY_CAPABILITY_POLICY: Literal["legacy_alias_deprecated"] = "legacy_alias_deprecated"
CANONICAL_EMAIL_CAPABILITY = "email.send"

# kind=primitive may name kernel primitives or optional invoke capabilities.
OPTIONAL_INVOKE_CAPABILITIES: frozenset[str] = frozenset(
    {
        "email.send",
        "crm.contact.upsert",
        "payments.checkout.create",
        "payments.order.capture",
        "payments.refund.create",
        "payments.catalog.upsert",
        "payments.subscription",
    }
)
CANONICAL_REQUIREMENT_CAPABILITIES: frozenset[str] = (
    RESOLVABLE_PRIMITIVES | OPTIONAL_INVOKE_CAPABILITIES
)

REQUIREMENTS_SEMANTICS: dict[str, str] = {
    "requires": "composition is not satisfiable without the declared dependency",
    "optional_requires": (
        "enhancement; absence must not make the block invalid. "
        "9B defines this contract; resolver/planner consumption is a later slice."
    ),
}

BLOCK_ID_PATTERN = r"^[a-z][a-z0-9]*(?:\.[a-z][a-z0-9_]*)+$"

APP_BLOCK_CANONICAL_MODEL: dict[str, object] = {
    "slice_id": "9b_app_block_canonical_model",
    "status": "complete",
    "catalog_authority": "core_yaml_only",
    "catalog_path": "src/domain/app_blocks/manifests/*.v1.yaml",
    "durable_install_authority": "feature_packs",
    "runtime_identity_authority": "domain_runtime",
    "forms_runtime_identity": "form_key",
    "block_pack_identity_separation": "explicit",
    "capability_vocabulary": "canonicalized",
    "email_transactional_policy": LEGACY_CAPABILITY_POLICY,
    "email_transactional_canonical": CANONICAL_EMAIL_CAPABILITY,
    "optional_requires_semantics": "defined_not_consumed",
    "feature_pack_aliases_not_blocks": True,
    "shared_pack_relationship": "many_blocks_to_one_pack",
    "dual_namespace_ids": tuple(sorted(DUAL_NAMESPACE_IDS)),
    "database_schema_change": False,
    "runtime_change": False,
    "codegen_behavior_change": False,
    "legacy_contact_removal": False,
    "legacy_contact_guard": "compatibility_contradiction_not_9b",
    "docs": "docs/audits/APP_BLOCKS_CANONICAL_9B.md",
}


def feature_pack_only_aliases() -> frozenset[str]:
    """Pack aliases that must never resolve as App Blocks."""
    return frozenset(FEATURE_PACK_ALIASES) - CANONICAL_BLOCK_IDS


def is_app_block_id(block_id: str) -> bool:
    return block_id in CANONICAL_BLOCK_IDS


def canonicalize_capability_id(raw_id: str) -> tuple[str, str | None]:
    """Return (canonical_id, deprecated_alias_or_none)."""
    token = (raw_id or "").strip()
    if not token:
        raise InvalidAppBlockManifestError("capability id must be non-empty")
    if token in LEGACY_CAPABILITY_ALIASES:
        return LEGACY_CAPABILITY_ALIASES[token], token
    return token, None


class CanonicalRequirement(BaseModel):
    model_config = ConfigDict(extra="forbid")
    kind: Literal["primitive", "feature_pack", "app_block"]
    id: str
    source_id: str
    role: Literal["required", "optional"]
    satisfiable_without: bool = False


class CanonicalInstallation(BaseModel):
    """How the block maps onto durable Feature Pack install. Not a block install API."""

    model_config = ConfigDict(extra="forbid")
    mechanism: Literal["feature_pack", "none"]
    feature_packs: tuple[str, ...] = ()


class CanonicalRuntime(BaseModel):
    """App Blocks do not own runtime state. Forms identity is form_key, not block id."""

    model_config = ConfigDict(extra="forbid")
    ownership: Literal["external", "none"] = "none"
    identity_kind: Literal["form_key", "none"] = "none"
    identity: str | None = None


class CanonicalAppBlock(BaseModel):
    model_config = ConfigDict(extra="forbid")
    schema_version: Literal[1] = 1
    kind: Literal["app_block"] = "app_block"
    id: str
    version: str
    family: str
    title: str
    description: str
    requires: tuple[CanonicalRequirement, ...] = ()
    optional_requires: tuple[CanonicalRequirement, ...] = ()
    installation: CanonicalInstallation
    runtime: CanonicalRuntime
    deprecated_capability_aliases: tuple[str, ...] = ()
    manifest: AppBlockManifest = Field(exclude=True)


class CanonicalCatalog(BaseModel):
    model_config = ConfigDict(extra="forbid")
    schema_version: Literal[1] = 1
    authority: Literal["core_yaml"] = "core_yaml"
    blocks: tuple[CanonicalAppBlock, ...]

    @property
    def ids(self) -> frozenset[str]:
        return frozenset(block.id for block in self.blocks)

    def get(self, block_id: str) -> CanonicalAppBlock | None:
        if block_id in feature_pack_only_aliases():
            return None
        for block in self.blocks:
            if block.id == block_id:
                return block
        return None

    def require(self, block_id: str) -> CanonicalAppBlock:
        found = self.get(block_id)
        if found is None:
            raise UnknownAppBlockError(block_id)
        return found

    def manifests(self) -> list[AppBlockManifest]:
        return [block.manifest for block in self.blocks]

    def blocks_for_feature_pack(self, slug: str) -> tuple[str, ...]:
        canonical_slug = canonical_feature_pack(slug)
        return tuple(
            block.id
            for block in self.blocks
            if canonical_slug in block.installation.feature_packs
        )


def _map_dep(dep: DependencyRef, role: Literal["required", "optional"]) -> CanonicalRequirement:
    if dep.kind == "primitive":
        canonical_id, _alias = canonicalize_capability_id(dep.id)
        if canonical_id not in CANONICAL_REQUIREMENT_CAPABILITIES:
            raise InvalidAppBlockManifestError(
                f"unknown capability {dep.id!r} (canonical {canonical_id!r}) "
                f"in {role} primitive requirement"
            )
        return CanonicalRequirement(
            kind=dep.kind,
            id=canonical_id,
            source_id=dep.id,
            role=role,
            satisfiable_without=role == "optional",
        )
    if not dep.id.strip():
        raise InvalidAppBlockManifestError(f"{role} dependency id must be non-empty")
    if dep.kind == "app_block" and dep.id in feature_pack_only_aliases():
        raise InvalidAppBlockManifestError(
            f"{dep.id} is a Feature Pack alias, not an App Block"
        )
    if dep.kind == "feature_pack":
        slug = canonical_feature_pack(dep.id)
        return CanonicalRequirement(
            kind=dep.kind,
            id=slug,
            source_id=dep.id,
            role=role,
            satisfiable_without=role == "optional",
        )
    return CanonicalRequirement(
        kind=dep.kind,
        id=dep.id,
        source_id=dep.id,
        role=role,
        satisfiable_without=role == "optional",
    )


def _installation_from(requires: tuple[CanonicalRequirement, ...]) -> CanonicalInstallation:
    slugs: list[str] = []
    seen: set[str] = set()
    for dep in requires:
        if dep.kind != "feature_pack":
            continue
        if dep.id not in seen:
            seen.add(dep.id)
            slugs.append(dep.id)
    if not slugs:
        return CanonicalInstallation(mechanism="none", feature_packs=())
    return CanonicalInstallation(mechanism="feature_pack", feature_packs=tuple(slugs))


def _runtime_from(block_id: str) -> CanonicalRuntime:
    form_key = FORM_RUNTIME_BY_BLOCK.get(block_id)
    if form_key is None:
        return CanonicalRuntime()
    return CanonicalRuntime(
        ownership="external",
        identity_kind="form_key",
        identity=form_key,
    )


def _binding_capability(binding: ProviderBinding) -> tuple[str, str | None]:
    canonical_id, alias = canonicalize_capability_id(binding.capability)
    if canonical_id not in CANONICAL_REQUIREMENT_CAPABILITIES:
        raise InvalidAppBlockManifestError(
            f"unknown provider_bindings capability {binding.capability!r}"
        )
    return canonical_id, alias


def canonicalize_manifest(manifest: AppBlockManifest) -> CanonicalAppBlock:
    if manifest.id in feature_pack_only_aliases():
        raise InvalidAppBlockManifestError(
            f"{manifest.id} is a Feature Pack alias, not an App Block"
        )
    if manifest.id in FORM_RUNTIME_BY_BLOCK.values():
        raise InvalidAppBlockManifestError(
            f"{manifest.id} collides with a Forms runtime form_key"
        )
    mapped = FORM_RUNTIME_BY_BLOCK.get(manifest.id)
    if mapped == manifest.id:
        raise InvalidAppBlockManifestError(
            f"App Block id {manifest.id} cannot be its own form_key"
        )
    aliases: list[str] = []
    requires = tuple(_map_dep(dep, "required") for dep in manifest.requires)
    optionals = tuple(_map_dep(dep, "optional") for dep in manifest.optional_requires)
    for dep in (*manifest.requires, *manifest.optional_requires):
        if dep.kind != "primitive":
            continue
        _canonical, alias = canonicalize_capability_id(dep.id)
        if alias:
            aliases.append(alias)
    for binding in manifest.provider_bindings:
        _canonical, alias = _binding_capability(binding)
        if alias:
            aliases.append(alias)
    return CanonicalAppBlock(
        schema_version=APP_BLOCK_SCHEMA_VERSION,
        kind=APP_BLOCK_KIND,
        id=manifest.id,
        version=manifest.version,
        family=manifest.family,
        title=manifest.title,
        description=manifest.description,
        requires=requires,
        optional_requires=optionals,
        installation=_installation_from(requires),
        runtime=_runtime_from(manifest.id),
        deprecated_capability_aliases=tuple(dict.fromkeys(aliases)),
        manifest=manifest,
    )


def compile_canonical_catalog(manifests: list[AppBlockManifest]) -> CanonicalCatalog:
    seen: dict[str, str] = {}
    blocks: list[CanonicalAppBlock] = []
    for manifest in manifests:
        if manifest.id in seen:
            raise InvalidAppBlockManifestError(
                f"duplicate App Block id {manifest.id}"
            )
        seen[manifest.id] = manifest.id
        blocks.append(canonicalize_manifest(manifest))
    catalog = CanonicalCatalog(blocks=tuple(blocks))
    if catalog.ids != CANONICAL_BLOCK_IDS and set(seen) <= CANONICAL_BLOCK_IDS:
        # Tests may pass a subset; only the default catalog must match exactly.
        pass
    return catalog


def compile_default_catalog(manifests: list[AppBlockManifest]) -> CanonicalCatalog:
    catalog = compile_canonical_catalog(manifests)
    if catalog.ids != CANONICAL_BLOCK_IDS:
        raise InvalidAppBlockManifestError(
            f"default App Block catalog must be exactly {sorted(CANONICAL_BLOCK_IDS)}, "
            f"got {sorted(catalog.ids)}"
        )
    return catalog
