"""FL-D1 App Block presentation coverage.

Capability = semantic/backend primitive.
App Block = reusable product surface for that primitive.

This module scores *presentation modes* from authored manifests. It does not
admit capabilities, Cloud engines, or new App Block IDs. Candidate variants
are FL-D2 work: the same canonical block, richer display/edit modes.
"""

from __future__ import annotations

from pathlib import Path
from typing import Any

import yaml
from pydantic import BaseModel, ConfigDict, Field

from src.domain.app_blocks.canonical import CANONICAL_BLOCK_IDS
from src.domain.app_blocks.job_packs import JOB_FEATURE_PACKS, known_job_pack_ids
from src.domain.app_blocks.presentation_variants import (
    HELD_PORTAL_BLOCKS,
    presentations_on,
)
from src.domain.app_blocks.registry import load_default_registry
from src.domain.app_blocks.schemas import AppBlockManifest
from src.domain.starter_library.archetypes import known_archetype_ids

COVERAGE_PATH = Path(__file__).resolve().parent / "app_block_coverage.v1.yaml"

PRESENTATION_MODES: tuple[str, ...] = (
    "table",
    "detail",
    "create_edit",
    "status_queue",
    "portal_view",
    "search_filter",
    "relationship_view",
    "calendar",
)

DERIVED_MODES: frozenset[str] = frozenset(PRESENTATION_MODES)

TABLE_CONTRACTS = frozenset({"record.table"})
DETAIL_CONTRACTS = frozenset({"record.detail"})
CREATE_CONTRACTS = frozenset({"record.create"})
EDIT_CONTRACTS = frozenset({"record.edit"})
FORM_CONTRACTS = frozenset({"form.standard"})
STATUS_SLOT_IDS = frozenset({"status"})
FILTER_SLOT_TYPES = frozenset({"filter_config"})


class BlockCoverage(BaseModel):
    model_config = ConfigDict(extra="forbid")
    id: str
    family: str
    role: str
    title: str
    modes: dict[str, str]
    candidate_variants: list[str] = Field(default_factory=list)
    notes: str = ""


class CandidatePack(BaseModel):
    model_config = ConfigDict(extra="forbid")
    id: str
    status: str
    starter: str
    required_app_blocks: list[str]
    forbidden_implicit: list[str] = Field(default_factory=list)
    notes: str = ""


class DeclaredApplication(BaseModel):
    model_config = ConfigDict(extra="forbid")
    id: str
    status: str
    starter: str
    packs: list[str]
    forbidden_implicit: list[str] = Field(default_factory=list)
    notes: str = ""


class PresentationCoverageReport(BaseModel):
    model_config = ConfigDict(extra="forbid")
    schema_version: int
    slice: str
    status: str
    blocks: list[BlockCoverage]
    d2_queue: list[dict[str, str]]
    d3_candidate_packs: list[CandidatePack]
    declared_applications: list[DeclaredApplication]
    starter_count: int
    job_pack_count: int

    def block(self, block_id: str) -> BlockCoverage:
        for item in self.blocks:
            if item.id == block_id:
                return item
        raise KeyError(block_id)


def _contracts(manifest: AppBlockManifest) -> set[str]:
    return {slot.component_contract for slot in manifest.slots if slot.component_contract}


def _slot_ids(manifest: AppBlockManifest) -> set[str]:
    return {slot.id for slot in manifest.slots}


def _slot_types(manifest: AppBlockManifest) -> set[str]:
    return {slot.type for slot in manifest.slots}


def _requires_block(manifest: AppBlockManifest, block_id: str) -> bool:
    return any(item.kind == "app_block" and item.id == block_id for item in manifest.requires)


def derived_modes(manifest: AppBlockManifest) -> dict[str, str]:
    """Score modes that can be proven from authored slots."""
    contracts = _contracts(manifest)
    slot_ids = _slot_ids(manifest)
    slot_types = _slot_types(manifest)
    kinds = presentations_on(manifest)
    has_status = bool(slot_ids & STATUS_SLOT_IDS) or "status_grouping" in kinds
    has_create = bool(contracts & CREATE_CONTRACTS)
    has_full_edit = bool(contracts & EDIT_CONTRACTS) and not (
        slot_ids & STATUS_SLOT_IDS
    )
    has_form = bool(contracts & FORM_CONTRACTS)

    if has_create or has_full_edit or has_form:
        create_edit = "present"
    elif (slot_ids & STATUS_SLOT_IDS) and contracts & EDIT_CONTRACTS:
        create_edit = "status_only"
    else:
        create_edit = "absent"

    if slot_types & FILTER_SLOT_TYPES:
        search = "present"
    elif _requires_block(manifest, "admin.record_table"):
        search = "inherited"
    else:
        search = "absent"

    if manifest.id in HELD_PORTAL_BLOCKS:
        portal = "held"
    elif "portal_read_only" in kinds:
        portal = "present"
    else:
        portal = "absent"

    return {
        "table": "present" if contracts & TABLE_CONTRACTS else "absent",
        "detail": "present" if contracts & DETAIL_CONTRACTS else "absent",
        "create_edit": create_edit,
        "status_queue": "present" if has_status else "absent",
        "search_filter": search,
        "portal_view": portal,
        "relationship_view": "present" if "relationship_view" in kinds else "absent",
        "calendar": "present" if "calendar" in kinds else "absent",
    }


def _load_overlay(path: Path | None = None) -> dict[str, Any]:
    raw = yaml.safe_load((path or COVERAGE_PATH).read_text(encoding="utf-8")) or {}
    if not isinstance(raw, dict):
        raise ValueError("app_block_coverage.v1.yaml must be a mapping")
    return raw


