"""Structural discovery of source materials (stage 030 op).

WHY:
    Discovery turns a target repository's declared sources into SourceRecords.
    It is the op the validation layer drives. Like stage 010's 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.SR.* code.

OBS (structural, not interpretive):
    Discovery reads ONLY structural facts: the [sources] declaration in the
    manifest, each entry's identifier / path / optional role, whether the path
    exists, and whether it resolves inside the target root. It never opens a
    source file's content, never parses it, never judges its meaning, truth, or
    authority. present is established with a filesystem existence check, not
    by reading bytes. This is what keeps AR.SOURCE.RECORD's separation intact:
    sources are recorded as origins, not interpreted.

OBS:
    Failures are raised as typed errors from accountable_record.errors:
        StageInputMissing      -> sources table/dir absent
        StageInputMalformed    -> a declaration is structurally invalid
        StageInputOutOfRoot    -> a path escapes the target root
        StageInputUnreadable   -> a present path could not be stat-checked
    The validation layer matches on type; no message inspection.
"""

from pathlib import Path
import tomllib

from accountable_record.boundary.toml import is_table
from accountable_record.errors import (
    StageInputMalformed,
    StageInputMissing,
    StageInputOutOfRoot,
    StageInputUnreadable,
)
from accountable_record.stage_010_identity.base.constants import (
    allowed_manifest_filenames,
)
from accountable_record.stage_030_sources.base.constants import (
    DEFAULT_SOURCES_DIRNAME,
    ENTRY_KEY_ID,
    ENTRY_KEY_PATH,
    ENTRY_KEY_ROLE,
    SOURCES_KEY_ENTRIES,
    SOURCES_TABLE,
)
from accountable_record.stage_030_sources.base.scalars import (
    entry_list,
    optional_string,
    required_string,
)
from accountable_record.stage_030_sources.model import SourceRecord


def discover_sources(repo_root: Path) -> list[SourceRecord]:
    """Discover declared source materials under repo_root, structurally.

    Args:
        repo_root: The target repository root.

    Returns:
        A list of :class:`SourceRecord`, one per declared source entry, in
        declaration order. May be empty only when a sources directory exists
        but declares no entries; total absence raises StageInputMissing.

    Raises:
        StageInputMissing: No [sources] table and no conventional sources
            directory.
        StageInputMalformed: The manifest or a source declaration is
            structurally invalid.
        StageInputOutOfRoot: A declared path escapes the target root.
        StageInputUnreadable: A present path could not be checked.
    """
    sources_table = _read_sources_table(repo_root)

    if sources_table is None:
        # No declaration. A conventional sources directory may still exist;
        # its mere presence is not itself a set of declared entries, so absence
        # of both is the missing case.
        if not (repo_root / DEFAULT_SOURCES_DIRNAME).is_dir():
            msg = "No [sources] table and no conventional sources directory"
            raise StageInputMissing(msg)
        return []

    entries = entry_list(sources_table, SOURCES_KEY_ENTRIES)
    records: list[SourceRecord] = []
    for entry in entries:
        records.append(_record_from_entry(entry, repo_root))
    return records


def _read_sources_table(repo_root: Path) -> dict[str, object] | None:
    """Return the [sources] table from the manifest, or None when absent.

    Raises:
        StageInputMalformed: The manifest is not valid TOML, or [sources] is
            present but not a table.
    """
    manifest_path = _find_manifest(repo_root)
    if manifest_path is None:
        return None

    try:
        data: dict[str, object] = tomllib.loads(
            manifest_path.read_text(encoding="utf-8")
        )
    except tomllib.TOMLDecodeError as exc:
        raise StageInputMalformed(
            f"Manifest is not valid TOML: {exc}",
            location=str(manifest_path),
        ) from exc
    except OSError as exc:
        raise StageInputUnreadable(
            f"Manifest could not be read: {exc}",
            location=str(manifest_path),
        ) from exc

    table = data.get(SOURCES_TABLE)
    if table is None:
        return None
    if not is_table(table):
        raise StageInputMalformed(
            f"[{SOURCES_TABLE}] is present but is not a table",
            location=str(manifest_path),
        )
    return table


def _find_manifest(repo_root: Path) -> Path | None:
    """Return the manifest path under repo_root, or None when absent."""
    for name in allowed_manifest_filenames():
        candidate = repo_root / name
        if candidate.is_file():
            return candidate
    return None


def _record_from_entry(entry: dict[str, object], repo_root: Path) -> SourceRecord:
    """Build a SourceRecord from one declared entry, structurally.

    Raises:
        StageInputMalformed: The entry lacks a required id or path.
        StageInputOutOfRoot: The entry path escapes the target root.
        StageInputUnreadable: The path could not be checked for presence.
    """
    try:
        source_id = required_string(entry, ENTRY_KEY_ID, "source entry")
        rel_path = required_string(entry, ENTRY_KEY_PATH, f"source {entry!r}")
        role = optional_string(entry, ENTRY_KEY_ROLE)
    except ValueError as exc:
        raise StageInputMalformed(str(exc)) from exc

    present = _check_present_within_root(source_id, rel_path, repo_root)

    return SourceRecord(
        source_id=source_id,
        rel_path=rel_path,
        role=role,
        present=present,
    )


def _check_present_within_root(
    source_id: str,
    rel_path: str,
    repo_root: Path,
) -> bool:
    """Return whether rel_path exists and stays inside repo_root.

    OBS:
        Existence only: a filesystem stat, never a content read. Path
        containment is checked structurally by resolving and comparing against
        the resolved root.

    Raises:
        StageInputOutOfRoot: The resolved path is not within the root.
        StageInputUnreadable: The path could not be stat-checked.
    """
    root_resolved = repo_root.resolve()
    candidate = (repo_root / rel_path).resolve()

    if root_resolved not in (candidate, *candidate.parents):
        raise StageInputOutOfRoot(
            f"Source {source_id!r} path {rel_path!r} escapes the target root",
            location=rel_path,
        )

    try:
        return candidate.exists()
    except OSError as exc:
        raise StageInputUnreadable(
            f"Source {source_id!r} path {rel_path!r} could not be checked: {exc}",
            location=rel_path,
        ) from exc
