"""Fast Lane Vertical Recipes. Compose admitted App Blocks; invent no semantics."""

from __future__ import annotations

from functools import lru_cache
from pathlib import Path

import yaml
from pydantic import BaseModel, ConfigDict, Field, model_validator

from src.domain.app_blocks.composition_contract import (
    FORBIDDEN_COMPOSITION_BLOCKS,
    VIS_CALC_BLOCKS,
    RecipeCalculatorSpec,
    RecipeDatasetSpec,
    RecipeKind,
    RecipePresentationSpec,
)
from src.domain.app_blocks.registry import AppBlockRegistry, load_default_registry
from src.domain.app_blocks.schemas import ProductIntent

RECIPE_DIR = Path(__file__).resolve().parent / "recipes"


class RecipeBlockSlots(BaseModel):
    model_config = ConfigDict(extra="forbid")
    heading: str | None = None
    empty_state: str | None = None
    submit_label: str | None = None
    route: str | None = None
    success_message: str | None = None


class RecipeRequires(BaseModel):
    model_config = ConfigDict(extra="forbid")
    app_blocks: list[str] = Field(min_length=1)


class VerticalRecipe(BaseModel):
    model_config = ConfigDict(extra="forbid")
    id: str = Field(min_length=1)
    name: str = Field(min_length=1)
    archetype: str = Field(min_length=1)
    proof_profile: str = Field(min_length=1)
    intent_phrases: list[str] = Field(min_length=1)
    requires: RecipeRequires
    terminology: dict[str, RecipeBlockSlots] = Field(default_factory=dict)
    public_labels: dict[str, str] = Field(default_factory=dict)
    unsupported_assumptions: list[str] = Field(default_factory=list)
    # Optional Visualization + Calculation composition contract. Absent on
    # older Fast Lane batches. Present recipes stay product-intent only —
    # Dataset / Calculation IR remain the execution contracts.
    kind: RecipeKind | None = None
    composition_pattern: str | None = None
    calculator: RecipeCalculatorSpec | None = None
    dataset: RecipeDatasetSpec | None = None
    presentation: RecipePresentationSpec | None = None
    area: str | None = None

    @property
    def required_app_blocks(self) -> list[str]:
        return list(self.requires.app_blocks)

    @model_validator(mode="after")
    def composition_contract(self) -> VerticalRecipe:
        blocks = set(self.requires.app_blocks)
        if self.kind is None and self.calculator is None and self.dataset is None:
            return self
        forbidden = blocks & FORBIDDEN_COMPOSITION_BLOCKS
        if forbidden:
            raise ValueError(
                f"{self.id} composition recipes cannot require {sorted(forbidden)}"
            )
        if self.kind in {"calculation", "visualization", "calculated_chart"}:
            if not blocks <= VIS_CALC_BLOCKS:
                raise ValueError(
                    f"{self.id} may only compose visualization.chart / "
                    f"calculation.calculator, got {sorted(blocks)}"
                )
        if self.kind == "calculation":
            if "calculation.calculator" not in blocks:
                raise ValueError(f"{self.id} calculation recipes require calculation.calculator")
            if self.calculator is None:
                raise ValueError(f"{self.id} calculation recipes require calculator spec")
        if self.kind == "visualization":
            if "visualization.chart" not in blocks:
                raise ValueError(f"{self.id} visualization recipes require visualization.chart")
            if self.dataset is None:
                raise ValueError(f"{self.id} visualization recipes require dataset spec")
        if self.kind == "calculated_chart":
            if blocks != {"calculation.calculator", "visualization.chart"}:
                raise ValueError(
                    f"{self.id} calculated_chart recipes require both calculator and chart"
                )
            if self.calculator is None or self.dataset is None:
                raise ValueError(
                    f"{self.id} calculated_chart recipes require calculator and dataset"
                )
        return self


class RecipeCatalog(BaseModel):
    model_config = ConfigDict(extra="forbid")
    schema_version: int
    lane: str
    batch: str
    ready_for_fast_lane_composition: bool = False
    recipes: list[VerticalRecipe]


def intent_corpus(intent: ProductIntent) -> str:
    """Raw customer text plus structured Discovery evidence.

    Empty structured fields are omitted so historical 13A/B2 matching is unchanged
    when Discovery did not populate them.
    """
    haystack = " ".join(
        [
            intent.source_prompt,
            intent.purpose,
            intent.first_build_goal,
            *intent.workflow_names,
            *intent.workflow_descriptions,
            *intent.entity_names,
            *intent.entity_descriptions,
            *intent.entity_lifecycles,
            *intent.archetypes,
            *intent.target_audience,
            *intent.access_scopes,
            *intent.requirement_lines,
        ]
    )
    extras = " ".join(
        part
        for part in (
            intent.original_intent,
            intent.domain,
            intent.requested_product_shape,
            *intent.form_names,
        )
        if part
    )
    if extras:
        haystack = f"{haystack} {extras}".strip()
    return haystack.lower()


def _catalog_paths(root: Path) -> list[Path]:
    return sorted(path for path in root.glob("*.yaml") if path.is_file())


