import pytest
import networkx as nx
import cogiles


class TestParseSingleNodes:
    @pytest.mark.parametrize(
        "symbol,expected_color",
        [
            ("R", (1.0, 0.0, 0.0)),
            ("G", (0.0, 0.5, 0.0)),
            ("B", (0.0, 0.0, 1.0)),
            ("Y", (1.0, 1.0, 0.0)),
            ("C", (0.0, 1.0, 1.0)),
            ("M", (1.0, 0.0, 1.0)),
            ("W", (1.0, 1.0, 1.0)),
            ("K", (0.0, 0.0, 0.0)),
            ("O", (1.0, 0.5, 0.0)),
            ("P", (0.5, 0.0, 0.5)),
            ("T", (0.0, 0.5, 0.5)),
            ("L", (0.5, 1.0, 0.0)),
            ("I", (0.3, 0.0, 0.5)),
        ],
    )
    def test_single_letter_node(self, symbol, expected_color):
        G = cogiles.parse(symbol)
        assert G.number_of_nodes() == 1
        assert G.number_of_edges() == 0
        assert G.nodes[0]["color"] == expected_color
        assert G.nodes[0]["symbol"] == symbol

    @pytest.mark.parametrize(
        "symbol,expected_color,expected_name",
        [
            ("Br", (0.6, 0.3, 0.0), "brown"),
            ("Gr", (0.5, 0.5, 0.5), "gray"),
            ("Pi", (1.0, 0.4, 0.7), "pink"),
            ("Ol", (0.5, 0.5, 0.0), "olive"),
        ],
    )
    def test_two_letter_node(self, symbol, expected_color, expected_name):
        G = cogiles.parse(symbol)
        assert G.number_of_nodes() == 1
        assert G.nodes[0]["color"] == expected_color
        assert G.nodes[0]["color_name"] == expected_name
        assert G.nodes[0]["symbol"] == symbol

    def test_bl_alias_parsed_as_black(self):
        G = cogiles.parse("Bl")
        assert G.number_of_nodes() == 1
        assert G.nodes[0]["color"] == (0.0, 0.0, 0.0)
        assert G.nodes[0]["symbol"] == "K"  # canonical
        assert G.nodes[0]["color_name"] == "black"


class TestParseChains:
    def test_simple_chain(self):
        G = cogiles.parse("RRR")
        assert G.number_of_nodes() == 3
        assert G.number_of_edges() == 2
        assert G.has_edge(0, 1)
        assert G.has_edge(1, 2)

    def test_mixed_color_chain(self):
        G = cogiles.parse("RGBY")
        assert G.number_of_nodes() == 4
        assert G.nodes[0]["symbol"] == "R"
        assert G.nodes[1]["symbol"] == "G"
        assert G.nodes[2]["symbol"] == "B"
        assert G.nodes[3]["symbol"] == "Y"

    def test_two_letter_in_chain(self):
        G = cogiles.parse("RBrGr")
        assert G.number_of_nodes() == 3
        assert G.nodes[0]["symbol"] == "R"
        assert G.nodes[1]["symbol"] == "Br"
        assert G.nodes[2]["symbol"] == "Gr"
        assert G.has_edge(0, 1)
        assert G.has_edge(1, 2)

    def test_chain_of_two_letter_nodes(self):
        G = cogiles.parse("BrBrBr")
        assert G.number_of_nodes() == 3
        for i in range(3):
            assert G.nodes[i]["color_name"] == "brown"

    def test_ambiguous_B_vs_Br(self):
        """'BBr' should parse as B followed by Br, not BB + r."""
        G = cogiles.parse("BBr")
        assert G.number_of_nodes() == 2
        assert G.nodes[0]["symbol"] == "B"
        assert G.nodes[1]["symbol"] == "Br"

    def test_all_two_letter_mixed(self):
        """String with all two-letter codes."""
        G = cogiles.parse("BrGrPiOl")
        assert G.number_of_nodes() == 4
        assert G.nodes[0]["color_name"] == "brown"
        assert G.nodes[1]["color_name"] == "gray"
        assert G.nodes[2]["color_name"] == "pink"
        assert G.nodes[3]["color_name"] == "olive"


