"""Data-driven discovery and structural validation of element quartets (s040 op).

WHY:
    Discovery walks data/elements/ and turns each authored element directory
    into an ElementRecord. Like the other stages' read ops it stays free of
    diagnostics: it returns records or raises a typed StageError, and the
    validation layer maps the error to an AR.EL.* code.

OBS (data-driven requirement):
    The walk identifies an element directory by the presence of the quartet,
    NOT by a hardcoded category or name list. Category and name come from the
    path segments. No constant in this module enumerates categories, names, or
    a count.

OBS (structural, not full semantics):
    For each quartet member, discovery checks: the file exists, it parses
    (TOML/JSON), and it carries the minimal observed shape (schema tag,
    required tables/keys, JSON object). It also checks two structural
    cross-links: component_group == on-disk category, and checks.toml
    element_type_id == element.toml compact_id. It does NOT validate examples
    against their schema or interpret rule contents. Those are later stages.

OBS (scope):
    Walks data/elements/ only. Never reads data/export/elements/ or the lock.

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

import json
from pathlib import Path
import tomllib
from typing import Any

from accountable_record.errors import (
    StageInputMalformed,
    StageInputMissing,
    StageInputUnreadable,
)
from accountable_record.stage_040_elements.base import constants
from accountable_record.stage_040_elements.base.scalars import (
    expect_object,
    expect_schema_tag,
    require_keys,
    require_string,
    require_table,
)
from accountable_record.stage_040_elements.model import ElementRecord


def discover_elements(
    repo_root: Path,
    *,
    elements_dirname: str = constants.ELEMENTS_DIRNAME,
) -> list[ElementRecord]:
    """Discover and structurally validate authored element quartets.

    Args:
        repo_root: The target repository root.
        elements_dirname: Repository-relative directory to walk, from the
            resolved [elements].declarations directive. Defaults to
            data/elements so existing callers and the no-manifest fallback
            behave as before.

    Returns:
        A list of ElementRecord, one per discovered element directory, in
        sorted (category, name) order for determinism.

    Raises:
        StageInputMissing: the declared elements directory is absent, or no
            element directories were discovered beneath it.
        StageInputMalformed: a quartet member is missing, fails to parse, lacks
            minimal shape, or a structural cross-link did not hold.
        StageInputUnreadable: a file could not be read.
    """
    elements_root = repo_root / elements_dirname
    if not elements_root.is_dir():
        msg = f"{elements_dirname}/ does not exist"
        raise StageInputMissing(msg, location=elements_dirname)

    element_dirs = _find_element_dirs(elements_root)
    if not element_dirs:
        msg = f"No element directories discovered beneath {elements_dirname}/"
        raise StageInputMissing(msg, location=elements_dirname)

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


def _find_element_dirs(elements_root: Path) -> list[Path]:
    """Find element directories by quartet presence, data-driven.

    OBS:
        An element directory is any directory beneath data/elements/ that
        contains element.toml. The walk does not assume a fixed two-level
        <category>/<element> depth beyond what the path yields; category/name
        are derived from the directory's position relative to elements_root.
        Directories without element.toml (e.g. category containers, examples/)
        are simply not element directories.
    """
    found: list[Path] = []
    for element_file in sorted(elements_root.rglob(constants.ELEMENT_FILENAME)):
        # rglob may match an element.toml nested under examples/ or similar;
        # accept the directory that directly contains it.
        found.append(element_file.parent)
    return sorted(set(found))


def _record_from_dir(
    element_dir: Path,
    repo_root: Path,
    elements_root: Path,
) -> ElementRecord:
    """Build and structurally validate one element record from its directory.

    Raises:
        StageInputMalformed: a member is 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()
    category, name = _category_and_name(element_dir, elements_root)
    label = f"{category}/{name}"

    _require_quartet_present(element_dir, rel_dir, label)

    element_toml = _load_toml(
        element_dir / constants.ELEMENT_FILENAME,
        repo_root,
    )
    checks_toml = _load_toml(
        element_dir / constants.CHECKS_FILENAME,
        repo_root,
    )
    _load_json(element_dir / constants.SCHEMA_FILENAME, repo_root)
    _load_json(
        element_dir / constants.EXAMPLES_DIRNAME / constants.EXAMPLE_VALID_FILENAME,
        repo_root,
    )

    compact_id = _check_element_shape(element_toml, rel_dir, category, label)
    _check_checks_shape(checks_toml, rel_dir, compact_id, label)
    # schema/example minimal shape are checked at load time below via helpers;
    # see _load_json_object for the example-object requirement.

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


