# ============================================================
# src/accountable_record/stage_030_sources/validation.py
# ============================================================
"""Stage 030 source-material validation for Accountable Record.

WHY:
    Stage 030 runs after identity. It establishes declared repository source
    materials structurally by reading schema-valid [provides].artifacts,
    building a source registry, and recording whether each declared path is
    present without reading or interpreting source content.

OBS:
    This stage uses [provides].artifacts because [sources] is not part of the
    current schema-valid implementation repository manifest. It does not use a
    schema-forbidden [sources] opt-out section.

OBS:
    The load and registry operations raise typed StageErrors. This layer maps
    each error type to an AR.SR.* diagnostic spec. No message inspection, no
    broad Exception catch.
"""

from dataclasses import dataclass, field

from accountable_record.errors import (
    StageError,
    StageInputDuplicate,
    StageInputMalformed,
    StageInputMissing,
    StageInputOutOfRoot,
    StageInputUnreadable,
)
from accountable_record.pipeline.context import PipelineState
from accountable_record.pipeline.diagnostics import Diagnostic, Diagnostics
from accountable_record.stage_010_identity.base.constants import MANIFEST_FILENAME
from accountable_record.stage_030_sources.base import diagnostics as dx
from accountable_record.stage_030_sources.load import load_declared_source_records
from accountable_record.stage_030_sources.model import SourcesArtifact
from accountable_record.stage_030_sources.registry import build_registry

_ERROR_SPEC_BY_TYPE: tuple[tuple[type[StageError], dx.DiagnosticSpec], ...] = (
    (StageInputMissing, dx.SOURCES_TABLE_MISSING),
    (StageInputMalformed, dx.SOURCE_DECLARATION_INVALID),
    (StageInputOutOfRoot, dx.SOURCE_OUT_OF_ROOT),
    (StageInputUnreadable, dx.SOURCE_UNREADABLE),
    (StageInputDuplicate, dx.SOURCE_ID_DUPLICATE),
)


@dataclass(frozen=True)
class SourcesValidationResult:
    """Validation result for stage 030."""

    diagnostics: Diagnostics = field(default_factory=Diagnostics)
    sources: SourcesArtifact | 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_sources_stage(state: PipelineState) -> SourcesValidationResult:
    """Validate declared source materials for the target repository.

    Args:
        state: Accumulated pipeline state. Stage 010 identity must be present.

    Returns:
        Sources validation result.
    """
    diagnostics = Diagnostics()

    if state.identity is None:
        diagnostics.add(dx.from_spec(dx.IDENTITY_REQUIRED))
        return SourcesValidationResult(diagnostics=diagnostics)

    repo_root = state.context.target_root

    try:
        records = load_declared_source_records(repo_root)
        registry = build_registry(list(records))
    except StageError as exc:
        diagnostics.add(_diagnostic_for(exc))
        return SourcesValidationResult(diagnostics=diagnostics)

    sources = SourcesArtifact(
        target_root=str(repo_root),
        registry=registry,
    )
    return SourcesValidationResult(diagnostics=diagnostics, sources=sources)


def _diagnostic_for(error: StageError) -> Diagnostic:
    """Map a typed StageError to its AR.SR.* diagnostic."""
    for error_type, spec in _ERROR_SPEC_BY_TYPE:
        if isinstance(error, error_type):
            return _diagnostic_from_stage_error(spec, error)

    return dx.from_spec_at(
        dx.SOURCE_DECLARATION_INVALID,
        error.location,
        detail=str(error),
    )


def _diagnostic_from_stage_error(
    spec: dx.DiagnosticSpec,
    error: StageError,
) -> Diagnostic:
    """Build a diagnostic for a typed source-stage error."""
    if spec is dx.SOURCES_TABLE_MISSING:
        return dx.from_spec_at(
            spec,
            error.location,
            manifest_filename=MANIFEST_FILENAME,
        )

    if spec is dx.SOURCE_MISSING:
        return dx.from_spec_at(
            spec,
            error.location,
            source_id=_source_id_from_error(error),
            path=_path_from_error(error),
        )

    if spec is dx.SOURCE_UNREADABLE:
        return dx.from_spec_at(
            spec,
            error.location,
            source_id=_source_id_from_error(error),
            path=_path_from_error(error),
            detail=str(error),
        )

    if spec is dx.SOURCE_OUT_OF_ROOT:
        return dx.from_spec_at(
            spec,
            error.location,
            source_id=_source_id_from_error(error),
            path=_path_from_error(error),
        )

    if spec is dx.SOURCE_ID_DUPLICATE:
        return dx.from_spec_at(
            spec,
            error.location,
            source_id=_source_id_from_error(error),
        )

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


def _source_id_from_error(error: StageError) -> str:
    """Return a conservative source id string from an error."""
    return "<unknown>"


def _path_from_error(error: StageError) -> str:
    """Return a conservative path string from an error."""
    return error.location or "<unknown>"