def _merge_modes(derived: dict[str, str], overlay_modes: dict[str, str]) -> dict[str, str]:
    merged: dict[str, str] = {}
    for mode in PRESENTATION_MODES:
        overlay = overlay_modes.get(mode)
        score = derived.get(mode, "absent")
        if overlay in {"held", "gated"}:
            merged[mode] = overlay
            continue
        if overlay == "n_a" and score in {None, "absent"}:
            merged[mode] = "n_a"
            continue
        if score and score != "absent":
            merged[mode] = score
            continue
        merged[mode] = overlay or score or "absent"
    return merged


def load_presentation_coverage(
    path: Path | None = None,
) -> PresentationCoverageReport:
    overlay = _load_overlay(path)
    registry = load_default_registry()
    overlay_blocks = {item["id"]: item for item in overlay.get("blocks") or []}
    missing = CANONICAL_BLOCK_IDS - set(overlay_blocks)
    extra = set(overlay_blocks) - CANONICAL_BLOCK_IDS
    if missing or extra:
        raise ValueError(
            f"coverage overlay ids mismatch missing={sorted(missing)} extra={sorted(extra)}"
        )

    blocks: list[BlockCoverage] = []
    for manifest in sorted(registry.all(), key=lambda item: item.id):
        row = overlay_blocks[manifest.id]
        modes = _merge_modes(derived_modes(manifest), dict(row.get("modes") or {}))
        blocks.append(
            BlockCoverage(
                id=manifest.id,
                family=manifest.family,
                role=str(row.get("role") or "product"),
                title=manifest.title,
                modes=modes,
                candidate_variants=list(row.get("candidate_variants") or []),
                notes=str(row.get("notes") or ""),
            )
        )

    packs = [
        CandidatePack.model_validate(item) for item in overlay.get("d3_candidate_packs") or []
    ]
    apps = [
        DeclaredApplication.model_validate(item)
        for item in overlay.get("declared_applications") or []
    ]
    return PresentationCoverageReport(
        schema_version=int(overlay.get("schema_version") or 1),
        slice=str(overlay.get("slice") or "FL-D2"),
        status=str(overlay.get("status") or "presentation_variants_complete"),
        blocks=blocks,
        d2_queue=list(overlay.get("d2_queue") or []),
        d3_candidate_packs=packs,
        declared_applications=apps,
        starter_count=len(known_archetype_ids()),
        job_pack_count=len(JOB_FEATURE_PACKS),
    )


def coverage_stats(report: PresentationCoverageReport | None = None) -> dict[str, int]:
    report = report or load_presentation_coverage()
    from src.domain.app_blocks.fast_lane_proof import prove_declared_applications
    product = [item for item in report.blocks if item.role == "product"]
    candidates = 0
    present_table = 0
    for item in product:
        present_table += int(item.modes.get("table") == "present")
        candidates += sum(1 for score in item.modes.values() if score == "candidate")
        candidates += len(item.candidate_variants)
    return {
        "canonical_blocks": len(report.blocks),
        "product_blocks": len(product),
        "product_tables_present": present_table,
        "presentation_candidates": candidates,
        "d2_queue": len(report.d2_queue),
        "d3_candidate_packs": len(report.d3_candidate_packs),
        "declared_applications": len(report.declared_applications),
        "composition_proven_applications": sum(
            item.composition_proven for item in prove_declared_applications()
        ),
        "starters": report.starter_count,
        "job_packs": report.job_pack_count,
        "proven_applications": sum(
            item.status == "composition_proven_runtime_proven"
            for item in report.declared_applications
        ),
    }


def assert_expansion_guards(report: PresentationCoverageReport | None = None) -> None:
    """Fail if FL-D coverage invents unfrozen IDs or extra Starters."""
    report = report or load_presentation_coverage()
    if report.starter_count != 11:
        raise ValueError(f"starter count must stay 11 after the generic application portal admission, got {report.starter_count}")
    admitted_job_pack_count = len(known_job_pack_ids())
    if report.job_pack_count != admitted_job_pack_count:
        raise ValueError(
            "presentation coverage job-pack count must match the admitted catalog "
            f"({admitted_job_pack_count}), got {report.job_pack_count}"
        )
    known_packs = known_job_pack_ids()
    known_starters = known_archetype_ids()
    for app in report.declared_applications:
        if app.starter not in known_starters:
            raise ValueError(f"{app.id} unknown starter {app.starter}")
        unknown = [pack for pack in app.packs if pack not in known_packs]
        if unknown:
            raise ValueError(f"{app.id} unfrozen packs {unknown}")
        if app.id == "student_consultancy":
            if app.status != "composition_proven_runtime_proven":
                raise ValueError(f"{app.id} must remain composition_proven_runtime_proven after generic portal proof")
            continue
        if app.status != "declared_not_proven":
            raise ValueError(f"{app.id} must remain declared_not_proven until FL-D5")
    for pack in report.d3_candidate_packs:
        if pack.status != "candidate":
            raise ValueError(f"{pack.id} is not admitted")
        unknown_blocks = [
            item for item in pack.required_app_blocks if item not in CANONICAL_BLOCK_IDS
        ]
        if unknown_blocks:
            raise ValueError(f"{pack.id} unfrozen blocks {unknown_blocks}")
        if pack.starter not in known_starters:
            raise ValueError(f"{pack.id} unknown starter {pack.starter}")
        if pack.id in known_packs:
            raise ValueError(f"{pack.id} already admitted; D3 candidates must be new")
