"""Structural component output validators for plan trees, decisions, and sessions.

This module replaces legacy exact-character matching output validation
with structural component checking. Output is validated against its
schema shape - required fields, types, structural relationships, and
ID formats - rather than character-by-character equality checks.

Validators
----------

- :func:`validate_plan_tree` - validates plan tree node dicts for
  required keys (``decision_id``, ``type``, ``sequence``, ``question``,
  ``children``), ULID format, correct types, and sibling ordering.

- :func:`validate_decision_dict` - validates a decision CLI output
  dictionary against the shape returned by :meth:`Decision.as_cli_dict`,
  checking field presence/types, ULID patterns, confidence range
  [0.0, 1.0], and boolean fields.

- :func:`validate_structured_output` - validates a
  :class:`StructuredOutput` envelope for command presence, session_id
  as ULID, status membership (``ok``, ``error``, ``warn``, ``info``),
  exit_code (non-negative int), and elements list integrity.

- :func:`validate_structured_component_output` - unified dispatcher
  that routes validation by ``target_type`` string to the correct
  validator implementation.

Based on:
    - docs/specification.md Output Rendering Framework
    - Epic #8137 - structural output validation overhaul
"""

from __future__ import annotations

import re
from collections.abc import Callable
from typing import Any

# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------

ULID_PATTERN = r"^[0-9A-HJKMNP-TV-Z]{26}$"
_ULID_RE = re.compile(ULID_PATTERN)

VALID_STATUSES = frozenset({"ok", "error", "warn", "info"})

PLAN_TREE_REQUIRED_KEYS = frozenset(
    {"decision_id", "type", "sequence", "question", "children"}
)

DECISION_CLI_REQUIRED_FIELDS: dict[str, type | tuple[type, ...] | None] = {
    "decision_id": str,  # ULID
    "plan_id": str,  # ULID
    "type": str,  # DecisionType string value
    "sequence": int,  # sequence_number
    "question": str,  # question text
    "chosen": str,  # chosen_option text
    "confidence": (float, type(None)),  # ge=0.0, le=1.0 or None
    "parent": str,  # parent ULID or "(root)"
    "is_correction": bool,  # True / False
    "superseded": bool,  # True / False
}


# ---------------------------------------------------------------------------
# Error types
# ---------------------------------------------------------------------------


class ValidationError(Exception):
    """Raised when structural validation fails.

    Attributes:
        message: Human-readable description of the failure.
        errors: List of individual field-level error strings.
    """

    def __init__(self, message: str, errors: list[str] | None = None) -> None:
        self.message = message
        self.errors = errors or []
        super().__init__(f"{message}: {'; '.join(self.errors)}")


class ValidationWarning(Warning):
    """Raised for non-fatal structural issues (e.g. optional field missing)."""

    pass


# ---------------------------------------------------------------------------
# Plan tree validator
# ---------------------------------------------------------------------------


def validate_plan_tree(nodes: list[dict[str, Any]]) -> dict[str, Any]:
    """Validate a plan tree represented as a list of node dicts.

    Each node must be a dictionary with the following required structural
    keys:

    * ``decision_id`` - valid ULID (26-char base32 uppercase)
    * ``type`` - non-empty string describing the decision type
    * ``sequence`` - non-negative integer for sibling ordering
    * ``question`` - non-empty string describing the decision question
    * ``children`` - list of child node references or dicts

    Args:
        nodes: Flat list of plan tree node dictionaries.

    Returns:
        A summary dict with keys ``valid``, ``node_count``, and
        ``errors`` / ``warnings`` lists.

    Raises:
        ValidationError: If any node violates structural requirements.
    """

    errors: list[str] = []
    warnings: list[str] = []
    valid = True

    seen_ids: set[str] = set()
    seen_sequences: dict[tuple[Any, str], set[int]] = {}

    for idx, node in enumerate(nodes):
        if not isinstance(node, dict):
            errors.append(f"node[{idx}] is not a dict (got {type(node).__name__})")
            valid = False
            continue

        # --- Required keys ---
        missing_keys = PLAN_TREE_REQUIRED_KEYS - set(node.keys())
        if missing_keys:
            errors.append(
                f"node[{idx}] missing required key(s): {sorted(missing_keys)}"
            )
            valid = False

        # --- decision_id ---
        did = node.get("decision_id")
        if did is not None:
            if not isinstance(did, str) or not _ULID_RE.match(did):
                errors.append(
                    f"node[{idx}] 'decision_id' must be a valid ULID, got {did!r}"
                )
                valid = False
            elif did in seen_ids:
                errors.append(f"node[{idx}] duplicate decision_id: {did}")
                valid = False
            else:
                seen_ids.add(did)

        # --- type ---
        node_type = node.get("type")
        if node_type is not None and (
            not isinstance(node_type, str) or not node_type.strip()
        ):
            errors.append(f"node[{idx}] 'type' must be a non-empty string")
            valid = False

        # --- sequence ---
        seq = node.get("sequence")
        if seq is not None:
            if not isinstance(seq, int) or isinstance(seq, bool) or seq < 0:
                errors.append(
                    f"node[{idx}] 'sequence' must be a non-negative "
                    f"integer, got {seq!r}"
                )
                valid = False
            else:
                parent_key = node.get("parent_decision_id", None)
                decision_id = node.get("decision_id", "")
                sibling_key = (parent_key, "ANY")  # track per-parent, not per-node
                if sibling_key in seen_sequences and seq in seen_sequences[sibling_key]:
                    errors.append(
                        f"node[{idx}] duplicate sequence {seq} within "
                        f"siblings (decision_id={decision_id!r})"
                    )
                    valid = False
                else:
                    known_seqs = seen_sequences.get(sibling_key, set())
                    known_seqs.add(seq)
                    seen_sequences[sibling_key] = known_seqs

        # --- question ---
        question = node.get("question")
        if question is not None and (
            not isinstance(question, str) or not question.strip()
        ):
            errors.append(f"node[{idx}] 'question' must be a non-empty string")
            valid = False

        # --- children ---
        children = node.get("children")
        if children is not None:
            if not isinstance(children, list):
                errors.append(
                    f"node[{idx}] 'children' must be a list, "
                    f"got {type(children).__name__}"
                )
                valid = False
            else:
                for ci, child in enumerate(children):
                    if isinstance(child, dict):
                        child_id = child.get("decision_id")
                        if child_id and not _ULID_RE.match(str(child_id)):
                            errors.append(
                                f"node[{idx}].children[{ci}] 'decision_id' "
                                f"must be a valid ULID, got {child_id!r}"
                            )
                            valid = False

    return {
        "valid": valid,
        "node_count": len(nodes),
        "errors": errors,
        "warnings": warnings,
    }