def _category_and_name(element_dir: Path, elements_root: Path) -> tuple[str, str]:
    """Derive (category, name) from the element directory path.

    OBS (data-driven):
        name is the element directory's own name; category is the first path
        segment beneath data/elements/. Read from the path, never hardcoded.
    """
    rel_parts = element_dir.relative_to(elements_root).parts

    if len(rel_parts) < 2:
        msg = (
            "Element directory must be at least two path segments beneath "
            f"{elements_root}: {element_dir}"
        )
        raise ValueError(msg)

    category = rel_parts[0]
    name = rel_parts[-1]
    return category, name


def _require_quartet_present(element_dir: Path, rel_dir: str, label: str) -> None:
    """Verify all four quartet members exist, else raise naming the path."""
    members = (
        element_dir / constants.ELEMENT_FILENAME,
        element_dir / constants.CHECKS_FILENAME,
        element_dir / constants.SCHEMA_FILENAME,
        element_dir / constants.EXAMPLES_DIRNAME / constants.EXAMPLE_VALID_FILENAME,
    )
    for member in members:
        if not member.is_file():
            member_rel = member.relative_to(element_dir).as_posix()
            msg = f"Element {label!r} is missing quartet member {member_rel!r}"
            raise StageInputMalformed(msg, location=f"{rel_dir}/{member_rel}")


def _load_toml(path: Path, repo_root: Path) -> dict[str, Any]:
    """Load and parse a TOML 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:
        return tomllib.loads(text)
    except tomllib.TOMLDecodeError as exc:
        raise StageInputMalformed(f"invalid TOML: {exc}", location=rel) from exc


def _load_json(path: Path, repo_root: Path) -> Any:
    """Load and parse a JSON file, raising typed errors naming the path.

    OBS:
        For examples/valid.json the parsed value must be a JSON object; that
        object requirement is enforced here so a malformed example is caught at
        load time with a precise 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:
        value = json.loads(text)
    except json.JSONDecodeError as exc:
        raise StageInputMalformed(f"invalid JSON: {exc}", location=rel) from exc

    # The example file (and the schema) must be JSON objects in every observed
    # case; enforce the object shape structurally.
    if path.name in (constants.EXAMPLE_VALID_FILENAME, constants.SCHEMA_FILENAME):
        try:
            expect_object(value, rel)
        except ValueError as exc:
            raise StageInputMalformed(str(exc), location=rel) from exc
        if path.name == constants.SCHEMA_FILENAME:
            try:
                require_keys(value, constants.SCHEMA_REQUIRED_KEYS, rel)
            except ValueError as exc:
                raise StageInputMalformed(str(exc), location=rel) from exc
    return value


def _check_element_shape(
    element_toml: dict[str, Any],
    rel_dir: str,
    category: str,
    label: str,
) -> str:
    """Check element.toml minimal shape; return its compact_id.

    Raises:
        StageInputMalformed: shape problem or category cross-link failure.
    """
    rel = f"{rel_dir}/{constants.ELEMENT_FILENAME}"
    try:
        expect_schema_tag(element_toml, constants.ELEMENT_SCHEMA_TAG, rel)
        require_keys(element_toml, constants.ELEMENT_REQUIRED_TABLES, rel)
        identity = require_table(element_toml, "identity", rel)
        require_keys(identity, constants.ELEMENT_IDENTITY_REQUIRED_KEYS, rel)
        compact_id = require_string(identity, "compact_id", rel)
        classification = require_table(element_toml, "classification", rel)
        group = require_string(
            classification,
            constants.ELEMENT_CLASSIFICATION_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 {label!r}: [classification].component_group {group!r} "
            f"does not match on-disk category {category!r}"
        )
        raise StageInputMalformed(msg, location=rel)

    return compact_id


def _check_checks_shape(
    checks_toml: dict[str, Any],
    rel_dir: str,
    compact_id: str,
    label: str,
) -> None:
    """Check checks.toml minimal shape and the element_type_id cross-link.

    Raises:
        StageInputMalformed: shape problem or element_type_id cross-link
            failure.
    """
    rel = f"{rel_dir}/{constants.CHECKS_FILENAME}"
    try:
        expect_schema_tag(checks_toml, constants.CHECKS_SCHEMA_TAG, rel)
        declared = require_string(
            checks_toml,
            constants.CHECKS_ELEMENT_TYPE_ID_KEY,
            rel,
        )
    except ValueError as exc:
        raise StageInputMalformed(str(exc), location=rel) from exc

    # Structural cross-link: checks element_type_id matches element compact_id.
    if declared != compact_id:
        msg = (
            f"Element {label!r}: checks.toml element_type_id {declared!r} "
            f"does not match element.toml compact_id {compact_id!r}"
        )
        raise StageInputMalformed(msg, location=rel)
