"""Pathway Validation - Pre-run validation for pathways.

This module provides comprehensive validation of pathways before execution:
- Check for empty pathways
- Validate node configurations (tools exist, expressions parse)
- Check connection validity (nodes exist)
- Detect cycles
- Check conditional/router targets
- Identify orphan nodes

Usage:
    from core.pathways.validation import validate_pathway

    result = validate_pathway(pathway, ctx)
    if not result.valid:
        for error in result.errors:
            print(f"Error: {error.message}")
"""

from typing import Any, Literal

from pydantic import BaseModel, Field

# Hoisted imports (Rule 1: No lazy imports)
from core.pathways.context import Context
from core.pathways.nodes.registry import NodeTypeRegistry
from core.pathways.pathway import PSEUDO_NODE_IDS, Pathway, PathwayMode


class ValidationIssue(BaseModel):
    """A single validation error or warning."""

    code: str
    message: str
    severity: Literal["error", "warning"]
    node_id: str | None = None
    field: str | None = None


class ValidationResult(BaseModel):
    """Result of pathway validation."""

    valid: bool
    errors: list[ValidationIssue] = Field(default_factory=list)
    warnings: list[ValidationIssue] = Field(default_factory=list)


def validate_pathway(pathway: Pathway, ctx: "Context") -> ValidationResult:
    """Validate a pathway before execution.

    Args:
        pathway: The pathway to validate
        ctx: Execution context (for checking tool availability)

    Returns
    -------
        ValidationResult with errors and warnings
    """
    errors: list[ValidationIssue] = []
    warnings: list[ValidationIssue] = []

    # 1. Check for empty pathway
    if not pathway.nodes:
        errors.append(
            ValidationIssue(
                code="EMPTY_PATHWAY",
                message="Pathway has no nodes",
                severity="error",
            ),
        )
        # Return early - other checks don't make sense
        return ValidationResult(valid=False, errors=errors, warnings=warnings)

    # 2. Validate each node
    for node in pathway.nodes.values():
        node_errors, node_warnings = _validate_node(node, ctx)
        errors.extend(node_errors)
        warnings.extend(node_warnings)

    # 3. Check connections reference valid nodes
    for conn in pathway.connections:
        if conn.from_node not in pathway.nodes:
            errors.append(
                ValidationIssue(
                    code="INVALID_CONNECTION_SOURCE",
                    message=f"Connection references unknown source node: {conn.from_node}",
                    severity="error",
                    field="from_node",
                ),
            )
        if conn.to_node not in pathway.nodes:
            errors.append(
                ValidationIssue(
                    code="INVALID_CONNECTION_TARGET",
                    message=f"Connection references unknown target node: {conn.to_node}",
                    severity="error",
                    field="to_node",
                ),
            )
        # Check for self-loops
        if conn.from_node == conn.to_node:
            errors.append(
                ValidationIssue(
                    code="SELF_LOOP",
                    message=f"Node '{conn.from_node}' has a connection to itself",
                    severity="error",
                    node_id=conn.from_node,
                ),
            )

    # 4. Check for cycles (only in DAG mode - cycles are allowed in STATEFUL mode)
    # Using hoisted import: PathwayMode
    if pathway.mode != PathwayMode.STATEFUL:
        cycle = find_cycle(pathway)
        if cycle:
            errors.append(
                ValidationIssue(
                    code="CYCLE_DETECTED",
                    message=(
                        "Pathway contains cycles - execution order cannot be determined. "
                        f"Cycle: {' -> '.join(cycle)}"
                    ),
                    severity="error",
                    node_id=cycle[0] if cycle else None,
                ),
            )

    # 5. Check for orphan nodes (no connections in or out)
    if len(pathway.nodes) > 1:
        connected_nodes = set()
        for conn in pathway.connections:
            connected_nodes.add(conn.from_node)
            connected_nodes.add(conn.to_node)

        warnings.extend(
            ValidationIssue(
                code="ORPHAN_NODE",
                message=f"Node '{node_id}' has no connections (will receive only initial inputs)",
                severity="warning",
                node_id=node_id,
            )
            for node_id in pathway.nodes
            if node_id not in connected_nodes
        )

    # 6. Check conditional/router node targets exist
    for node_id, node in pathway.nodes.items():
        node_type = getattr(node, "type", None)

        if node_type == "conditional":
            true_node = getattr(node, "true_node", None)
            false_node = getattr(node, "false_node", None)

            if true_node and true_node not in pathway.nodes:
                errors.append(
                    ValidationIssue(
                        code="INVALID_CONDITIONAL_TARGET",
                        message=f"Conditional true_node references unknown node: {true_node}",
                        severity="error",
                        node_id=node_id,
                        field="true_node",
                    ),
                )
            if false_node and false_node not in pathway.nodes:
                errors.append(
                    ValidationIssue(
                        code="INVALID_CONDITIONAL_TARGET",
                        message=f"Conditional false_node references unknown node: {false_node}",
                        severity="error",
                        node_id=node_id,
                        field="false_node",
                    ),
                )

        if node_type == "loop":
            body = getattr(node, "body", [])
            errors.extend(
                ValidationIssue(
                    code="INVALID_LOOP_BODY",
                    message=f"Loop body references unknown node: {body_node}",
                    severity="error",
                    node_id=node_id,
                    field="body",
                )
                for body_node in body
                if body_node not in pathway.nodes
            )

    return ValidationResult(
        valid=len(errors) == 0,
        errors=errors,
        warnings=warnings,
    )


