# ============================================================
# src/accountable_record/pipeline/manifest_schema.py
# ============================================================
"""Manifest-schema loading and schema-derived policy access.

WHY:
    Multiple pipeline stages need facts declared by the manifest schema:
    allowed manifest filenames, required/optional/forbidden sections by repo
    class, field definitions, validation rules, and future canonical-version
    authority. Those facts should be read once from schema TOML and exposed as
    data, not re-hardcoded in each stage.

OBS:
    This module is not a validator. It provides read-only access to parsed
    schema policy so stages can make their own stage-specific decisions.

OBS:
    The manifest schema is data. Runtime policy such as "which file is the
    canonical version source for this repository class" must be read from schema
    data when that policy exists, not encoded as Python if/else logic.

REQ:
    - No dependency on stage modules.
    - No diagnostics here.
    - No PipelineState mutation here.
    - No network access here.
    - Importing this module performs no I/O.
"""

from dataclasses import dataclass
from pathlib import Path
import tomllib
from typing import TypeIs


def _is_table(value: object) -> TypeIs[dict[str, object]]:
    """Narrow an opaque TOML value to a table.

    WHY:
        ``isinstance(value, dict)`` narrows ``object`` to
        ``dict[Unknown, Unknown]`` under strict type checking, which
        reintroduces Unknown types throughout. TOML guarantees that any parsed
        table has ``str`` keys, so this guard asserts the precise
        ``dict[str, object]`` shape that the rest of the module relies on.
    """
    return isinstance(value, dict)


def _is_list(value: object) -> TypeIs[list[object]]:
    """Narrow an opaque TOML value to a list.

    WHY:
        Like ``_is_table``, this preserves a precise element type
        (``list[object]``) instead of letting ``isinstance`` decay the value to
        ``list[Unknown]`` under strict type checking.
    """
    return isinstance(value, list)


__all__ = [
    "DEFAULT_SCHEMA_FILENAME",
    "ManifestSchemaError",
    "SectionPolicy",
    "FieldPolicy",
    "ClassPolicy",
    "CanonicalVersionAuthority",
    "ManifestSchema",
    "load_manifest_schema",
    "load_manifest_schema_if_present",
    "find_manifest_schema_path",
]

DEFAULT_SCHEMA_FILENAME = "manifest-schema.toml"


class ManifestSchemaError(ValueError):
    """Raised when manifest-schema TOML is unreadable or malformed."""


@dataclass(frozen=True)
class SectionPolicy:
    """Schema policy for a manifest section.

    Attributes:
        name: Section name.
        required: Whether the section is globally required by section registry.
        description: Human-readable section description.
        allowed_fields: Fields allowed in the section.
    """

    name: str
    required: bool
    description: str
    allowed_fields: tuple[str, ...]


@dataclass(frozen=True)
class FieldPolicy:
    """Schema policy for a manifest field.

    Attributes:
        section: Section name.
        name: Field name.
        type_name: Schema-declared type name.
        required: Whether the field is required.
        constraints: Schema-declared constraints.
    """

    section: str
    name: str
    type_name: str
    required: bool
    constraints: tuple[str, ...]


@dataclass(frozen=True)
class ClassPolicy:
    """Schema policy for a repository class.

    Attributes:
        name: Repository class name.
        required_repo_name_patterns: Accepted repository naming patterns.
        required_layer_roles: Accepted layer roles for this class.
        required_sections: Required manifest sections for this class.
        optional_sections: Optional manifest sections for this class.
        forbidden_sections: Forbidden manifest sections for this class.
        required_compatibility_fields: Required compatibility fields.
    """

    name: str
    required_repo_name_patterns: tuple[str, ...]
    required_layer_roles: tuple[str, ...]
    required_sections: tuple[str, ...]
    optional_sections: tuple[str, ...]
    forbidden_sections: tuple[str, ...]
    required_compatibility_fields: tuple[str, ...]


@dataclass(frozen=True)
class CanonicalVersionAuthority:
    """Schema-declared canonical version authority for a repository class.

    Attributes:
        repo_class: Repository class this authority applies to.
        source_file: Repository-relative file that contains the canonical
            version.
        source_field: Optional field path inside the source file.
        reason: Optional schema-provided explanation.
    """

    repo_class: str
    source_file: str
    source_field: str | None = None
    reason: str = ""


