"""stage_050_catalog/index.py. Reconcile the built catalog against the declared catalog (stage 050 op).

WHY:
    index.py compares the catalog built from validated elements against the
    repository's DECLARED element catalog (data/catalog/elements.json). It
    reports compact_ids that are validated-but-undeclared and
    declared-but-orphaned. This is the "reconcile/shape catalog entries against
    declared index data" responsibility, kept structural.

OBS (data-driven):
    The declared compact_id set is READ from data/catalog/elements.json at
    runtime. No element names, categories, or counts are hardcoded.

OBS (boundary):
    Reconciliation is a comparison only. It does not write files, does not
    touch data/export/, and does not reconcile lock digests.

OBS (missing declared catalog is tolerated):
    If the declared catalog is absent, reconciliation is skipped and reported
    as not-present rather than failing the stage; the catalog this stage builds
    does not depend on the declared file existing. A present-but-malformed
    declared catalog is a typed error.
"""

import json
from pathlib import Path

from accountable_record.boundary.json import is_table
from accountable_record.errors import StageInputMalformed, StageInputUnreadable
from accountable_record.stage_050_catalog.base import constants
from accountable_record.stage_050_catalog.base.scalars import (
    expect_schema_tag,
    require_list_of_objects,
    require_string,
)
from accountable_record.stage_050_catalog.model import Catalog, CatalogReconciliation


def reconcile_against_declared(
    catalog: Catalog,
    repo_root: Path,
) -> CatalogReconciliation:
    """Reconcile the built catalog against the declared catalog.

    Args:
        catalog: The catalog built from validated elements.
        repo_root: The target repository root.

    Returns:
        A :class:`CatalogReconciliation`. When the declared catalog is absent,
        declared_present is False and both diff sets are empty.

    Raises:
        StageInputMalformed: The declared catalog is present but unparseable or
            lacks its minimal shape.
        StageInputUnreadable: The declared catalog could not be read.
    """
    declared_path = repo_root / constants.DECLARED_CATALOG_PATH
    if not declared_path.is_file():
        return CatalogReconciliation(declared_present=False)

    declared_ids = _read_declared_compact_ids(declared_path)
    built_ids = catalog.compact_ids()

    undeclared = tuple(sorted(built_ids - declared_ids))
    orphaned = tuple(sorted(declared_ids - built_ids))

    return CatalogReconciliation(
        declared_present=True,
        undeclared=undeclared,
        orphaned=orphaned,
    )


def _read_declared_compact_ids(declared_path: Path) -> frozenset[str]:
    """Read the declared catalog and return its set of compact_ids.

    Raises:
        StageInputMalformed: Unparseable JSON or missing minimal shape.
        StageInputUnreadable: The file could not be read.
    """
    rel = constants.DECLARED_CATALOG_PATH

    try:
        text = declared_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):
        raise StageInputMalformed(
            "declared catalog must be a JSON object", location=rel
        )

    try:
        expect_schema_tag(data, constants.CATALOG_SCHEMA_TAG, rel)
        entries = require_list_of_objects(data, constants.CATALOG_KEY_ELEMENTS, rel)
        ids = {
            require_string(entry, constants.ENTRY_KEY_COMPACT_ID, rel)
            for entry in entries
        }
    except ValueError as exc:
        raise StageInputMalformed(str(exc), location=rel) from exc

    return frozenset(ids)
