# ============================================================
# src/accountable_record/stage_070_resolution/lock.py
# ============================================================
"""Load and structurally validate stage 070 resolution locks.

WHY:
    Stage 070 owns resolution/lock material. This module reads
    data/locks/elements.lock.json, parses JSON, checks the minimal observed
    shape, and returns an ElementsLock model. It does not build diagnostics;
    callers convert typed failures into AR.RS.* diagnostics.

OBS:
    This layer checks structure, not semantic resolution correctness. It does
    not recompute digests and does not compare locks to exports.
"""

import json
from pathlib import Path
from typing import Any

from accountable_record.errors import (
    StageInputMalformed,
    StageInputMissing,
    StageInputUnreadable,
)
from accountable_record.stage_070_resolution.base import constants
from accountable_record.stage_070_resolution.base.scalars import (
    expect_literal,
    expect_schema_tag,
    expect_sha256_hex,
    require_bool,
    require_keys,
    require_object,
    require_object_list,
    require_string,
    require_string_list,
    require_sub_object,
)
from accountable_record.stage_070_resolution.model import (
    ElementLockRecord,
    ElementsLock,
    PackageLockRecord,
    ResolutionPolicy,
)


def load_elements_lock(
    repo_root: Path, lock_rel_path: str | None = None
) -> ElementsLock:
    """Load and validate the elements lock file.

    Args:
        repo_root: Target repository root.
        lock_rel_path: Repository-relative lock path. Defaults to
            data/locks/elements.lock.json.

    Returns:
        Parsed elements lock model.

    Raises:
        StageInputMissing: If the lock root or lock file is missing.
        StageInputUnreadable: If the lock file cannot be read or parsed as JSON.
        StageInputMalformed: If the parsed JSON lacks the minimal lock shape.
    """
    rel_path = lock_rel_path or constants.ELEMENTS_LOCK_PATH
    lock_path = repo_root / rel_path

    locks_root = repo_root / constants.LOCKS_DIRNAME
    if not locks_root.is_dir():
        raise StageInputMissing(
            f"resolution locks are expected but {constants.LOCKS_DIRNAME}/ does not exist",
            location=constants.LOCKS_DIRNAME,
        )

    if not lock_path.is_file():
        raise StageInputMissing(
            f"resolution locks are expected but {rel_path} is missing",
            location=rel_path,
        )

    try:
        raw = json.loads(lock_path.read_text(encoding="utf-8"))
    except OSError as exc:
        raise StageInputUnreadable(
            f"{rel_path} could not be read: {exc}",
            location=rel_path,
        ) from exc
    except json.JSONDecodeError as exc:
        raise StageInputUnreadable(
            f"{rel_path} is not valid JSON: {exc.msg}",
            location=rel_path,
        ) from exc

    try:
        root = require_object(raw, "elements lock")
        require_keys(root, constants.LOCK_REQUIRED_KEYS, "elements lock")
        expect_schema_tag(
            root,
            constants.ELEMENT_LOCK_SCHEMA_TAG,
            "elements lock",
        )

        policy = _parse_policy(
            require_sub_object(
                root,
                constants.LOCK_KEY_RESOLUTION_POLICY,
                "elements lock",
            )
        )
        packages = _parse_packages(
            require_object_list(
                root,
                constants.LOCK_KEY_PACKAGES,
                "elements lock",
            )
        )
        elements = _parse_elements(
            require_object_list(
                root,
                constants.LOCK_KEY_ELEMENTS,
                "elements lock",
            )
        )

        resolved_at = require_string(
            root,
            constants.LOCK_KEY_RESOLVED_AT,
            "elements lock",
        )
        schema = require_string(root, constants.LOCK_KEY_SCHEMA, "elements lock")
    except ValueError as exc:
        raise StageInputMalformed(
            f"{rel_path} is invalid: {exc}",
            location=rel_path,
        ) from exc

    return ElementsLock(
        target_root=str(repo_root),
        rel_path=rel_path,
        schema=schema,
        resolved_at=resolved_at,
        policy=policy,
        packages=tuple(packages),
        elements=tuple(elements),
    )


