"""Lift legacy composition JSON onto FeaturePackContract V2.

Catalog overlays win for semantic fields (provides, events, field protection).
Legacy ``requires`` / ``requires_packs`` / ``requires_connections`` are classified
when the stored blob is still V1.
"""
from __future__ import annotations

from typing import Any

from src.domain.feature_packs.catalog_v2 import overlay_for
from src.domain.feature_packs.contract import (
    FEATURE_PACK_CONTRACT_VERSION,
    FeaturePackContract,
    PackCompatibility,
    PackDependencyRef,
    PackEntityContract,
    PackEntityIngestBinding,
    PackEvent,
    PackPlannerMeta,
    PackProviderBinding,
    PackSecurity,
    PackWebhookBinding,
    PackWorkflowContract,
    ProvidedCapability,
    classify_legacy_token,
    validate_workflow_transitions,
)
from src.domain.calculation.ir import CalculationIR
from src.domain.feature_packs.events import (
    entity_fields_from_composition,
    runtime_domain_event_payload,
    validate_pack_event_mappings,
)
from src.domain.feature_packs.ownership import validate_composition_ownership_contracts


def _as_dep_list(value: Any) -> list[PackDependencyRef]:
    deps: list[PackDependencyRef] = []
    for item in value or []:
        if isinstance(item, PackDependencyRef):
            deps.append(item)
        elif isinstance(item, dict):
            deps.append(PackDependencyRef.model_validate(item))
        elif isinstance(item, str):
            classified = classify_legacy_token(item)
            if isinstance(classified, PackDependencyRef):
                deps.append(classified)
    return _unique_deps(deps)


def _as_bindings(value: Any) -> list[PackProviderBinding]:
    bindings: list[PackProviderBinding] = []
    for item in value or []:
        if isinstance(item, PackProviderBinding):
            bindings.append(item)
        elif isinstance(item, dict):
            bindings.append(PackProviderBinding.model_validate(item))
        elif isinstance(item, str):
            classified = classify_legacy_token(item)
            if isinstance(classified, PackProviderBinding):
                bindings.append(classified)
    return _unique_bindings(bindings)


def _unique_deps(deps: list[PackDependencyRef]) -> list[PackDependencyRef]:
    seen: set[tuple[str, str]] = set()
    out: list[PackDependencyRef] = []
    for dep in deps:
        key = (dep.kind, dep.id)
        if key not in seen:
            seen.add(key)
            out.append(dep)
    return out


def _unique_bindings(bindings: list[PackProviderBinding]) -> list[PackProviderBinding]:
    seen: set[tuple[str, tuple[str, ...]]] = set()
    out: list[PackProviderBinding] = []
    for binding in bindings:
        key = (binding.capability, tuple(binding.providers))
        if key not in seen:
            seen.add(key)
            out.append(binding)
    return out


def _legacy_tokens(
    composition: dict[str, Any],
) -> tuple[list[PackDependencyRef], list[PackProviderBinding]]:
    deps: list[PackDependencyRef] = []
    bindings: list[PackProviderBinding] = []

    for token in composition.get("requires_packs") or []:
        if isinstance(token, str):
            deps.append(PackDependencyRef(kind="feature_pack", id=token))

    connection_tokens = composition.get("requires_connections")
    require_tokens = composition.get("requires") or []
    # Empty list is falsy; only fall back to ``requires`` when the explicit
    # connections key is absent — not when it is intentionally [].
    if connection_tokens is None:
        source = require_tokens
        extra_requires: list[Any] = []
    else:
        source = connection_tokens
        extra_requires = require_tokens if isinstance(require_tokens, list) else []

    for token in extra_requires:
        _absorb_token(token, deps, bindings)

    for token in source or []:
        _absorb_token(token, deps, bindings)

    return _unique_deps(deps), _unique_bindings(bindings)


def _absorb_token(
    token: Any,
    deps: list[PackDependencyRef],
    bindings: list[PackProviderBinding],
) -> None:
    if isinstance(token, dict):
        if token.get("kind") in {"primitive", "feature_pack"}:
            deps.append(PackDependencyRef.model_validate(token))
        else:
            bindings.append(PackProviderBinding.model_validate(token))
        return
    if not isinstance(token, str):
        return
    classified = classify_legacy_token(token)
    if isinstance(classified, PackProviderBinding):
        bindings.append(classified)
    else:
        deps.append(classified)


def _provides(value: Any) -> list[ProvidedCapability]:
    out: list[ProvidedCapability] = []
    for item in value or []:
        if isinstance(item, ProvidedCapability):
            out.append(item)
        else:
            out.append(ProvidedCapability.model_validate(item))
    return out