@dataclass(frozen=True)
class ManifestSchema:
    """Read-only accessor for parsed manifest-schema TOML."""

    path: Path
    data: dict[str, object]

    @property
    def schema_version(self) -> str | None:
        """Return [meta].version when present."""
        meta = self._table("meta")
        value = meta.get("version")
        return value if isinstance(value, str) else None

    @property
    def manifest_filename(self) -> str:
        """Return [manifest].filename."""
        manifest = self._required_table("manifest")
        value = manifest.get("filename")
        if not isinstance(value, str) or not value.strip():
            msg = "manifest schema requires [manifest].filename"
            raise ManifestSchemaError(msg)
        return value.strip()

    @property
    def allowed_manifest_filenames(self) -> tuple[str, ...]:
        """Return [manifest].allowed_filenames."""
        manifest = self._required_table("manifest")
        return _required_string_tuple(
            manifest,
            "allowed_filenames",
            "[manifest]",
        )

    def section_policy(self, section_name: str) -> SectionPolicy | None:
        """Return policy for one manifest section, or None when absent."""
        section_root = self._table("section")
        raw = section_root.get(section_name)
        if raw is None:
            return None
        if not _is_table(raw):
            msg = f"[section.{section_name}] must be a table"
            raise ManifestSchemaError(msg)

        required = raw.get("required")
        if not isinstance(required, bool):
            msg = f"[section.{section_name}].required must be boolean"
            raise ManifestSchemaError(msg)

        description = raw.get("description", "")
        if not isinstance(description, str):
            msg = f"[section.{section_name}].description must be string"
            raise ManifestSchemaError(msg)

        return SectionPolicy(
            name=section_name,
            required=required,
            description=description,
            allowed_fields=_required_string_tuple(
                raw,
                "allowed_fields",
                f"[section.{section_name}]",
            ),
        )

    def all_section_policies(self) -> dict[str, SectionPolicy]:
        """Return all section policies keyed by section name."""
        section_root = self._table("section")
        policies: dict[str, SectionPolicy] = {}

        for name in sorted(section_root):
            policy = self.section_policy(name)
            if policy is not None:
                policies[name] = policy

        return policies

    def field_policy(self, section_name: str, field_name: str) -> FieldPolicy | None:
        """Return policy for one manifest field, or None when absent."""
        field_root = self._table("field")
        section = _optional_table(field_root, section_name, "[field]")
        if section is None:
            return None

        context = f"[field.{section_name}.{field_name}]"
        raw = _optional_table(section, field_name, f"[field.{section_name}]")
        if raw is None:
            return None

        return FieldPolicy(
            section=section_name,
            name=field_name,
            type_name=_required_string(raw, "type", context),
            required=_required_bool(raw, "required", context),
            constraints=_optional_string_tuple(raw, "constraints", context),
        )

    def class_policy(self, repo_class: str) -> ClassPolicy | None:
        """Return policy for one repository class, or None when absent."""
        class_root = self._table("class")
        raw = class_root.get(repo_class)
        if raw is None:
            return None
        if not _is_table(raw):
            msg = f"[class.{repo_class}] must be a table"
            raise ManifestSchemaError(msg)

        context = f"[class.{repo_class}]"
        return ClassPolicy(
            name=repo_class,
            required_repo_name_patterns=_required_string_tuple(
                raw,
                "required_repo_name_patterns",
                context,
            ),
            required_layer_roles=_required_string_tuple(
                raw,
                "required_layer_roles",
                context,
            ),
            required_sections=_required_string_tuple(
                raw,
                "required_sections",
                context,
            ),
            optional_sections=_required_string_tuple(
                raw,
                "optional_sections",
                context,
            ),
            forbidden_sections=_required_string_tuple(
                raw,
                "forbidden_sections",
                context,
            ),
            required_compatibility_fields=_required_string_tuple(
                raw,
                "required_compatibility_fields",
                context,
            ),
        )

    def all_class_policies(self) -> dict[str, ClassPolicy]:
        """Return all repository-class policies keyed by class name."""
        class_root = self._table("class")
        policies: dict[str, ClassPolicy] = {}

        for name in sorted(class_root):
            policy = self.class_policy(name)
            if policy is not None:
                policies[name] = policy

        return policies

    def validation_rule(self, dotted_key: str) -> object | None:
        """Return a validation rule by dotted key below [validation].

        Example:
            validation_rule("contract.contract_role_must_be_known")

        The return type is ``object | None``: callers must narrow the result
        with ``isinstance`` before use, because a validation rule may be any
        TOML scalar, list, or table depending on the rule.
        """
        current: object = self._table("validation")
        for part in dotted_key.split("."):
            if not _is_table(current) or part not in current:
                return None
            current = current[part]
        return current

    def canonical_version_authority(
        self,
        repo_class: str,
    ) -> CanonicalVersionAuthority | None:
        """Return schema-declared canonical version authority, if present."""
        authority_root = self._table("version_authority")
        class_root = _optional_table(authority_root, "class", "[version_authority]")
        if class_root is None:
            return None

        context = f"[version_authority.class.{repo_class}]"
        raw = _optional_table(class_root, repo_class, "[version_authority.class]")
        if raw is None:
            return None

        return CanonicalVersionAuthority(
            repo_class=repo_class,
            source_file=_required_string(raw, "source_file", context),
            source_field=_optional_string(raw, "source_field", context),
            reason=_optional_string(raw, "reason", context) or "",
        )

    def _table(self, key: str) -> dict[str, object]:
        """Return an optional top-level table, or empty dict when absent."""
        value = self.data.get(key)
        if value is None:
            return {}
        if not _is_table(value):
            msg = f"[{key}] must be a table"
            raise ManifestSchemaError(msg)
        return value

    def _required_table(self, key: str) -> dict[str, object]:
        """Return a required top-level table."""
        value = self.data.get(key)
        if not _is_table(value):
            msg = f"manifest schema requires [{key}] table"
            raise ManifestSchemaError(msg)
        return value