def _validate_node(
    node: Any,
    ctx: "Context",
) -> tuple[list[ValidationIssue], list[ValidationIssue]]:
    """Validate a single node's configuration.

    Returns
    -------
        Tuple of (errors, warnings)
    """
    errors: list[ValidationIssue] = []
    warnings: list[ValidationIssue] = []

    node_id = getattr(node, "id", "unknown")
    node_type = getattr(node, "type", None)

    def add_error(code: str, message: str, field: str) -> None:
        errors.append(
            ValidationIssue(
                code=code,
                message=message,
                severity="error",
                node_id=node_id,
                field=field,
            ),
        )

    def add_warning(code: str, message: str, field: str) -> None:
        warnings.append(
            ValidationIssue(
                code=code,
                message=message,
                severity="warning",
                node_id=node_id,
                field=field,
            ),
        )

    def validate_expression(expr: str, field: str, error_code: str) -> None:
        """Validate a safe expression, adding error if invalid."""
        if not expr:
            return
        if "{{" in expr:  # Template - skip validation
            return
        from core.pathways.expressions import SafeExpressionError, safe_expr_parse

        try:
            safe_expr_parse(expr)
        except SafeExpressionError as e:
            add_error(error_code, f"Invalid expression: {e}", field)

    # Type-specific validation via dispatch table.
    validator = _NODE_VALIDATORS.get(node_type)
    if validator:
        validator(node, ctx, add_error, add_warning, validate_expression)

    return errors, warnings


# ---------------------------------------------------------------------------
# Per-type validators — each takes only the params it needs
# ---------------------------------------------------------------------------


def _validate_tool(node, ctx, add_error, _add_warning, _validate_expr) -> None:
    tool_name = getattr(node, "tool", "")
    if not tool_name:
        add_error("MISSING_TOOL_NAME", "Tool node has no tool specified", "tool")
    elif tool_name not in (ctx.tools or {}):
        add_error("TOOL_NOT_FOUND", f"Tool not found: {tool_name}", "tool")


def _validate_transform(node, _ctx, add_error, _add_warning, validate_expression) -> None:
    expr = getattr(node, "expr", "")
    if not expr:
        add_error("EMPTY_EXPRESSION", "Transform node has no expression", "expr")
    else:
        validate_expression(expr, "expr", "INVALID_EXPRESSION")


def _validate_condition_node(node, _ctx, add_error, _add_warning, validate_expression) -> None:
    node_type = getattr(node, "type", "conditional")
    condition = getattr(node, "condition", "")
    if not condition:
        add_error("EMPTY_CONDITION", f"{node_type.title()} node has no condition", "condition")
    else:
        validate_expression(condition, "condition", "INVALID_CONDITION")


def _validate_llm(node, _ctx, add_error, add_warning, _validate_expr) -> None:
    prompt = getattr(node, "prompt", "")
    if not prompt or not prompt.strip():
        add_error("EMPTY_PROMPT", "LLM node has empty prompt", "prompt")
    if not getattr(node, "model", ""):
        add_warning(
            "NO_MODEL_SPECIFIED",
            "LLM node has no model specified (will use default)",
            "model",
        )
    if getattr(node, "response_format", "text") == "json" and not getattr(
        node,
        "json_schema",
        None,
    ):
        add_warning(
            "JSON_WITHOUT_SCHEMA",
            "LLM node requests JSON but has no schema (output may be unpredictable)",
            "json_schema",
        )


def _validate_code(node, _ctx, add_error, _add_warning, _validate_expr) -> None:
    code = getattr(node, "code", "")
    if not code or not code.strip():
        add_error("EMPTY_CODE", "Code node has no code", "code")
    else:
        try:
            compile(code, "<pathway_code>", "exec")
        except SyntaxError as e:
            add_error(
                "INVALID_CODE_SYNTAX",
                f"Code syntax error: {e.msg} at line {e.lineno}",
                "code",
            )


def _validate_map(node, _ctx, add_error, _add_warning, _validate_expr) -> None:
    if not getattr(node, "over", ""):
        add_error("MAP_NO_COLLECTION", "Map node has no 'over' collection specified", "over")
    if not getattr(node, "node_data", {}):
        add_error("MAP_NO_NODE", "Map node has no node_data to apply", "node_data")


