"""Parse COGILES strings into NetworkX graphs."""

from __future__ import annotations

import networkx as nx
import parsimonious

from cogiles.grammar import cogiles_grammar
from cogiles.colors import symbol_to_color_info
from cogiles.errors import CogilesParseError


def _squeeze_list(lst):
    """Recursively unwrap single-element lists."""
    if isinstance(lst, list):
        if len(lst) == 1:
            return _squeeze_list(lst[0])
        return [_squeeze_list(v) for v in lst]
    return lst


class CogilesVisitor(parsimonious.NodeVisitor):
    """Walk the parsimonious parse tree and collect graph structure."""

    def __init__(self):
        super().__init__()
        self.node_index: int = 0
        self.index_node_map: dict[int, parsimonious.nodes.Node] = {}

        self.anchor_map: dict[int, int] = {}
        self.anchor_edges: list[tuple[int, int]] = []

        self.node_sequence_map: dict[int, int] = {}
        self.prev_node: int = 0
        self.is_break: bool = False

    def visit_graph(self, node, visited_children):
        return visited_children

    def visit_branch(self, node, visited_children):
        branch = _squeeze_list(visited_children[1:-1])
        if not isinstance(branch, list):
            branch = [branch]

        # Truly empty branch R()R — no-op, preserve surrounding state.
        if not branch:
            return branch

        # Filter to only node indices. Breaks and anchors return None,
        # sub-branches return lists — neither should be treated as nodes.
        node_indices = [x for x in branch if isinstance(x, int)]

        if not node_indices:
            # Branch contains only breaks, anchors, or empty sub-branches
            # but no actual nodes — reject as degenerate input.
            # Examples: R(.)R, R(-1)R-1, R(()())
            raise ValueError("Branch must contain at least one node")

        first_node = node_indices[0]
        if first_node not in self.node_sequence_map:
            # First node has no parent — either a leading branch like (R)
            # or a break leaked into the branch scope like R.(G).
            raise ValueError("Branch has no parent node to connect to")

        self.prev_node = self.node_sequence_map[first_node]
        # Bug fix (P-BUG-2): reset break state after processing a branch
        # to prevent breaks inside the branch from leaking out and
        # disconnecting the continuation node. Example: R(G.)R — the
        # trailing break inside (G.) should not disconnect the final R.
        self.is_break = False
        return branch

    def visit_node(self, node, visited_children):
        idx = self.node_index
        self.index_node_map[idx] = node

        if idx != 0:
            if self.is_break:
                self.is_break = False
            else:
                self.node_sequence_map[idx] = self.prev_node

        self.prev_node = self.node_index
        self.node_index += 1
        return idx

    def visit_break(self, node, visited_children):
        self.is_break = True
        # Return None (not a node index) so visit_branch can filter it out.
        return None

    def visit_anchor(self, node, visited_children):
        anchor_number = int(node.text.replace("-", ""))
        node_index = self.prev_node
        if anchor_number not in self.anchor_map:
            self.anchor_map[anchor_number] = node_index
        else:
            self.anchor_edges.append((self.anchor_map[anchor_number], node_index))
        # Return None (not a node index) so visit_branch can filter it out.
        return None

    def generic_visit(self, node, visited_children):
        return visited_children or node

    def build_graph(self) -> nx.Graph:
        """Construct an nx.Graph from the collected visitor state."""
        G = nx.Graph()

        for idx in sorted(self.index_node_map):
            parse_node = self.index_node_map[idx]
            symbol = str(parse_node.text)
            info = symbol_to_color_info(symbol)
            G.add_node(
                idx,
                color=info.rgb,
                symbol=info.symbol,
                color_name=info.name,
            )

        for child, parent in self.node_sequence_map.items():
            G.add_edge(child, parent)

        for i, j in self.anchor_edges:
            # Bug fix (P-BUG-1): reject self-loops from same-node anchors.
            # Example: R-1-1 creates anchor_edges [(0, 0)] because both
            # anchor references resolve to the same prev_node. Self-loops
            # cannot be represented in COGILES (the encoder strips them),
            # so reject them at parse time.
            if i == j:
                raise ValueError(
                    f"Anchor creates a self-loop on node {i}"
                )
            G.add_edge(i, j)

        return G


def parse(cogiles_string: str) -> nx.Graph:
    """Parse a COGILES string into a NetworkX graph.

    Parameters
    ----------
    cogiles_string : str
        The COGILES-formatted string to parse.

    Returns
    -------
    nx.Graph
        A graph where each node has ``color`` (RGB tuple),
        ``symbol`` (letter code), and ``color_name`` (string) attributes.

    Raises
    ------
    CogilesParseError
        If the string is empty or contains invalid syntax.
    """
    # Bug fix (OBS-5): reject non-string input with a clear error
    # instead of letting it fail with TypeError/AttributeError later.
    if not isinstance(cogiles_string, str):
        raise CogilesParseError(
            f"Expected string, got {type(cogiles_string).__name__}",
            text=repr(cogiles_string),
        )

    if not cogiles_string:
        raise CogilesParseError("Empty COGILES string", text=cogiles_string)

    try:
        tree = cogiles_grammar.parse(cogiles_string)
    except parsimonious.exceptions.ParseError as exc:
        raise CogilesParseError(
            f"Invalid COGILES syntax: {exc}",
            text=cogiles_string,
            position=getattr(exc, "pos", None),
        ) from exc

    try:
        visitor = CogilesVisitor()
        visitor.visit(tree)
    except parsimonious.exceptions.VisitationError as exc:
        # VisitationError wraps exceptions from visitor methods.
        # Extract the original message for a cleaner error.
        cause = exc.__context__ or exc.__cause__
        msg = str(cause) if cause else str(exc).split("\n")[0]
        raise CogilesParseError(msg, text=cogiles_string) from exc

    try:
        return visitor.build_graph()
    except ValueError as exc:
        raise CogilesParseError(str(exc), text=cogiles_string) from exc
