"""Compile a Codegen install plan from Discovery intent. Deterministic.

Plans are compiled from the canonical in-memory catalog (validated YAML),
not by re-parsing manifest files ad hoc. Natural-language matching policy
belongs to the injected ``CapabilityPlanner`` port, not this compiler.
"""

import json

from src.domain.app_blocks.catalogs import default_known_packs, default_primitives
from src.domain.app_blocks.composition_resolve import resolve_composition
from src.domain.app_blocks.planner import (
    default_slots,
    product_intent_from_spec,
    select_capabilities,
)
from src.domain.app_blocks.ports import CapabilityPlanner
from src.domain.app_blocks.registry import AppBlockRegistry, load_default_registry
from src.domain.app_blocks.resolver import planned_from_manifest, resolve_block
from src.domain.app_blocks.schemas import AppBlockGap, AppBlockPlan, PlannedBlock
from src.domain.app_blocks.vertical_recipes import match_recipe_for_intent
from src.domain.calculation.packs import calculator_packs_for_text
from src.domain.composition_recipes.schemas import CompositionRecipe
from src.domain.projects.discovery_schemas import DiscoverySpec


class PinnedEmptyRecipe:
    """Discovery pinned no recipe. Do not fall back to a YAML match."""


PINNED_EMPTY_RECIPE = PinnedEmptyRecipe()