@lru_cache(maxsize=1)
def load_recipe_catalogs(directory: str | None = None) -> tuple[RecipeCatalog, ...]:
    root = Path(directory) if directory else RECIPE_DIR
    paths = _catalog_paths(root)
    if not paths:
        raise FileNotFoundError(f"no Fast Lane recipe catalogs in {root}")
    catalogs: list[RecipeCatalog] = []
    for path in paths:
        data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
        catalog = RecipeCatalog.model_validate(data)
        if catalog.schema_version != 1:
            raise ValueError(
                f"{path.name} unsupported recipe catalog schema_version={catalog.schema_version}"
            )
        if catalog.lane != "fast_lane":
            raise ValueError(f"{path.name} lane must be fast_lane, got {catalog.lane!r}")
        catalogs.append(catalog)
    if directory is None:
        from src.domain.app_blocks.visualization_calculation_catalog import (
            composition_catalog,
        )

        catalogs.append(composition_catalog())
    return tuple(catalogs)


def load_recipe_catalog(directory: str | None = None) -> RecipeCatalog:
    """Merged view of every batch file. Live matching uses composition-ready batches."""
    catalogs = load_recipe_catalogs(directory)
    recipes: list[VerticalRecipe] = []
    for catalog in catalogs:
        recipes.extend(catalog.recipes)
    return RecipeCatalog(
        schema_version=1,
        lane="fast_lane",
        batch=",".join(catalog.batch for catalog in catalogs),
        ready_for_fast_lane_composition=any(
            catalog.ready_for_fast_lane_composition for catalog in catalogs
        ),
        recipes=recipes,
    )


def load_recipes(
    directory: str | None = None, *, composition_ready_only: bool = False
) -> list[VerticalRecipe]:
    recipes: list[VerticalRecipe] = []
    for catalog in load_recipe_catalogs(directory):
        if composition_ready_only and not catalog.ready_for_fast_lane_composition:
            continue
        recipes.extend(catalog.recipes)
    return recipes


def validate_recipes(
    recipes: list[VerticalRecipe] | None = None,
    *,
    registry: AppBlockRegistry | None = None,
) -> list[str]:
    """Return blocking problems. Empty means every required block is admitted."""
    recipes = recipes if recipes is not None else load_recipes()
    registry = registry or load_default_registry()
    problems: list[str] = []
    seen_ids: set[str] = set()
    for recipe in recipes:
        if recipe.id in seen_ids:
            problems.append(f"duplicate recipe id {recipe.id}")
        seen_ids.add(recipe.id)
        for block_id in recipe.required_app_blocks:
            manifest = registry.get(block_id)
            if manifest is None:
                problems.append(f"{recipe.id} requires unadmitted App Block {block_id}")
                continue
            if not manifest.implemented:
                problems.append(f"{recipe.id} requires unimplemented App Block {block_id}")
        for block_id in recipe.terminology:
            if block_id not in recipe.required_app_blocks:
                problems.append(
                    f"{recipe.id} terminology references {block_id} which is not required"
                )
        for phrase in recipe.intent_phrases:
            if not phrase.strip() or phrase != phrase.lower():
                problems.append(f"{recipe.id} intent phrase must be lowercase: {phrase!r}")
        if recipe.kind is not None:
            problems.extend(_validate_composition_recipe(recipe))
    return problems


def _validate_composition_recipe(recipe: VerticalRecipe) -> list[str]:
    problems: list[str] = []
    for required in (
        "reporting.dashboard",
        "chart_definition_entity",
        "calculator_definition_entity",
    ):
        if required not in recipe.unsupported_assumptions:
            problems.append(f"{recipe.id} must list unsupported {required}")
    if recipe.kind in {"calculation", "calculated_chart"}:
        if "calculation.evaluate" not in recipe.unsupported_assumptions:
            problems.append(f"{recipe.id} must list unsupported calculation.evaluate")
    if recipe.presentation is not None:
        for metric in recipe.presentation.metrics:
            if metric.presentation != "metric.card":
                problems.append(f"{recipe.id} metrics must use metric.card")
        for chart in recipe.presentation.charts:
            if chart.chart_type not in {"bar", "line", "pie", "area"}:
                problems.append(f"{recipe.id} unknown chart_type {chart.chart_type}")
    return problems


def match_vertical_recipe(
    text: str, recipes: list[VerticalRecipe] | None = None
) -> VerticalRecipe | None:
    """Return the most specific matching recipe, or None.

    Specificity is the longest matching intent phrase. Recipes may not invent
    blocks; they only compose admitted ones. The live planner passes
    composition-ready recipes only.
    """
    haystack = text.lower()
    recipes = recipes if recipes is not None else load_recipes(composition_ready_only=True)
    hits: list[tuple[int, VerticalRecipe]] = []
    for recipe in recipes:
        matched = [phrase for phrase in recipe.intent_phrases if phrase in haystack]
        if matched:
            hits.append((max(len(phrase) for phrase in matched), recipe))
    if not hits:
        return None
    hits.sort(key=lambda item: (-item[0], item[1].id))
    return hits[0][1]


def match_recipe_for_intent(intent: ProductIntent) -> VerticalRecipe | None:
    return match_vertical_recipe(intent_corpus(intent))


def recipe_slot_overlay(recipe: VerticalRecipe, block_id: str) -> dict[str, str]:
    slots = recipe.terminology.get(block_id)
    if slots is None:
        return {}
    return {key: value for key, value in slots.model_dump().items() if value}