class TestParseBranches:
    def test_simple_branch(self):
        G = cogiles.parse("RRR(BB)")
        assert G.number_of_nodes() == 5
        assert G.number_of_edges() == 4
        assert G.has_edge(0, 1)
        assert G.has_edge(1, 2)
        assert G.has_edge(2, 3)
        assert G.has_edge(3, 4)

    def test_branch_with_continuation(self):
        G = cogiles.parse("RR(BB)RR")
        assert G.number_of_nodes() == 6
        assert G.has_edge(1, 2)  # branch start
        assert G.has_edge(1, 4)  # continuation

    def test_multiple_branches_star(self):
        """Y(G)(G)(G) = star with Y center and 3 G leaves."""
        G = cogiles.parse("Y(G)(G)(G)")
        assert G.number_of_nodes() == 4
        assert G.number_of_edges() == 3
        for i in [1, 2, 3]:
            assert G.has_edge(0, i)

    def test_nested_branches(self):
        G = cogiles.parse("R(G(B))")
        assert G.number_of_nodes() == 3
        assert G.has_edge(0, 1)
        assert G.has_edge(1, 2)

    def test_branch_does_not_connect_to_branch_end(self):
        """After a branch, the next node connects to the parent, not branch end."""
        G = cogiles.parse("R(GG)B")
        assert G.number_of_nodes() == 4
        assert G.has_edge(0, 1)
        assert G.has_edge(1, 2)
        assert G.has_edge(0, 3)
        assert not G.has_edge(2, 3)

    def test_deeply_nested(self):
        G = cogiles.parse("R(G(B(Y)))")
        assert G.number_of_nodes() == 4
        assert G.has_edge(0, 1)
        assert G.has_edge(1, 2)
        assert G.has_edge(2, 3)

    def test_branch_from_original_repo(self):
        """RRR(G)(YGG)RR from the original test suite."""
        G = cogiles.parse("RRR(G)(YGG)RR")
        assert G.number_of_nodes() == 9
        assert G.number_of_edges() == 8
        assert G.has_edge(0, 1)
        assert G.has_edge(1, 2)
        assert G.has_edge(2, 3)  # branch (G)
        assert G.has_edge(2, 4)  # branch (YGG)
        assert G.has_edge(4, 5)
        assert G.has_edge(5, 6)
        assert G.has_edge(2, 7)  # continuation
        assert G.has_edge(7, 8)


class TestParseAnchors:
    def test_simple_cycle(self):
        G = cogiles.parse("R-1RRRRR-1")
        assert G.number_of_nodes() == 6
        assert G.number_of_edges() == 6
        assert nx.is_connected(G)
        assert all(d == 2 for _, d in G.degree())

    def test_five_node_cycle(self):
        G = cogiles.parse("R-1RRRR-1")
        assert G.number_of_nodes() == 5
        assert G.number_of_edges() == 5

    def test_multiple_anchors_on_one_node(self):
        G = cogiles.parse("R-1-2GGG-2BBB-1")
        assert G.number_of_nodes() == 7
        assert G.has_edge(0, 1)
        assert G.has_edge(0, 3)  # anchor -2
        assert G.has_edge(0, 6)  # anchor -1

    def test_anchor_with_more_than_two_uses(self):
        G = cogiles.parse("R-1(GG-1)(BB-1)")
        assert G.has_edge(0, 2)
        assert G.has_edge(0, 4)

    def test_single_anchor_no_effect(self):
        G = cogiles.parse("R-1RR")
        assert G.number_of_nodes() == 3
        assert G.number_of_edges() == 2

    def test_complex_anchors(self):
        """R-1R(GY-1)BRRR(R)RB from original test suite."""
        G = cogiles.parse("R-1R(GY-1)BRRR(R)RB")
        assert G.number_of_nodes() == 11
        assert G.number_of_edges() == 11
        assert G.has_edge(0, 1)
        assert G.has_edge(1, 2)  # branch (GY-1)
        assert G.has_edge(2, 3)
        assert G.has_edge(0, 3)  # anchor -1
        assert G.has_edge(1, 4)  # continuation B
        assert G.has_edge(4, 5)
        assert G.has_edge(5, 6)
        assert G.has_edge(6, 7)
        assert G.has_edge(7, 8)  # branch (R)
        assert G.has_edge(7, 9)  # continuation R
        assert G.has_edge(9, 10)


    def test_multi_digit_anchor(self):
        """Anchors with multi-digit numbers like -10 should work."""
        G = cogiles.parse("R-10RRRR-10")
        assert G.number_of_nodes() == 5
        assert G.number_of_edges() == 5
        assert all(d == 2 for _, d in G.degree())