# ---------------------------------------------------------------------------
# Decision CLI dict validator
# ---------------------------------------------------------------------------


def validate_decision_dict(d: dict[str, Any]) -> dict[str, Any]:
    """Validate a decision CLI output dictionary.

    Checks the output of :meth:`Decision.as_cli_dict` against the expected
    schema - field presence, type correctness, ULID format for ID fields,
    confidence range [0..1], and boolean fields.

    Args:
        d: A dict returned by ``decision.as_cli_dict()``.

    Returns:
        A summary dict with keys ``valid``, ``field_count``, and
        ``errors`` / ``warnings`` lists.

    Raises:
        ValidationError: If the dict fails structural validation.
    """

    errors: list[str] = []
    warnings: list[str] = []
    valid = True

    for field in DECISION_CLI_REQUIRED_FIELDS:
        if field not in d:
            errors.append(f"missing required field: {field}")
            valid = False
            continue

        value = d[field]

        # --- ULID fields ---
        if field in ("decision_id", "plan_id"):
            if not isinstance(value, str) or not _ULID_RE.match(value):
                errors.append(f"'{field}' must be a valid ULID, got {value!r}")
                valid = False

        # --- confidence range ---
        elif field == "confidence":
            if value is not None and isinstance(value, float):
                if value < 0.0 or value > 1.0:
                    errors.append(f"'{field}' must be in range [0.0, 1.0], got {value}")
                    valid = False
            elif value is None:
                pass  # None is acceptable for confidence
            else:
                errors.append(
                    f"'{field}' must be float or None, got {type(value).__name__}"
                )
                valid = False

        # --- parent field special handling (ULID or "(root)") ---
        elif field == "parent":
            if not isinstance(value, str):
                errors.append(f"'{field}' must be a string, got {type(value).__name__}")
                valid = False
            elif value != "(root)" and not _ULID_RE.match(value):
                errors.append(f"'{field}' must be a ULID or '(root)', got {value!r}")
                valid = False

        # --- boolean fields ---
        elif field in ("is_correction", "superseded"):
            if not isinstance(value, bool):
                errors.append(
                    f"'{field}' must be a boolean, got {type(value).__name__}"
                )
                valid = False

        # --- type fields (str) ---
        elif field in ("type", "question", "chosen"):
            if not isinstance(value, str) or not value.strip():
                errors.append(f"'{field}' must be a non-empty string")
                valid = False

        # --- sequence (int) ---
        elif not (isinstance(value, int) and not isinstance(value, bool)):
            errors.append(f"'{field}' must be an integer, got {type(value).__name__}")
            valid = False

    return {
        "valid": valid,
        "field_count": len(d),
        "errors": errors,
        "warnings": warnings,
    }


# ---------------------------------------------------------------------------
# StructuredOutput validator
# ---------------------------------------------------------------------------