def load_manifest_schema(path: Path) -> ManifestSchema:
    """Load manifest-schema TOML from a path.

    Args:
        path: Path to manifest-schema.toml.

    Returns:
        Parsed manifest schema accessor.

    Raises:
        ManifestSchemaError: If the file cannot be read or parsed.
    """
    try:
        with path.open("rb") as handle:
            data = tomllib.load(handle)
    except OSError as exc:
        msg = f"Could not read manifest schema {path}: {exc}"
        raise ManifestSchemaError(msg) from exc
    except tomllib.TOMLDecodeError as exc:
        msg = f"Could not parse manifest schema {path}: {exc}"
        raise ManifestSchemaError(msg) from exc

    if not _is_table(data):
        msg = f"Manifest schema {path} did not parse to a table"
        raise ManifestSchemaError(msg)

    return ManifestSchema(path=path, data=data)


def load_manifest_schema_if_present(path: Path) -> ManifestSchema | None:
    """Load manifest schema when present, else return None."""
    if not path.is_file():
        return None
    return load_manifest_schema(path)


def find_manifest_schema_path(repo_root: Path) -> Path | None:
    """Return the first local manifest-schema TOML path found.

    OBS:
        This intentionally avoids network access. If a repository points to a
        remote schema URL, earlier/later policy may decide how to vendor or
        resolve it. This helper only finds local files.
    """
    candidates = (
        repo_root / DEFAULT_SCHEMA_FILENAME,
        repo_root / "data" / "schemas" / DEFAULT_SCHEMA_FILENAME,
        repo_root / "schemas" / DEFAULT_SCHEMA_FILENAME,
    )

    for candidate in candidates:
        if candidate.is_file():
            return candidate

    return None


def _required_string_tuple(
    table: dict[str, object],
    key: str,
    context: str,
) -> tuple[str, ...]:
    """Return a required list[string] as tuple[str, ...]."""
    value = table.get(key)
    if not _is_list(value):
        msg = f"{context} requires {key!r} to be list[string]"
        raise ManifestSchemaError(msg)

    result: list[str] = []
    for index, item in enumerate(value):
        if not isinstance(item, str) or not item.strip():
            msg = f"{context} {key!r}[{index}] must be a non-empty string"
            raise ManifestSchemaError(msg)
        result.append(item.strip())

    return tuple(result)


def _optional_table(
    table: dict[str, object],
    key: str,
    context: str,
) -> dict[str, object] | None:
    """Return an optional nested table."""
    value = table.get(key)
    if value is None:
        return None
    if not _is_table(value):
        msg = f"{context}.{key} must be a table"
        raise ManifestSchemaError(msg)
    return value


def _required_string(
    table: dict[str, object],
    key: str,
    context: str,
) -> str:
    """Return a required non-empty string."""
    value = table.get(key)
    if not isinstance(value, str) or not value.strip():
        msg = f"{context}.{key} must be a non-empty string"
        raise ManifestSchemaError(msg)
    return value.strip()


def _optional_string(
    table: dict[str, object],
    key: str,
    context: str,
) -> str | None:
    """Return an optional non-empty string."""
    value = table.get(key)
    if value is None:
        return None
    if not isinstance(value, str):
        msg = f"{context}.{key} must be a string when present"
        raise ManifestSchemaError(msg)
    stripped = value.strip()
    return stripped or None


def _required_bool(
    table: dict[str, object],
    key: str,
    context: str,
) -> bool:
    """Return a required boolean."""
    value = table.get(key)
    if not isinstance(value, bool):
        msg = f"{context}.{key} must be boolean"
        raise ManifestSchemaError(msg)
    return value


def _optional_string_tuple(
    table: dict[str, object],
    key: str,
    context: str,
) -> tuple[str, ...]:
    """Return an optional list[string] as tuple[str, ...]."""
    value = table.get(key)
    if value is None:
        return ()
    if not _is_list(value):
        msg = f"{context}.{key} must be list[string] when present"
        raise ManifestSchemaError(msg)

    result: list[str] = []
    for index, item in enumerate(value):
        if not isinstance(item, str) or not item.strip():
            msg = f"{context}.{key}[{index}] must be a non-empty string"
            raise ManifestSchemaError(msg)
        result.append(item.strip())

    return tuple(result)