def _parse_policy(table: dict[str, Any]) -> ResolutionPolicy:
    """Parse the resolution_policy object."""
    require_keys(table, constants.POLICY_REQUIRED_KEYS, "resolution policy")
    return ResolutionPolicy(
        one_major_version_per_identity_per_graph=require_bool(
            table,
            constants.POLICY_ONE_MAJOR_VERSION_PER_IDENTITY_PER_GRAPH,
            "resolution policy",
        ),
        range_conflicts_are_errors=require_bool(
            table,
            constants.POLICY_RANGE_CONFLICTS_ARE_ERRORS,
            "resolution policy",
        ),
    )


def _parse_packages(records: list[dict[str, Any]]) -> list[PackageLockRecord]:
    """Parse package lock records."""
    parsed: list[PackageLockRecord] = []

    for index, record in enumerate(records):
        context = _record_context("package lock record", index, record)
        require_keys(record, constants.PACKAGE_REQUIRED_KEYS, context)

        compact_id = require_string(record, constants.PACKAGE_KEY_COMPACT_ID, context)
        digest = require_string(record, constants.PACKAGE_KEY_DIGEST, context)

        expect_literal(
            record,
            constants.PACKAGE_KEY_DIGEST_ALGORITHM,
            constants.DIGEST_ALGORITHM_SHA256,
            context,
        )
        expect_literal(
            record,
            constants.PACKAGE_KEY_DIGEST_TARGET,
            constants.DIGEST_TARGET_CANONICAL_GENERATED_JSON,
            context,
        )
        expect_sha256_hex(digest, context)

        parsed.append(
            PackageLockRecord(
                compact_id=compact_id,
                version=require_string(record, constants.PACKAGE_KEY_VERSION, context),
                canonical_uri=require_string(
                    record,
                    constants.PACKAGE_KEY_CANONICAL_URI,
                    context,
                ),
                digest_algorithm=require_string(
                    record,
                    constants.PACKAGE_KEY_DIGEST_ALGORITHM,
                    context,
                ),
                digest_target=require_string(
                    record,
                    constants.PACKAGE_KEY_DIGEST_TARGET,
                    context,
                ),
                digest=digest,
                source=require_string(record, constants.PACKAGE_KEY_SOURCE, context),
            )
        )

    return parsed


def _parse_elements(records: list[dict[str, Any]]) -> list[ElementLockRecord]:
    """Parse element lock records."""
    parsed: list[ElementLockRecord] = []

    for index, record in enumerate(records):
        context = _record_context("element lock record", index, record)
        require_keys(record, constants.ELEMENT_REQUIRED_KEYS, context)

        compact_id = require_string(record, constants.ELEMENT_KEY_COMPACT_ID, context)
        digest = require_string(record, constants.ELEMENT_KEY_DIGEST, context)

        expect_literal(
            record,
            constants.ELEMENT_KEY_DIGEST_ALGORITHM,
            constants.DIGEST_ALGORITHM_SHA256,
            context,
        )
        expect_literal(
            record,
            constants.ELEMENT_KEY_DIGEST_TARGET,
            constants.DIGEST_TARGET_CANONICAL_GENERATED_JSON,
            context,
        )
        expect_sha256_hex(digest, context)

        parsed.append(
            ElementLockRecord(
                compact_id=compact_id,
                version=require_string(record, constants.ELEMENT_KEY_VERSION, context),
                included_by=tuple(
                    require_string_list(
                        record,
                        constants.ELEMENT_KEY_INCLUDED_BY,
                        context,
                    )
                ),
                digest_algorithm=require_string(
                    record,
                    constants.ELEMENT_KEY_DIGEST_ALGORITHM,
                    context,
                ),
                digest_target=require_string(
                    record,
                    constants.ELEMENT_KEY_DIGEST_TARGET,
                    context,
                ),
                digest=digest,
            )
        )

    return parsed


def _record_context(prefix: str, index: int, record: dict[str, Any]) -> str:
    """Return a useful record label for shape errors."""
    compact_id = record.get("compact_id")
    if isinstance(compact_id, str) and compact_id.strip():
        return f"{prefix} {compact_id!r}"
    return f"{prefix} at index {index}"