def _planner(value: Any, *, legacy_aggregate: bool) -> PackPlannerMeta:
    if isinstance(value, PackPlannerMeta):
        planner = value
    elif isinstance(value, dict) and value:
        planner = PackPlannerMeta.model_validate(value)
    else:
        planner = PackPlannerMeta()
    if legacy_aggregate:
        return planner.model_copy(
            update={"selectable": False, "preferred_for_new_projects": False}
        )
    return planner


def _webhooks(value: Any) -> list[PackWebhookBinding]:
    out: list[PackWebhookBinding] = []
    for item in value or []:
        if isinstance(item, PackWebhookBinding):
            out.append(item)
        elif isinstance(item, dict) and item.get("target") and item.get("forward_path"):
            out.append(PackWebhookBinding.model_validate(item))
    return out


def _entity_ingest_bindings(value: Any) -> list[PackEntityIngestBinding]:
    out: list[PackEntityIngestBinding] = []
    for item in value or []:
        if isinstance(item, PackEntityIngestBinding):
            out.append(item)
        elif isinstance(item, dict) and item.get("entity_key") and item.get("webhook_provider"):
            out.append(PackEntityIngestBinding.model_validate(item))
    return out


def _events(value: Any) -> list[PackEvent]:
    events: list[PackEvent] = []
    for item in value or []:
        if isinstance(item, PackEvent):
            events.append(item)
        elif isinstance(item, str):
            events.append(PackEvent(id=item))
        elif isinstance(item, dict):
            events.append(PackEvent.model_validate(item))
    seen: set[str] = set()
    out: list[PackEvent] = []
    for event in events:
        if event.id not in seen:
            seen.add(event.id)
            out.append(event)
    return out


def load_pack_contract(
    slug: str,
    composition: dict[str, Any] | None,
    *,
    access_policy: dict[str, Any] | None = None,
    implementation_version: str | None = None,
) -> FeaturePackContract:
    blob = dict(composition or {})
    overlay = overlay_for(slug)
    already_v2 = blob.get("schema_version") == FEATURE_PACK_CONTRACT_VERSION

    if already_v2:
        deps = _as_dep_list(blob.get("requires"))
        optionals = _as_dep_list(blob.get("optional_requires"))
        bindings = _as_bindings(blob.get("provider_bindings"))
    else:
        deps, bindings = _legacy_tokens(blob)
        optionals = _as_dep_list(blob.get("optional_requires"))

    deps = _merge_deps(_as_dep_list(overlay.get("requires")), deps)
    optionals = _merge_deps(_as_dep_list(overlay.get("optional_requires")), optionals)
    bindings = _merge_bindings(_as_bindings(overlay.get("provider_bindings")), bindings)

    provides = _provides(overlay.get("provides") or blob.get("provides"))
    events = _events(overlay.get("events") or blob.get("events"))
    entities = _entities(overlay.get("entities") or blob.get("entity_contracts"))
    calculators = _calculators(overlay.get("calculators") or blob.get("calculators"))
    workflows = _workflows(overlay.get("workflows") or blob.get("workflows"))
    webhook_bindings = _webhooks(
        overlay.get("webhook_bindings") or blob.get("webhook_bindings")
    )
    entity_ingest_bindings = _entity_ingest_bindings(
        overlay.get("entity_ingest_bindings") or blob.get("entity_ingest_bindings")
    )

    security_raw = overlay.get("security") or blob.get("security") or {}
    if isinstance(security_raw, PackSecurity):
        security = security_raw
    else:
        security = PackSecurity.model_validate(security_raw or {})

    compat_raw = overlay.get("compatibility") or blob.get("compatibility") or {}
    legacy_aggregate = bool(
        overlay.get("legacy_aggregate")
        or (compat_raw or {}).get("legacy_aggregate")
        if isinstance(compat_raw, dict)
        else getattr(compat_raw, "legacy_aggregate", False)
    )
    if isinstance(compat_raw, PackCompatibility):
        compatibility = compat_raw.model_copy(
            update={
                "implementation_version": (
                    implementation_version or compat_raw.implementation_version
                ),
                "legacy_aggregate": legacy_aggregate,
            }
        )
    else:
        payload = dict(compat_raw or {})
        payload.setdefault("contract_version", 2)
        payload["legacy_aggregate"] = legacy_aggregate
        if implementation_version:
            payload["implementation_version"] = implementation_version
        compatibility = PackCompatibility.model_validate(payload)

    _ = access_policy
    return FeaturePackContract(
        slug=slug,
        provides=provides,
        requires=deps,
        optional_requires=optionals,
        provider_bindings=bindings,
        events=events,
        entities=entities,
        calculators=calculators,
        workflows=workflows,
        security=security,
        compatibility=compatibility,
        planner=_planner(
            overlay.get("planner") or blob.get("planner"),
            legacy_aggregate=legacy_aggregate,
        ),
        webhook_bindings=webhook_bindings,
        entity_ingest_bindings=entity_ingest_bindings,
    )


