from __future__ import annotations

from collections import deque
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from typing import Any


@dataclass(frozen=True, slots=True)
class DefinitionPort:
    id: str
    label: str
    required: bool = True
    multiple: bool = False
    minimum_connections: int = 1


@dataclass(frozen=True, slots=True)
class DefinitionConfigField:
    id: str
    label: str
    kind: str
    required: bool = False
    description: str | None = None
    options: tuple[tuple[str, str], ...] = ()


@dataclass(frozen=True, slots=True)
class DefinitionNodeType:
    type: str
    category: str
    label: str
    description: str
    icon: str
    input_ports: tuple[DefinitionPort, ...] = ()
    output_ports: tuple[DefinitionPort, ...] = (
        DefinitionPort(id="output", label="Output"),
    )
    config_fields: tuple[DefinitionConfigField, ...] = ()
    default_config: Mapping[str, Any] = field(default_factory=dict)
    metadata: Mapping[str, Any] = field(default_factory=dict)


@dataclass(frozen=True, slots=True)
class DefinitionNode:
    id: str
    type: str


@dataclass(frozen=True, slots=True)
class DefinitionEdge:
    id: str
    source: str
    target: str
    source_port: str = "output"
    target_port: str = "input"


@dataclass(frozen=True, slots=True)
class DefinitionNodeCountConstraint:
    code: str
    label: str
    minimum: int = 0
    maximum: int | None = None
    node_types: tuple[str, ...] = ()
    type_prefixes: tuple[str, ...] = ()
    categories: tuple[str, ...] = ()

    def matches(self, node: DefinitionNode, node_type: DefinitionNodeType | None) -> bool:
        return (
            node.type in self.node_types
            or any(node.type.startswith(prefix) for prefix in self.type_prefixes)
            or node_type is not None
            and node_type.category in self.categories
        )


@dataclass(frozen=True, slots=True)
class DefinitionGraphConstraints:
    max_nodes: int = 100
    max_edges: int = 200
    allow_cycles: bool = False
    require_connected: bool = True
    node_counts: tuple[DefinitionNodeCountConstraint, ...] = ()


@dataclass(frozen=True, slots=True)
class DefinitionGraphLibrary:
    id: str
    version: str
    category_labels: Mapping[str, str]
    node_types: tuple[DefinitionNodeType, ...]
    constraints: DefinitionGraphConstraints = field(default_factory=DefinitionGraphConstraints)

    def node_type(self, type_id: str) -> DefinitionNodeType | None:
        return next((item for item in self.node_types if item.type == type_id), None)


@dataclass(frozen=True, slots=True)
class DefinitionDiagnostic:
    severity: str
    code: str
    message: str
    node_id: str | None = None
    field: str | None = None


def validate_definition_graph(
    library: DefinitionGraphLibrary,
    *,
    nodes: Sequence[DefinitionNode],
    edges: Sequence[DefinitionEdge],
) -> tuple[DefinitionDiagnostic, ...]:
    if not nodes:
        return (_error("graph.empty", "Add nodes before saving the definition."),)
    constraints = library.constraints
    diagnostics = _graph_size_diagnostics(
        node_count=len(nodes),
        edge_count=len(edges),
        constraints=constraints,
    )
    node_by_id = {node.id: node for node in nodes}
    definitions: dict[str, DefinitionNodeType] = {}
    for item in library.node_types:
        definitions.setdefault(item.type, item)
    diagnostics.extend(_identifier_diagnostics(nodes=nodes, edges=edges))
    incoming, undirected, topology_edges, edge_diagnostics = (
        _validate_definition_edges(
            edges=edges,
            node_by_id=node_by_id,
            definitions=definitions,
        )
    )
    diagnostics.extend(edge_diagnostics)
    diagnostics.extend(
        _node_port_diagnostics(
            nodes=nodes,
            definitions=definitions,
            incoming=incoming,
            library_id=library.id,
        )
    )
    diagnostics.extend(
        _node_count_diagnostics(
            nodes=nodes,
            definitions=definitions,
            constraints=constraints,
        )
    )
    diagnostics.extend(
        _topology_diagnostics(
            nodes=nodes,
            node_by_id=node_by_id,
            topology_edges=topology_edges,
            undirected=undirected,
            constraints=constraints,
        )
    )
    return tuple(diagnostics)


