# ============================================================
# src/accountable_record/stage_050_catalog/validation.py
# ============================================================
"""Stage 050 catalog validation for Accountable Record.

WHY:
    Stage 050 runs after elements. It builds a normalized catalog over the
    elements validated by stage 040 and reconciles it against the repository's
    declared element catalog when that declaration is present. It consumes the
    s040 elements artifact, returns a CatalogValidationResult carrying
    diagnostics plus a CatalogArtifact, and emits ELEMENTS_REQUIRED when
    elements are absent.

OBS (real dependency):
    The catalog is built from state.elements. The ELEMENTS_REQUIRED gate is a
    genuine data dependency and is retained.

OBS (boundary):
    Builds and reconciles a catalog in memory. Writes no files, owns no
    data/export/elements/, and reconciles no lock digests.

OBS (typed errors):
    The ops raise typed StageErrors. This layer maps each error type to an
    AR.CA.* diagnostic spec without message inspection.
"""

from dataclasses import dataclass, field

from accountable_record.errors import (
    StageError,
    StageInputMalformed,
    StageInputUnreadable,
)
from accountable_record.pipeline.context import PipelineState
from accountable_record.pipeline.diagnostics import Diagnostic, Diagnostics
from accountable_record.stage_040_elements.model import ElementsArtifact
from accountable_record.stage_050_catalog.base import diagnostics as dx
from accountable_record.stage_050_catalog.base.constants import (
    DECLARED_CATALOG_PATH,
)
from accountable_record.stage_050_catalog.build import build_catalog
from accountable_record.stage_050_catalog.index import reconcile_against_declared
from accountable_record.stage_050_catalog.model import (
    CatalogArtifact,
    CatalogReconciliation,
)

_ERROR_SPEC_BY_TYPE: tuple[tuple[type[StageError], dx.DiagnosticSpec], ...] = (
    (StageInputMalformed, dx.CATALOG_BUILD_FAILED),
    (StageInputUnreadable, dx.CATALOG_BUILD_FAILED),
)


@dataclass(frozen=True)
class CatalogValidationResult:
    """Validation result for stage 050."""

    diagnostics: Diagnostics = field(default_factory=Diagnostics)
    catalog: CatalogArtifact | None = None

    @property
    def has_errors(self) -> bool:
        """Return true if any error-severity diagnostics are present."""
        return self.diagnostics.has_errors

    @property
    def exit_code(self) -> int:
        """Return 0 if no errors, else 1."""
        return self.diagnostics.exit_code


def validate_catalog_stage(state: PipelineState) -> CatalogValidationResult:
    """Build and validate the catalog for the target repository."""
    diagnostics = Diagnostics()

    elements = _elements_from_state(state)
    if elements is None:
        diagnostics.add(dx.from_spec(dx.ELEMENTS_REQUIRED))
        return CatalogValidationResult(diagnostics=diagnostics)

    repo_root = state.context.target_root

    try:
        catalog = build_catalog(elements, repo_root)
        reconciliation = reconcile_against_declared(catalog, repo_root)
    except StageError as exc:
        diagnostics.add(_diagnostic_for(exc))
        return CatalogValidationResult(diagnostics=diagnostics)

    _emit_reconciliation_diagnostics(
        reconciliation,
        diagnostics,
        validates_target_materials=state.context.repository_profile.validates_target_materials,
    )

    artifact = CatalogArtifact(
        target_root=str(repo_root),
        catalog=catalog,
        reconciliation=reconciliation,
    )
    return CatalogValidationResult(diagnostics=diagnostics, catalog=artifact)


def _elements_from_state(state: PipelineState) -> ElementsArtifact | None:
    """Return the stage 040 elements artifact from state, if present."""
    if isinstance(state.elements, ElementsArtifact):
        return state.elements
    return None


def _emit_reconciliation_diagnostics(
    reconciliation: CatalogReconciliation,
    diagnostics: Diagnostics,
    *,
    validates_target_materials: bool,
) -> None:
    """Emit diagnostics for reconciliation findings."""
    if not reconciliation.declared_present:
        if validates_target_materials:
            diagnostics.add(
                dx.from_spec(
                    dx.DECLARED_CATALOG_MISSING,
                    location=DECLARED_CATALOG_PATH,
                )
            )
        return

    for compact_id in reconciliation.undeclared:
        diagnostics.add(
            dx.from_spec(
                dx.CATALOG_ENTRY_UNDECLARED,
                compact_id=compact_id,
                location=DECLARED_CATALOG_PATH,
            )
        )

    for compact_id in reconciliation.orphaned:
        diagnostics.add(
            dx.from_spec(
                dx.CATALOG_ENTRY_ORPHANED,
                compact_id=compact_id,
                location=DECLARED_CATALOG_PATH,
            )
        )


def _diagnostic_for(error: StageError) -> Diagnostic:
    """Map a typed StageError to its AR.CA.* diagnostic."""
    location = error.location or DECLARED_CATALOG_PATH

    for error_type, spec in _ERROR_SPEC_BY_TYPE:
        if isinstance(error, error_type):
            if error.location == DECLARED_CATALOG_PATH:
                return dx.from_spec(
                    dx.DECLARED_CATALOG_INVALID,
                    detail=str(error),
                    location=location,
                )

            return dx.from_spec(
                spec,
                detail=str(error),
                location=location,
            )

    return dx.from_spec(
        dx.CATALOG_BUILD_FAILED,
        detail=str(error),
        location=location,
    )