def _merge_deps(
    preferred: list[PackDependencyRef], fallback: list[PackDependencyRef]
) -> list[PackDependencyRef]:
    seen = {(dep.kind, dep.id) for dep in preferred}
    out = list(preferred)
    for dep in fallback:
        if (dep.kind, dep.id) not in seen:
            out.append(dep)
            seen.add((dep.kind, dep.id))
    return out


def _merge_bindings(
    preferred: list[PackProviderBinding], fallback: list[PackProviderBinding]
) -> list[PackProviderBinding]:
    seen = {item.capability for item in preferred}
    out = list(preferred)
    for item in fallback:
        if item.capability not in seen:
            out.append(item)
            seen.add(item.capability)
    return out


def _entities(value: Any) -> list[PackEntityContract]:
    out: list[PackEntityContract] = []
    for item in value or []:
        if isinstance(item, PackEntityContract):
            out.append(item)
        elif isinstance(item, dict) and item.get("entity_key"):
            out.append(
                PackEntityContract(
                    entity_key=str(item["entity_key"]),
                    schema_mode=item.get("schema_mode") or "extensible",
                    workflow_configurable=bool(item.get("workflow_configurable")),
                )
            )
    return out


def _calculators(value: Any) -> list[CalculationIR]:
    out: list[CalculationIR] = []
    for item in value or []:
        if isinstance(item, CalculationIR):
            out.append(item)
        elif isinstance(item, dict):
            out.append(CalculationIR.model_validate(item))
    return out


def _workflows(value: Any) -> list[PackWorkflowContract]:
    out: list[PackWorkflowContract] = []
    for item in value or []:
        if isinstance(item, PackWorkflowContract):
            out.append(item)
        elif isinstance(item, dict):
            out.append(PackWorkflowContract.model_validate(item))
    return out


def stamp_composition(
    slug: str,
    composition: dict[str, Any] | None,
    *,
    access_policy: dict[str, Any] | None = None,
    implementation_version: str | None = None,
) -> dict[str, Any]:
    """Return composition JSON with V2 contract keys and no overloaded requires."""
    blob = dict(composition or {})
    contract = load_pack_contract(
        slug,
        blob,
        access_policy=access_policy,
        implementation_version=implementation_version,
    )
    stamped = {**blob, **contract.to_composition_fields()}
    stamped.pop("requires_packs", None)
    stamped.pop("requires_connections", None)
    stamped.pop("requires_capabilities", None)
    _apply_workflows_to_entities(stamped, contract.workflows)
    _apply_field_policy(stamped, overlay_for(slug).get("field_policy") or {})
    _apply_entity_schema_mode(stamped, contract.entities)
    _apply_webhook_bindings(stamped, contract.webhook_bindings)
    entity_fields = entity_fields_from_composition(stamped)
    validate_pack_event_mappings(contract, entity_fields=entity_fields)
    _validate_entity_ingest_bindings(contract.entity_ingest_bindings, entity_fields=entity_fields)
    _apply_domain_events_to_entities(stamped, contract.events)
    validate_composition_ownership_contracts(stamped)
    return stamped


def _validate_entity_ingest_bindings(
    bindings: list[PackEntityIngestBinding], *, entity_fields: dict[str, set[str]]
) -> None:
    """Catch a typo'd identity field at Core-test time, same discipline as
    _apply_workflows_to_entities for workflow fields — Cloud's schema-push
    re-validation is a backstop, not the first line of defense."""
    for binding in bindings:
        fields = entity_fields.get(binding.entity_key)
        if fields is None:
            continue
        missing = [key for key in binding.identity_fields if key not in fields]
        if missing:
            raise ValueError(
                f"entity_ingest_binding for '{binding.entity_key}': identity_fields "
                f"not declared on the entity: {', '.join(missing)}"
            )
        if binding.default_status_field and binding.default_status_field not in fields:
            raise ValueError(
                f"entity_ingest_binding for '{binding.entity_key}': default_status_field "
                f"'{binding.default_status_field}' is not declared on the entity"
            )