def _validate_fallback(node, _ctx, add_error, _add_warning, _validate_expr) -> None:
    if not getattr(node, "nodes_data", []):
        add_error("FALLBACK_NO_NODES", "Fallback node has no fallback options", "nodes_data")


def _validate_wrapper_node(node, _ctx, add_error, _add_warning, _validate_expr) -> None:
    node_type = getattr(node, "type", "retry")
    if not getattr(node, "node_data", {}):
        add_error(
            f"{node_type.upper()}_NO_NODE",
            f"{node_type.title()} node has no inner node",
            "node_data",
        )


_NODE_VALIDATORS: dict[str, Any] = {
    "tool": _validate_tool,
    "transform": _validate_transform,
    "conditional": _validate_condition_node,
    "llm": _validate_llm,
    "code": _validate_code,
    "code_execute": _validate_code,
    "map": _validate_map,
    "fallback": _validate_fallback,
    "retry": _validate_wrapper_node,
    "timeout": _validate_wrapper_node,
}


_WHITE, _GRAY, _BLACK = 0, 1, 2


def find_cycle(pathway: Pathway) -> list[str] | None:
    """Find a cycle in the pathway graph using DFS.

    Returns
    -------
        A list of node_ids representing the cycle, including the repeated
        start/end node to "close the loop" (e.g. ["a", "b", "c", "a"]),
        or None if no cycle exists.
    """
    color: dict[str, int] = dict.fromkeys(pathway.nodes, _WHITE)
    parent: dict[str, str] = {}

    # Build adjacency list from connections (skip pseudo-nodes like input/output)
    adj: dict[str, list[str]] = {nid: [] for nid in pathway.nodes}
    for conn in pathway.connections:
        # Skip pseudo-nodes (input/output)
        if conn.from_node in PSEUDO_NODE_IDS or conn.to_node in PSEUDO_NODE_IDS:
            continue
        if conn.from_node in adj and conn.to_node in adj:
            adj[conn.from_node].append(conn.to_node)

    def _reconstruct_cycle(u: str, v: str) -> list[str] | None:
        # Found back-edge u -> v where v is GRAY in current recursion stack.
        # Reconstruct path v -> ... -> u -> v via parent pointers.
        path: list[str] = [v]
        cur = u
        while cur != v:
            path.append(cur)
            if cur not in parent:
                return None
            cur = parent[cur]
        path.append(v)
        path.reverse()
        return path

    def dfs(node: str) -> list[str] | None:
        color[node] = _GRAY
        for neighbor in adj.get(node, []):
            if neighbor not in color:
                continue
            if color[neighbor] == _GRAY:
                return _reconstruct_cycle(node, neighbor)
            if color[neighbor] == _WHITE:
                parent[neighbor] = node
                cycle = dfs(neighbor)
                if cycle:
                    return cycle
        color[node] = _BLACK
        return None

    for node in pathway.nodes:
        if color[node] == _WHITE:
            cycle = dfs(node)
            if cycle:
                return cycle
    return None


def _has_cycle(pathway: Pathway) -> bool:
    """Detect cycles in the pathway graph."""
    return find_cycle(pathway) is not None


def validate_node_config(node_type: str, config: dict[str, Any], ctx: Context) -> ValidationResult:
    """Validate a node configuration without a full pathway.

    Useful for validating node config in Studio UI before adding to pathway.

    Args:
        node_type: Node type string (e.g., "llm", "tool")
        config: Node configuration dict
        ctx: Execution context

    Returns
    -------
        ValidationResult
    """
    # Using hoisted import: NodeTypeRegistry
    errors: list[ValidationIssue] = []
    warnings: list[ValidationIssue] = []

    # Get node type spec
    spec = NodeTypeRegistry.get(node_type)
    if spec is None:
        errors.append(
            ValidationIssue(
                code="UNKNOWN_NODE_TYPE",
                message=f"Unknown node type: {node_type}",
                severity="error",
            ),
        )
        return ValidationResult(valid=False, errors=errors, warnings=warnings)

    # Try to instantiate the node to validate config
    try:
        node = spec.node_class(
            id="validation_check",
            **config,
        )

        # Run node-specific validation
        node_errors, node_warnings = _validate_node(node, ctx)
        errors.extend(node_errors)
        warnings.extend(node_warnings)

    except Exception as e:
        errors.append(
            ValidationIssue(
                code="INVALID_CONFIG",
                message=f"Invalid configuration: {e}",
                severity="error",
            ),
        )

    return ValidationResult(
        valid=len(errors) == 0,
        errors=errors,
        warnings=warnings,
    )


__all__ = [
    "ValidationIssue",
    "ValidationResult",
    "validate_node_config",
    "validate_pathway",
]