def validate_structured_output(
    output: dict[str, Any],
) -> dict[str, Any]:
    """Validate a StructuredOutput envelope.

    Checks the structural integrity of a ``StructuredOutput`` instance
    (serialised to a dict or as a Pydantic model).  Required fields are
    ``command``, ``session_id``, and ``status``.  ``exit_code`` must be
    a non-negative integer.  ``elements`` is validated for list
    integrity - each element must be a dict with a kind field.

    Args:
        output: A StructuredOutput (dict).

    Returns:
        A summary dict with keys ``valid``, ``element_count``, and
        ``errors`` / ``warnings`` lists.

    Raises:
        ValidationError: If the envelope fails structural validation.
    """

    errors: list[str] = []
    warnings: list[str] = []
    valid = True

    # --- command (str, non-empty) ---
    command = output.get("command")
    if not isinstance(command, str) or not command.strip():
        errors.append("'command' must be a non-empty string")
        valid = False

    # --- session_id (ULID) ---
    session_id = output.get("session_id")
    if not isinstance(session_id, str) or not _ULID_RE.match(session_id):
        errors.append(f"'session_id' must be a valid ULID, got '{session_id!r}'")
        valid = False

    # --- status (membership) ---
    status = output.get("status")
    if not isinstance(status, str) or status not in VALID_STATUSES:
        errors.append(
            f"'status' must be one of {sorted(VALID_STATUSES)}, got '{status!r}'"
        )
        valid = False

    # --- exit_code (non-negative int) ---
    exit_code = output.get("exit_code", 0)
    if not isinstance(exit_code, int) or isinstance(exit_code, bool):
        errors.append(
            f"'exit_code' must be a non-negative integer, "
            f"got {type(exit_code).__name__}"
        )
        valid = False
    elif exit_code < 0:
        errors.append(f"'exit_code' must be >= 0, got {exit_code}")
        valid = False

    # --- elements (list of dicts with kind) ---
    elements = output.get("elements", [])
    if not isinstance(elements, list):
        errors.append(f"'elements' must be a list, got {type(elements).__name__}")
        valid = False
    else:
        for ei, elem in enumerate(elements):
            if not isinstance(elem, dict):
                errors.append(
                    f"elements[{ei}] is not a dict (got {type(elem).__name__})"
                )
                valid = False
            elif "kind" not in elem:
                errors.append(f"elements[{ei}] missing 'kind' field")
                valid = False

    return {
        "valid": valid,
        "element_count": len(elements) if isinstance(elements, list) else 0,
        "errors": errors,
        "warnings": warnings,
    }


# ---------------------------------------------------------------------------
# Unified dispatcher
# ---------------------------------------------------------------------------

_TARGET_TYPE_MAP: dict[str, Callable[[Any], dict[str, Any]]] = {
    # --- Plan tree targets ---
    "plan_tree": validate_plan_tree,
    "plan-tree": validate_plan_tree,
    "decisions": validate_plan_tree,
    "decision-tree": validate_plan_tree,
    "tree": validate_plan_tree,
    # --- Decision CLI targets ---
    "decision": validate_decision_dict,
    "decision_cli": validate_decision_dict,
    "decision-cli": validate_decision_dict,
    "cli_dict": validate_decision_dict,
    "as_cli_dict": validate_decision_dict,
    # --- StructuredOutput targets ---
    "structured_output": validate_structured_output,
    "structured-output": validate_structured_output,
    "session_output": validate_structured_output,
    "output_session": validate_structured_output,
}


def validate_structured_component_output(
    target_type: str,
    data: Any,
) -> dict[str, Any]:
    """Validate output by routing to the appropriate validator.

    Dispatches validation to a registered handler based on
    ``target_type``.  Unknown types raise :class:`ValidationError`.

    Args:
        target_type: A string identifying the output component type.
            Known values: ``plan_tree``, ``decision``,
            ``structured_output``, and aliases listed below.

        data: The raw data to validate.  Its interpretation depends on
            ``target_type``:

            * ``plan_tree`` - list[dict] of tree nodes
            * ``decision`` - dict matching Decision.as_cli_dict() output
            * ``structured_output`` - dict containing the envelope

    Returns:
        A summary dict with keys ``valid``, type-specific count, and
        ``errors`` / ``warnings`` lists.

    Raises:
        ValidationError: If ``target_type`` is unknown or validation fails.
    """

    validator = _TARGET_TYPE_MAP.get(target_type)
    if validator is None:
        available = sorted(_TARGET_TYPE_MAP.keys())
        raise ValidationError(
            f"Unknown target_type '{target_type}'. Available targets: {available}",
            errors=[f"valid types: {', '.join(available)}"],
        )

    result = validator(data)
    return result


# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------

__all__ = [
    "DECISION_CLI_REQUIRED_FIELDS",
    "PLAN_TREE_REQUIRED_KEYS",
    "ULID_PATTERN",
    "VALID_STATUSES",
    "ValidationError",
    "ValidationWarning",
    "validate_decision_dict",
    "validate_plan_tree",
    "validate_structured_component_output",
    "validate_structured_output",
]
