# ============================================================
# src/accountable_record/stage_080_generated/collect.py
# ============================================================
"""Collect and structurally validate stage 080 generated artifacts.

WHY:
    Stage 080 owns generated-artifact validation. collect.py discovers element
    export artifacts under data/export/elements/, validates each structurally,
    and returns GeneratedElementRecord values. It is the read op the validation
    layer drives. Like earlier read ops it stays free of diagnostics: it returns
    records or raises a typed StageError.

OBS (generated-artifact scope):
    Discovery walks ONLY data/export/elements/. Any non-element export material
    under the export root is never discovered as an element record and never
    reconciled against the lock.

OBS (no digest ownership):
    GeneratedElementRecord is passive and does not carry a digest. The
    generated-to-resolution-lock transition computes canonical-generated-json
    digests when it writes the lock.

OBS (no writes):
    Read-only. Discovers and parses; writes nothing.

OBS (errors):
    Failures are raised as typed errors from accountable_record.errors so the
    validation layer matches on type.
"""

import json
from pathlib import Path

from accountable_record.boundary.json import is_table
from accountable_record.errors import (
    StageInputMalformed,
    StageInputMissing,
    StageInputUnreadable,
)
from accountable_record.stage_080_generated.base import constants
from accountable_record.stage_080_generated.base.scalars import (
    require_string,
    require_sub_object,
)
from accountable_record.stage_080_generated.model import GeneratedElementRecord

__all__ = ["collect_generated_elements"]


def collect_generated_elements(
    repo_root: Path,
    generated_path: str | None = None,
) -> list[GeneratedElementRecord]:
    """Discover and structurally validate generated element artifacts.

    Args:
        repo_root: The target repository root.
        generated_path: Repository-relative generated root. Defaults to
            data/export.

    Returns:
        A list of GeneratedElementRecord, one per generated element artifact,
        in sorted path order.

    Raises:
        StageInputMissing: the generated root is absent.
        StageInputMalformed: a generated file fails to parse or lacks minimal
            shape.
        StageInputUnreadable: a file could not be read.
    """
    rel_root = generated_path or constants.GENERATED_DIRNAME
    generated_root = repo_root / rel_root
    if not generated_root.is_dir():
        msg = f"{rel_root}/ does not exist"
        raise StageInputMissing(msg, location=rel_root)

    elements_root = generated_root / constants.GENERATED_ELEMENTS_SUBDIR
    if not elements_root.is_dir():
        return []

    records: list[GeneratedElementRecord] = []
    for artifact_path in _find_generated_files(elements_root):
        records.append(_record_from_file(artifact_path, repo_root, elements_root))
    return records


def _find_generated_files(elements_root: Path) -> list[Path]:
    """Find generated element artifact files, data-driven, in sorted order."""
    matches = sorted(elements_root.rglob(constants.GENERATED_ELEMENT_FILENAME))
    return [path for path in matches if path.is_file()]


def _record_from_file(
    artifact_path: Path,
    repo_root: Path,
    elements_root: Path,
) -> GeneratedElementRecord:
    """Build and structurally validate one generated element record.

    Raises:
        StageInputMalformed: the file is unparseable or mis-shaped.
        StageInputUnreadable: the file could not be read.
    """
    rel_path = artifact_path.relative_to(repo_root).as_posix()
    parsed = _load_json(artifact_path, repo_root)
    compact_id = _check_generated_shape(parsed, rel_path)

    element_dir = artifact_path.parent
    rel_dir = element_dir.relative_to(repo_root).as_posix()
    category, name = _category_and_name(
        element_dir=element_dir,
        elements_root=elements_root,
        rel_path=rel_path,
    )

    return GeneratedElementRecord(
        category=category,
        name=name,
        rel_dir=rel_dir,
        compact_id=compact_id,
    )


def _category_and_name(
    *,
    element_dir: Path,
    elements_root: Path,
    rel_path: str,
) -> tuple[str, str]:
    """Return the category/name pair for an element directory."""
    relative = element_dir.relative_to(elements_root)
    if len(relative.parts) != 2:
        msg = (
            f"{rel_path} must be under "
            f"{constants.GENERATED_ELEMENTS_SUBDIR}/<category>/<name>/"
        )
        raise StageInputMalformed(msg, location=rel_path)

    category, name = relative.parts
    return category, name


def _load_json(path: Path, repo_root: Path) -> dict[str, object]:
    """Load and parse a JSON object file, raising typed errors naming the path."""
    rel = path.relative_to(repo_root).as_posix()

    try:
        text = path.read_text(encoding="utf-8")
    except OSError as exc:
        raise StageInputUnreadable(str(exc), location=rel) from exc

    try:
        data: object = json.loads(text)
    except json.JSONDecodeError as exc:
        raise StageInputMalformed(f"invalid JSON: {exc}", location=rel) from exc

    if not is_table(data):
        msg = f"{rel} must be a JSON object"
        raise StageInputMalformed(msg, location=rel)

    return data


def _check_generated_shape(parsed: dict[str, object], rel_path: str) -> str:
    """Check generated artifact minimal shape; return its compact_id.

    Raises:
        StageInputMalformed: shape problem.
    """
    try:
        identity = require_sub_object(
            parsed,
            constants.GENERATED_ELEMENT_IDENTITY,
            rel_path,
        )
        return require_string(
            identity,
            constants.GENERATED_ELEMENT_COMPACT_ID_KEY,
            rel_path,
        )
    except ValueError as exc:
        raise StageInputMalformed(str(exc), location=rel_path) from exc
