"""stage_060_exports/index.py. Discover and reconcile element exports (stage 060 op).

WHY:
    index.py discovers the element exports under data/export/elements/,
    validates each export pair structurally, and reconciles the element-export
    set against the catalog from stage 050. It is the op the validation layer
    drives. Like earlier read ops it stays free of diagnostics: it returns
    records / a reconciliation or raises a typed StageError.

OBS (element-export scope):
    Discovery walks ONLY data/export/<path>/elements/. Non-element exports
    under data/export/ are never discovered as element records and never
    reconciled against the catalog. Reconciliation is 1:1 within the
    element-export namespace.

OBS (consume the catalog):
    The catalog compact_id set is passed in by the validation layer (from
    state.catalog). This op does not rebuild the catalog or read data/catalog/.

OBS (no writes):
    Read-only. Discovers and compares; 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_060_exports.base import constants
from accountable_record.stage_060_exports.base.scalars import (
    require_string,
    require_sub_object,
)
from accountable_record.stage_060_exports.model import (
    ExportElementRecord,
    ExportReconciliation,
)


def discover_element_exports(
    repo_root: Path,
    export_path: str,
) -> list[ExportElementRecord]:
    """Discover and structurally validate element exports.

    Args:
        repo_root: The target repository root.
        export_path: The repository-relative export root (from the directive).

    Returns:
        A list of :class:`ExportElementRecord`, one per element export
        directory, in sorted (category, name) order.

    Raises:
        StageInputMissing: the export root is absent.
        StageInputMalformed: an export pair member is missing, fails to parse,
            lacks minimal shape, or a structural cross-link did not hold.
        StageInputUnreadable: a file could not be read.
    """
    export_root = repo_root / export_path
    if not export_root.is_dir():
        msg = f"{export_path}/ does not exist"
        raise StageInputMissing(msg, location=export_path)

    elements_root = export_root / constants.EXPORT_ELEMENTS_SUBDIR
    if not elements_root.is_dir():
        # An export root with no element-export namespace yields no element
        # exports; that is not itself an error here (the export root exists).
        return []

    element_dirs = _find_element_export_dirs(elements_root)
    records: list[ExportElementRecord] = []
    for element_dir in element_dirs:
        records.append(_record_from_dir(element_dir, repo_root, elements_root))
    return records


def reconcile_with_catalog(
    records: list[ExportElementRecord],
    catalog_compact_ids: frozenset[str],
) -> ExportReconciliation:
    """Reconcile element exports against the catalog, 1:1, element-scoped.

    Args:
        records: Discovered element export records.
        catalog_compact_ids: The catalog's element compact_id set.

    Returns:
        An :class:`ExportReconciliation` naming catalog entries lacking an
        export and element exports lacking a catalog entry.
    """
    export_ids = frozenset(record.compact_id for record in records)

    catalog_missing_export = tuple(sorted(catalog_compact_ids - export_ids))
    uncataloged_exports = tuple(sorted(export_ids - catalog_compact_ids))

    return ExportReconciliation(
        catalog_missing_export=catalog_missing_export,
        uncataloged_exports=uncataloged_exports,
    )


def _find_element_export_dirs(elements_root: Path) -> list[Path]:
    """Find element export directories by element.json presence, data-driven."""
    found: list[Path] = []
    for element_file in sorted(elements_root.rglob(constants.EXPORT_ELEMENT_FILENAME)):
        found.append(element_file.parent)
    return sorted(set(found))


def _record_from_dir(
    element_dir: Path,
    repo_root: Path,
    elements_root: Path,
) -> ExportElementRecord:
    """Build and structurally validate one element export record.

    Raises:
        StageInputMalformed: a pair member missing, unparseable, mis-shaped, or
            a cross-link failed.
        StageInputUnreadable: a member could not be read.
    """
    rel_dir = element_dir.relative_to(repo_root).as_posix()
    rel_parts = element_dir.relative_to(elements_root).parts
    name = rel_parts[-1]
    category = rel_parts[0]
    label = f"{category}/{name}"

    _require_pair_present(element_dir, rel_dir, label)

    element_json = _load_json_object(
        element_dir / constants.EXPORT_ELEMENT_FILENAME,
        repo_root,
    )
    _load_json_value(element_dir / constants.EXPORT_CHECKS_FILENAME, repo_root)

    compact_id = _check_export_shape(element_json, rel_dir, category, label)

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


def _require_pair_present(element_dir: Path, rel_dir: str, label: str) -> None:
    """Verify both export pair members exist, else raise naming the path."""
    for member in constants.EXPORT_PAIR_MEMBERS:
        if not (element_dir / member).is_file():
            msg = f"element export {label!r} is missing pair member {member!r}"
            raise StageInputMalformed(msg, location=f"{rel_dir}/{member}")


def _load_json_value(path: Path, repo_root: Path) -> object:
    """Load and parse a JSON 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

    return data


def _load_json_object(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()
    data = _load_json_value(path, repo_root)
    if not is_table(data):
        msg = f"{rel} must be a JSON object"
        raise StageInputMalformed(msg, location=rel)
    return data


def _check_export_shape(
    element_json: dict[str, object],
    rel_dir: str,
    category: str,
    label: str,
) -> str:
    """Check exported element.json minimal shape; return its compact_id.

    Raises:
        StageInputMalformed: shape problem or category cross-link failure.
    """
    rel = f"{rel_dir}/{constants.EXPORT_ELEMENT_FILENAME}"

    try:
        identity = require_sub_object(
            element_json, constants.EXPORT_ELEMENT_IDENTITY, rel
        )
        classification = require_sub_object(
            element_json, constants.EXPORT_ELEMENT_CLASSIFICATION, rel
        )
        compact_id = require_string(
            identity, constants.EXPORT_ELEMENT_COMPACT_ID_KEY, rel
        )
        group = require_string(
            classification, constants.EXPORT_ELEMENT_COMPONENT_GROUP_KEY, rel
        )
    except ValueError as exc:
        raise StageInputMalformed(str(exc), location=rel) from exc

    # Structural cross-link: declared component group matches on-disk category.
    if group != category:
        msg = (
            f"element export {label!r}: classification.component_group "
            f"{group!r} does not match on-disk category {category!r}"
        )
        raise StageInputMalformed(msg, location=rel)

    return compact_id