class TestParseBreaks:
    def test_simple_break(self):
        G = cogiles.parse("RR.BB")
        assert G.number_of_nodes() == 4
        assert G.number_of_edges() == 2
        assert not nx.is_connected(G)
        assert G.has_edge(0, 1)
        assert G.has_edge(2, 3)
        assert not G.has_edge(1, 2)

    def test_break_with_complex_components(self):
        G = cogiles.parse("R-1RR-1.RR(GG)R")
        assert not nx.is_connected(G)
        components = list(nx.connected_components(G))
        assert len(components) == 2

    def test_multiple_breaks(self):
        G = cogiles.parse("R.G.B")
        assert G.number_of_nodes() == 3
        assert G.number_of_edges() == 0

    def test_single_node_per_component(self):
        G = cogiles.parse("R.B")
        assert G.number_of_nodes() == 2
        assert G.number_of_edges() == 0


class TestParseEdgeCases:
    def test_empty_branch_is_noop(self):
        """R()R should parse as RR — empty branch does nothing."""
        G = cogiles.parse("R()R")
        assert G.number_of_nodes() == 2
        assert G.number_of_edges() == 1
        assert G.has_edge(0, 1)

    def test_adjacent_breaks(self):
        """R..R is equivalent to R.R — adjacent breaks produce disconnected nodes."""
        G = cogiles.parse("R..R")
        assert G.number_of_nodes() == 2
        assert G.number_of_edges() == 0

    def test_bare_break_produces_empty_graph(self):
        """A single break '.' produces a graph with no nodes."""
        G = cogiles.parse(".")
        assert G.number_of_nodes() == 0
        assert G.number_of_edges() == 0

    def test_only_breaks(self):
        """Multiple breaks '...' produce an empty graph."""
        G = cogiles.parse("...")
        assert G.number_of_nodes() == 0
        assert G.number_of_edges() == 0

    def test_standalone_anchor_no_nodes(self):
        """A standalone anchor '-1' produces an empty graph."""
        G = cogiles.parse("-1")
        assert G.number_of_nodes() == 0
        assert G.number_of_edges() == 0

    def test_branch_after_break(self):
        """R.R(G) — branch on second component."""
        G = cogiles.parse("R.R(G)")
        assert G.number_of_nodes() == 3
        assert G.number_of_edges() == 1
        assert not G.has_edge(0, 1)
        assert G.has_edge(1, 2)

    def test_break_inside_branch(self):
        """R(G.B) — break inside a branch creates disconnected nodes."""
        G = cogiles.parse("R(G.B)")
        assert G.number_of_nodes() == 3
        assert G.has_edge(0, 1)
        assert not G.has_edge(1, 2)