def compile_install_plan(
    spec: DiscoverySpec | dict | None,
    *,
    source_prompt: str = "",
    registry: AppBlockRegistry | None = None,
    primitives: frozenset[str] | None = None,
    known_packs: frozenset[str] | None = None,
    planner: CapabilityPlanner | None = None,
    composition_recipe: CompositionRecipe | PinnedEmptyRecipe | None = None,
) -> AppBlockPlan:
    """Return a diagnostic plan; validity means ``not plan.unsatisfiable``.

    Root selection order is product presentation priority. Nested edges and
    set-like install collections have canonical ordering; neither is execution
    order. Optional observations never participate in the hard closure.
    Matching policy is supplied through ``planner``; this function only
    closes the selected catalog graph.
    """
    registry = registry or load_default_registry()
    catalog = registry.canonical
    primitives = primitives if primitives is not None else default_primitives()
    known_packs = known_packs if known_packs is not None else default_known_packs()
    intent = product_intent_from_spec(spec, source_prompt=source_prompt)
    pinned_empty = isinstance(composition_recipe, PinnedEmptyRecipe)
    bound_recipe = None if pinned_empty else composition_recipe
    suppress_yaml_recipe = pinned_empty or bound_recipe is not None
    if planner is None:
        if suppress_yaml_recipe:
            def planner(item, _select=select_capabilities):
                return _select(item, include_matched_recipe=False)
        else:
            planner = select_capabilities
    selections = planner(intent)
    recipe = None if suppress_yaml_recipe else match_recipe_for_intent(intent)
    composition = resolve_composition(recipe, intent, bound_recipe)
    forbidden = set(composition.forbidden_capabilities)

    blocks: list[PlannedBlock] = []
    seen: set[str] = set()
    packs: list[str] = []
    primitive_ids: list[str] = []
    gaps: list[AppBlockGap] = []
    configs = []
    capabilities: list[str] = []
    starter_contracts: list[str] = []
    graph: dict[str, list[str]] = {}

    queue: list[tuple[str, str]] = [
        (block_id, reason)
        for _, block_id, reason in selections
        if block_id not in forbidden
    ]
    queue.extend(
        (block_id, f"Required by composition recipe {composition.matched_recipe_id}.")
        for block_id in composition.required_app_blocks
        if block_id not in forbidden
    )
    capabilities.extend(
        capability for capability, block_id, _ in selections if block_id not in forbidden
    )

    while queue:
        block_id, reason = queue.pop(0)
        if block_id in seen:
            continue
        seen.add(block_id)
        canonical_block = catalog.get(block_id)
        if canonical_block is None:
            gaps.append(
                AppBlockGap(
                    type="block_missing",
                    id=block_id,
                    severity="build_blocking",
                    affects=[block_id],
                    message=f"Unknown App Block {block_id}. Do not invent a substitute backend.",
                )
            )
            continue
        manifest = canonical_block.manifest
        slots = default_slots(manifest, intent)
        found_packs, found_primitives, nested, found_gaps, found_configs, found_optionals = (
            resolve_block(
                manifest,
                registry,
                primitives=primitives,
                known_packs=known_packs,
            )
        )
        gaps.extend(found_gaps)
        configs.extend(found_configs)
        packs.extend(found_packs)
        primitive_ids.extend(found_primitives)
        starter_contracts.extend(
            slot.component_contract for slot in manifest.slots if slot.component_contract
        )
        if manifest.implemented:
            blocks.append(
                planned_from_manifest(
                    manifest, reason, slots, optional_requirements=found_optionals
                )
            )
        graph[block_id] = sorted(item for item in set(nested) if item not in forbidden)
        for nested_id in graph[block_id]:
            queue.append((nested_id, f"Required by {manifest.id}."))

    gaps.extend(_cycle_gaps(graph))
    if "calculation.calculator" in seen:
        from src.domain.app_blocks.vertical_recipes import intent_corpus

        packs.extend(calculator_packs_for_text(intent_corpus(intent)))
    collection_recipe_ids: list[str] = []
    if "content.collection" in seen:
        from src.domain.app_blocks.vertical_recipes import intent_corpus
        from src.domain.collections.composition import collection_recipe_for_text

        collection_recipe_ids = [collection_recipe_for_text(intent_corpus(intent)).id]
    configs = _unique_models(configs)
    packs.extend(composition.required_feature_packs)
    return AppBlockPlan(
        requested_capabilities=list(dict.fromkeys(capabilities)),
        blocks=blocks,
        required_feature_packs=sorted(set(packs)),
        required_primitives=sorted(set(primitive_ids)),
        configuration_requirements=configs,
        gaps=_unique_models(gaps),
        runtime_dependencies=sorted(
            {item.capability for item in configs if item.behavior in {"degraded", "disabled"}}
        ),
        starter_contracts=sorted(set(starter_contracts)),
        collection_recipe_ids=collection_recipe_ids,
        matched_recipe_id=composition.matched_recipe_id,
        composition_recipe_version=composition.composition_recipe_version,
        starter_id=composition.starter_id,
        feature_pack_ids=list(composition.feature_pack_ids),
        forbidden_capabilities=list(composition.forbidden_capabilities),
        product_archetype=recipe.archetype if recipe else None,
        unsupported_assumptions=list(recipe.unsupported_assumptions) if recipe else [],
    )


def _unique_models(values: list) -> list:
    """Deduplicate exact rows, retaining distinct owner/provider policies."""
    rows = {}
    for value in values:
        key = json.dumps(value.model_dump(), sort_keys=True)
        rows[key] = value
    return [rows[key] for key in sorted(rows)]


def _cycle_gaps(graph: dict[str, list[str]]) -> list[AppBlockGap]:
    """Diagnose back edges in the reachable hard graph, never optional edges."""
    complete: set[str] = set()
    active: list[str] = []
    gaps: list[AppBlockGap] = []

    def visit(block_id: str) -> None:
        if block_id in active:
            chain = active[active.index(block_id) :] + [block_id]
            gaps.append(
                AppBlockGap(
                    type="unsupported",
                    id=block_id,
                    severity="build_blocking",
                    affects=sorted(set(chain)),
                    message=f"App Block dependency cycle: {' -> '.join(chain)}.",
                )
            )
            return
        if block_id in complete:
            return
        active.append(block_id)
        for dependency in graph.get(block_id, []):
            visit(dependency)
        active.pop()
        complete.add(block_id)

    for block_id in sorted(graph):
        visit(block_id)
    return gaps