def _apply_workflows_to_entities(
    composition: dict[str, Any], workflows: list[PackWorkflowContract]
) -> None:
    """Stamp each declared PackWorkflowContract onto its entity's schema, validated
    against that entity's own declared field options -- the same canonical check
    Level-1 synthesized packs get in composer.py's derive_pack_composition. Hand-
    authored catalog_v2/migration-seeded packs (lead-lifecycle, case-lifecycle,
    approval-workflow, support-inbox) previously reached Cloud with zero Core-side
    validation of their transition graphs, relying entirely on Cloud's schema-push-
    time re-validation (records.ts) as the only backstop. Failing here instead means
    a typo'd state name in a catalog pack is caught by Core tests, not discovered
    only when Cloud rejects (or silently mis-enforces) the pushed schema.
    """
    by_key = {item.entity_key: item for item in workflows}
    for entity in composition.get("entities") or []:
        if not isinstance(entity, dict):
            continue
        wf = by_key.get(str(entity.get("entity_key") or ""))
        if wf is None:
            continue
        schema = entity.setdefault("schema", {})
        if not isinstance(schema, dict) or "workflow" in schema:
            continue
        fields = schema.get("fields") or []
        target = next(
            (f for f in fields if isinstance(f, dict) and f.get("key") == wf.field),
            None,
        )
        if target is None:
            raise ValueError(
                f"workflow field '{wf.field}' is not declared in {entity.get('entity_key')}'s fields"
            )
        normalized_transitions = validate_workflow_transitions(
            target.get("type"), target.get("options"), wf.transitions
        )
        schema["workflow"] = {
            "field": wf.field,
            "transitions": normalized_transitions,
        }


def _apply_field_policy(
    composition: dict[str, Any], field_policy: dict[str, Any]
) -> None:
    for entity in composition.get("entities") or []:
        if not isinstance(entity, dict):
            continue
        policy = field_policy.get(str(entity.get("entity_key") or "")) or {}
        schema = entity.get("schema")
        if not isinstance(schema, dict):
            continue
        schema.pop("protected_fields", None)
        for field in schema.get("fields") or []:
            if not isinstance(field, dict):
                continue
            flags = policy.get(str(field.get("key") or ""))
            if not isinstance(flags, dict):
                continue
            field.update(flags)


def _apply_entity_schema_mode(
    composition: dict[str, Any], entities: list[PackEntityContract]
) -> None:
    by_key = {item.entity_key: item for item in entities}
    for entity in composition.get("entities") or []:
        if not isinstance(entity, dict):
            continue
        meta = by_key.get(str(entity.get("entity_key") or ""))
        if meta is None:
            continue
        schema = entity.setdefault("schema", {})
        if isinstance(schema, dict):
            schema["schema_mode"] = meta.schema_mode
            schema["workflow_configurable"] = meta.workflow_configurable


def _apply_domain_events_to_entities(
    composition: dict[str, Any], events: list[PackEvent]
) -> None:
    """Stamp emittable PackEvents onto the owning entity's schema.domain_events.

    Same channel as schema.workflow / access_policy. Non-emittable declarations
    are not transported — Cloud must not see names it could infer from.
    """
    by_entity: dict[str, list[dict[str, Any]]] = {}
    seen: dict[str, set[str]] = {}
    for event in events:
        payload = runtime_domain_event_payload(event)
        if payload is None:
            continue
        entity_key = event.producer.entity if event.producer else None
        if not entity_key:
            continue
        owned = seen.setdefault(entity_key, set())
        if event.id in owned:
            continue
        owned.add(event.id)
        by_entity.setdefault(entity_key, []).append(payload)
    for entity in composition.get("entities") or []:
        if not isinstance(entity, dict):
            continue
        key = str(entity.get("entity_key") or "")
        runtime = by_entity.get(key)
        if not runtime:
            continue
        schema = entity.setdefault("schema", {})
        if isinstance(schema, dict):
            schema["domain_events"] = runtime


def _apply_webhook_bindings(
    composition: dict[str, Any], bindings: list[PackWebhookBinding]
) -> None:
    if not bindings:
        return
    composition["webhooks"] = [
        {
            "provider": item.provider,
            "event_types": list(item.events),
            "target": item.target,
            "forward_path": item.forward_path,
            "protected": item.protected,
        }
        for item in bindings
    ]


def derived_field_keys(schema: dict[str, Any], flag: str) -> list[str]:
    keys: list[str] = []
    for field in schema.get("fields") or []:
        if isinstance(field, dict) and field.get(flag) and field.get("key"):
            keys.append(str(field["key"]))
    return keys