def _graph_size_diagnostics(
    *,
    node_count: int,
    edge_count: int,
    constraints: DefinitionGraphConstraints,
) -> list[DefinitionDiagnostic]:
    diagnostics: list[DefinitionDiagnostic] = []
    if node_count > constraints.max_nodes:
        diagnostics.append(
            _error(
                "graph.node_limit",
                f"Definitions are limited to {constraints.max_nodes:,} nodes.",
            )
        )
    if edge_count > constraints.max_edges:
        diagnostics.append(
            _error(
                "graph.edge_limit",
                f"Definitions are limited to {constraints.max_edges:,} edges.",
            )
        )
    return diagnostics


def _identifier_diagnostics(
    *,
    nodes: Sequence[DefinitionNode],
    edges: Sequence[DefinitionEdge],
) -> list[DefinitionDiagnostic]:
    diagnostics: list[DefinitionDiagnostic] = []
    if len({node.id for node in nodes}) != len(nodes):
        diagnostics.append(_error("graph.duplicate_node", "Node identifiers must be unique."))
    if len({edge.id for edge in edges}) != len(edges):
        diagnostics.append(_error("graph.duplicate_edge", "Edge identifiers must be unique."))
    return diagnostics


def _validate_definition_edges(
    *,
    edges: Sequence[DefinitionEdge],
    node_by_id: Mapping[str, DefinitionNode],
    definitions: Mapping[str, DefinitionNodeType],
) -> tuple[
    dict[str, list[DefinitionEdge]],
    dict[str, set[str]],
    list[DefinitionEdge],
    list[DefinitionDiagnostic],
]:
    incoming: dict[str, list[DefinitionEdge]] = {node_id: [] for node_id in node_by_id}
    undirected: dict[str, set[str]] = {node_id: set() for node_id in node_by_id}
    topology_edges: list[DefinitionEdge] = []
    diagnostics: list[DefinitionDiagnostic] = []
    for edge in edges:
        structural_error = _edge_structure_diagnostic(edge, node_by_id)
        if structural_error is not None:
            diagnostics.append(structural_error)
            continue
        topology_edges.append(edge)
        port_error = _edge_port_diagnostic(
            edge,
            source_definition=definitions.get(node_by_id[edge.source].type),
            target_definition=definitions.get(node_by_id[edge.target].type),
        )
        if port_error is not None:
            diagnostics.append(port_error)
            continue
        incoming[edge.target].append(edge)
        undirected[edge.source].add(edge.target)
        undirected[edge.target].add(edge.source)
    return incoming, undirected, topology_edges, diagnostics


def _edge_structure_diagnostic(
    edge: DefinitionEdge,
    node_by_id: Mapping[str, DefinitionNode],
) -> DefinitionDiagnostic | None:
    if edge.source not in node_by_id:
        return _error(
            "edge.unknown_source",
            f"Edge {edge.id!r} references an unknown source node.",
        )
    if edge.target not in node_by_id:
        return _error(
            "edge.unknown_target",
            f"Edge {edge.id!r} references an unknown target node.",
        )
    if edge.source == edge.target:
        return _error(
            "edge.self_reference",
            "A node cannot connect to itself.",
            node_id=edge.source,
        )
    return None


def _edge_port_diagnostic(
    edge: DefinitionEdge,
    *,
    source_definition: DefinitionNodeType | None,
    target_definition: DefinitionNodeType | None,
) -> DefinitionDiagnostic | None:
    if source_definition is not None and edge.source_port not in {
        port.id for port in source_definition.output_ports
    }:
        return _error(
            "edge.unknown_source_port",
            f"Node {edge.source!r} has no output port {edge.source_port!r}.",
            node_id=edge.source,
        )
    if target_definition is not None and edge.target_port not in {
        port.id for port in target_definition.input_ports
    }:
        return _error(
            "edge.unknown_target_port",
            f"Node {edge.target!r} has no input port {edge.target_port!r}.",
            node_id=edge.target,
        )
    return None


def _node_port_diagnostics(
    *,
    nodes: Sequence[DefinitionNode],
    definitions: Mapping[str, DefinitionNodeType],
    incoming: Mapping[str, Sequence[DefinitionEdge]],
    library_id: str,
) -> list[DefinitionDiagnostic]:
    diagnostics: list[DefinitionDiagnostic] = []
    for node in nodes:
        definition = definitions.get(node.type)
        if definition is None:
            diagnostics.append(
                _error(
                    "node.unsupported_type",
                    f"Node type {node.type!r} is not in the {library_id!r} library.",
                    node_id=node.id,
                    field="type",
                )
            )
            continue
        for port in definition.input_ports:
            connections = [
                edge for edge in incoming.get(node.id, ()) if edge.target_port == port.id
            ]
            minimum = port.minimum_connections if port.required else 0
            if len(connections) < minimum:
                diagnostics.append(
                    _error(
                        "node.input_required",
                        (
                            f"{definition.label} requires {minimum} "
                            f"{port.label.lower()} connection"
                            f"{'' if minimum == 1 else 's'}."
                        ),
                        node_id=node.id,
                    )
                )
            if not port.multiple and len(connections) > 1:
                diagnostics.append(
                    _error(
                        "node.input_multiple",
                        f"{port.label} accepts only one connection.",
                        node_id=node.id,
                    )
                )
    return diagnostics