class TestParseBranchBreakScoping:
    """Bug fix tests: is_break state must not leak out of branches (P-BUG-2)."""

    def test_trailing_break_in_branch_does_not_leak(self):
        """R(G.)R — break inside branch should not disconnect continuation."""
        G = cogiles.parse("R(G.)R")
        assert G.number_of_nodes() == 3
        assert G.number_of_edges() == 2
        assert G.has_edge(0, 1)
        assert G.has_edge(0, 2)

    def test_trailing_break_in_longer_branch(self):
        """R(GG.)RR — break at end of multi-node branch."""
        G = cogiles.parse("R(GG.)RR")
        assert G.number_of_nodes() == 5
        assert G.number_of_edges() == 4
        assert G.has_edge(0, 1)
        assert G.has_edge(1, 2)
        assert G.has_edge(0, 3)  # continuation connects to branch parent
        assert G.has_edge(3, 4)

    def test_break_between_nodes_inside_branch(self):
        """R(G.B)R — break inside branch disconnects B from G."""
        G = cogiles.parse("R(G.B)R")
        assert G.number_of_nodes() == 4
        assert G.has_edge(0, 1)  # R-G
        assert not G.has_edge(1, 2)  # break
        assert G.has_edge(0, 3)  # continuation


class TestParseSelfLoopAnchor:
    """Bug fix tests: same-node anchors must not create self-loops (P-BUG-1)."""

    def test_same_node_anchor_rejected(self):
        """R-1-1 — two anchors on the same node create a self-loop."""
        with pytest.raises(cogiles.CogilesParseError):
            cogiles.parse("R-1-1")

    def test_different_node_anchor_accepted(self):
        """R-1R-1 — anchors on different nodes create a valid edge."""
        G = cogiles.parse("R-1R-1")
        assert G.number_of_nodes() == 2
        assert G.number_of_edges() == 1  # chain and anchor both create edge (0,1)
        assert G.has_edge(0, 1)


class TestParseDegenrateBranches:
    """Bug fix tests: degenerate branches raise clean errors (P-CRASH-1..6)."""

    def test_break_before_branch_rejected(self):
        """R.(G) — branch directly after break has no parent."""
        with pytest.raises(cogiles.CogilesParseError):
            cogiles.parse("R.(G)")

    def test_break_at_start_of_branch_rejected(self):
        """R(.G) — break as first element of branch."""
        with pytest.raises(cogiles.CogilesParseError):
            cogiles.parse("R(.G)")

    def test_nested_empty_branches_rejected(self):
        """R(()()) — nested empty branches with no actual nodes."""
        with pytest.raises(cogiles.CogilesParseError):
            cogiles.parse("R(()())")

    def test_break_only_branch_rejected(self):
        """R(.)R — branch containing only a break."""
        with pytest.raises(cogiles.CogilesParseError):
            cogiles.parse("R(.)R")

    def test_anchor_only_branch_rejected(self):
        """R(-1)R-1 — branch containing only an anchor."""
        with pytest.raises(cogiles.CogilesParseError):
            cogiles.parse("R(-1)R-1")

    def test_leading_branch_rejected(self):
        """(R) — branch with no preceding node."""
        with pytest.raises(cogiles.CogilesParseError):
            cogiles.parse("(R)")


class TestParseInputValidation:
    """Bug fix tests: non-string input gives clear errors (OBS-5)."""

    def test_integer_input_rejected(self):
        with pytest.raises(cogiles.CogilesParseError):
            cogiles.parse(123)

    def test_none_input_rejected(self):
        with pytest.raises(cogiles.CogilesParseError):
            cogiles.parse(None)

    def test_list_input_rejected(self):
        with pytest.raises(cogiles.CogilesParseError):
            cogiles.parse(["R"])


class TestParseNodeAttributes:
    def test_all_three_attributes_present(self):
        G = cogiles.parse("R")
        node = G.nodes[0]
        assert "color" in node
        assert "symbol" in node
        assert "color_name" in node

    def test_color_is_tuple(self):
        G = cogiles.parse("R")
        assert isinstance(G.nodes[0]["color"], tuple)
        assert len(G.nodes[0]["color"]) == 3