def _node_count_diagnostics(
    *,
    nodes: Sequence[DefinitionNode],
    definitions: Mapping[str, DefinitionNodeType],
    constraints: DefinitionGraphConstraints,
) -> list[DefinitionDiagnostic]:
    diagnostics: list[DefinitionDiagnostic] = []
    for count_constraint in constraints.node_counts:
        count = sum(
            count_constraint.matches(node, definitions.get(node.type))
            for node in nodes
        )
        if count < count_constraint.minimum:
            diagnostics.append(
                _error(
                    count_constraint.code,
                    (
                        f"A definition needs at least {count_constraint.minimum} "
                        f"{count_constraint.label} node"
                        f"{'' if count_constraint.minimum == 1 else 's'}."
                    ),
                )
            )
        if count_constraint.maximum is not None and count > count_constraint.maximum:
            diagnostics.append(
                _error(
                    count_constraint.code,
                    (
                        f"A definition allows at most {count_constraint.maximum} "
                        f"{count_constraint.label} node"
                        f"{'' if count_constraint.maximum == 1 else 's'}."
                    ),
                )
            )
    return diagnostics


def _topology_diagnostics(
    *,
    nodes: Sequence[DefinitionNode],
    node_by_id: Mapping[str, DefinitionNode],
    topology_edges: Sequence[DefinitionEdge],
    undirected: Mapping[str, set[str]],
    constraints: DefinitionGraphConstraints,
) -> list[DefinitionDiagnostic]:
    diagnostics: list[DefinitionDiagnostic] = []
    _, cyclic = definition_topological_order(nodes, topology_edges)
    if cyclic and not constraints.allow_cycles:
        diagnostics.append(_error("graph.cycle", "Definition edges must form an acyclic graph."))
    if constraints.require_connected and len(node_by_id) > 1:
        connected = _connected_nodes(next(iter(node_by_id)), undirected)
        if len(connected) != len(node_by_id):
            diagnostics.append(
                _error("graph.disconnected", "Every node must belong to one connected definition.")
            )
    return diagnostics


def definition_topological_order(
    nodes: Sequence[DefinitionNode],
    edges: Sequence[DefinitionEdge],
) -> tuple[tuple[str, ...], bool]:
    node_ids = [node.id for node in nodes]
    incoming_count = {node_id: 0 for node_id in node_ids}
    outgoing: dict[str, list[str]] = {node_id: [] for node_id in node_ids}
    for edge in edges:
        if (
            edge.source in outgoing
            and edge.target in incoming_count
            and edge.source != edge.target
        ):
            outgoing[edge.source].append(edge.target)
            incoming_count[edge.target] += 1
    ready = deque(node_id for node_id in node_ids if incoming_count[node_id] == 0)
    ordered: list[str] = []
    while ready:
        node_id = ready.popleft()
        ordered.append(node_id)
        for target in outgoing[node_id]:
            incoming_count[target] -= 1
            if incoming_count[target] == 0:
                ready.append(target)
    return tuple(ordered), len(ordered) != len(node_ids)


def _connected_nodes(start: str, adjacency: Mapping[str, set[str]]) -> set[str]:
    seen: set[str] = set()
    pending = [start]
    while pending:
        node_id = pending.pop()
        if node_id in seen:
            continue
        seen.add(node_id)
        pending.extend(adjacency.get(node_id, ()))
    return seen


def _error(
    code: str,
    message: str,
    *,
    node_id: str | None = None,
    field: str | None = None,
) -> DefinitionDiagnostic:
    return DefinitionDiagnostic(
        severity="error",
        code=code,
        message=message,
        node_id=node_id,
        field=field,
    )


__all__ = [
    "DefinitionConfigField",
    "DefinitionDiagnostic",
    "DefinitionEdge",
    "DefinitionGraphConstraints",
    "DefinitionGraphLibrary",
    "DefinitionNode",
    "DefinitionNodeCountConstraint",
    "DefinitionNodeType",
    "DefinitionPort",
    "definition_topological_order",
    "validate_definition_graph",
]
