import pytest
import networkx as nx
import cogiles
from cogiles.pattern import parse_pattern, find_pattern, Match


class TestParsePatternRegularNodes:
    """Pattern strings with only regular COGILES nodes should work identically."""

    def test_single_node(self):
        G = parse_pattern("R")
        assert G.number_of_nodes() == 1
        assert G.nodes[0]["match"]("R")
        assert not G.nodes[0]["match"]("G")

    def test_chain(self):
        G = parse_pattern("RGB")
        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_branch(self):
        G = parse_pattern("R(G)(B)")
        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_anchor_cycle(self):
        G = parse_pattern("R-1GBY-1")
        assert G.number_of_nodes() == 4
        assert G.number_of_edges() == 4
        assert G.has_edge(0, 3)  # anchor closes the cycle

    def test_two_letter_symbols(self):
        G = parse_pattern("Br")
        assert G.number_of_nodes() == 1
        assert G.nodes[0]["match"]("Br")

    def test_bl_alias(self):
        G = parse_pattern("Bl")
        assert G.number_of_nodes() == 1
        # Bl canonicalizes to K
        assert G.nodes[0]["match"]("K")


class TestParsePatternWildcard:

    def test_single_wildcard(self):
        G = parse_pattern("*")
        assert G.number_of_nodes() == 1
        assert G.nodes[0]["match"]("R")
        assert G.nodes[0]["match"]("G")
        assert G.nodes[0]["match"]("K")
        assert G.nodes[0]["match"]("Br")

    def test_wildcard_chain(self):
        G = parse_pattern("***")
        assert G.number_of_nodes() == 3
        assert G.number_of_edges() == 2

    def test_wildcard_with_branch(self):
        G = parse_pattern("*(*)")
        assert G.number_of_nodes() == 2
        assert G.number_of_edges() == 1

    def test_mixed_wildcard_and_concrete(self):
        G = parse_pattern("R(*)(B)")
        assert G.number_of_nodes() == 3
        assert G.nodes[0]["match"]("R")
        assert not G.nodes[0]["match"]("G")
        assert G.nodes[1]["match"]("R")  # wildcard matches anything
        assert G.nodes[1]["match"]("G")
        assert G.nodes[2]["match"]("B")
        assert not G.nodes[2]["match"]("R")


class TestParsePatternColorSet:

    def test_two_color_set(self):
        G = parse_pattern("[RG]")
        assert G.number_of_nodes() == 1
        assert G.nodes[0]["match"]("R")
        assert G.nodes[0]["match"]("G")
        assert not G.nodes[0]["match"]("B")

    def test_single_color_set(self):
        G = parse_pattern("[R]")
        assert G.number_of_nodes() == 1
        assert G.nodes[0]["match"]("R")
        assert not G.nodes[0]["match"]("G")

    def test_many_colors_in_set(self):
        G = parse_pattern("[RGBYC]")
        assert G.number_of_nodes() == 1
        for sym in ["R", "G", "B", "Y", "C"]:
            assert G.nodes[0]["match"](sym)
        assert not G.nodes[0]["match"]("K")

    def test_two_letter_in_set(self):
        G = parse_pattern("[BrGr]")
        assert G.number_of_nodes() == 1
        assert G.nodes[0]["match"]("Br")
        assert G.nodes[0]["match"]("Gr")
        assert not G.nodes[0]["match"]("R")

    def test_mixed_one_and_two_letter_in_set(self):
        G = parse_pattern("[RBr]")
        assert G.number_of_nodes() == 1
        assert G.nodes[0]["match"]("R")
        assert G.nodes[0]["match"]("Br")
        assert not G.nodes[0]["match"]("G")

    def test_color_set_in_branch(self):
        G = parse_pattern("R([GB])")
        assert G.number_of_nodes() == 2
        assert G.has_edge(0, 1)
        assert G.nodes[1]["match"]("G")
        assert G.nodes[1]["match"]("B")
        assert not G.nodes[1]["match"]("R")


class TestParsePatternNegatedSet:

    def test_negated_single(self):
        G = parse_pattern("[!R]")
        assert G.number_of_nodes() == 1
        assert not G.nodes[0]["match"]("R")
        assert G.nodes[0]["match"]("G")
        assert G.nodes[0]["match"]("B")
        assert G.nodes[0]["match"]("K")

    def test_negated_multiple(self):
        G = parse_pattern("[!RG]")
        assert G.number_of_nodes() == 1
        assert not G.nodes[0]["match"]("R")
        assert not G.nodes[0]["match"]("G")
        assert G.nodes[0]["match"]("B")

    def test_negated_two_letter(self):
        G = parse_pattern("[!Br]")
        assert G.number_of_nodes() == 1
        assert not G.nodes[0]["match"]("Br")
        assert G.nodes[0]["match"]("R")

    def test_negated_in_chain(self):
        G = parse_pattern("[!R][!G]")
        assert G.number_of_nodes() == 2
        assert G.has_edge(0, 1)


class TestParsePatternErrors:

    def test_empty_string(self):
        with pytest.raises(cogiles.CogilesParseError):
            parse_pattern("")

    def test_non_string(self):
        with pytest.raises(cogiles.CogilesParseError):
            parse_pattern(123)

    def test_invalid_syntax(self):
        with pytest.raises(cogiles.CogilesParseError):
            parse_pattern("[")

    def test_leading_branch(self):
        with pytest.raises(cogiles.CogilesParseError):
            parse_pattern("(R)")

    def test_self_loop_anchor(self):
        with pytest.raises(cogiles.CogilesParseError):
            parse_pattern("*-1-1")


class TestFindPatternExactColor:

    def test_find_single_node(self):
        matches = find_pattern("R(G)(B)", "R")
        assert len(matches) == 1
        assert matches[0].nodes == frozenset({0})

    def test_find_edge(self):
        matches = find_pattern("RGB", "RG")
        assert len(matches) == 1
        assert matches[0].nodes == frozenset({0, 1})

    def test_find_no_match(self):
        matches = find_pattern("RGB", "Y")
        assert len(matches) == 0

    def test_find_star_pattern(self):
        matches = find_pattern("R(G)(B)(Y)", "R(G)")
        assert len(matches) == 1
        assert 0 in matches[0].nodes  # R is node 0

    def test_find_multiple_matches(self):
        # RGR has two R nodes — pattern "R" should match twice
        matches = find_pattern("RGR", "R")
        assert len(matches) == 2
        node_sets = {m.nodes for m in matches}
        assert frozenset({0}) in node_sets
        assert frozenset({2}) in node_sets

    def test_find_chain_in_cycle(self):
        # 4-node cycle, all R. Pattern R-R (chain of 2 R's).
        # Each edge is a match: 4 edges in cycle → 4 matches.
        matches = find_pattern("R-1RRR-1", "RR")
        assert len(matches) == 4

    def test_pattern_larger_than_graph(self):
        matches = find_pattern("RG", "RGBY")
        assert len(matches) == 0


class TestFindPatternWildcard:

    def test_wildcard_matches_all_nodes(self):
        matches = find_pattern("RGB", "*")
        assert len(matches) == 3

    def test_wildcard_edge(self):
        # R-G-B chain: pattern *-* should find edges: (0,1) and (1,2)
        matches = find_pattern("RGB", "**")
        assert len(matches) == 2

    def test_wildcard_star_pattern(self):
        # R(G)(B)(Y): find any node with 2 wildcard neighbors
        # Only R (node 0) has degree 3, so *(*) matches:
        # {0,1}, {0,2}, {0,3}
        matches = find_pattern("R(G)(B)(Y)", "*(*)")
        assert len(matches) == 3

    def test_mixed_wildcard_and_exact(self):
        # R(G)(B)(Y): find R connected to any node
        matches = find_pattern("R(G)(B)(Y)", "R(*)")
        assert len(matches) == 3
        for m in matches:
            assert 0 in m.nodes  # R is always node 0


class TestFindPatternColorSet:

    def test_color_set_matches(self):
        matches = find_pattern("RGB", "[RB]")
        assert len(matches) == 2
        node_sets = {m.nodes for m in matches}
        assert frozenset({0}) in node_sets  # R
        assert frozenset({2}) in node_sets  # B

    def test_color_set_edge(self):
        # R-G-B: find [RG]-[GB] edge
        # R-G matches (R in [RG], G in [GB])
        # G-B matches (G in [RG], B in [GB])
        matches = find_pattern("RGB", "[RG][GB]")
        assert len(matches) == 2


class TestFindPatternNegatedSet:

    def test_negated_set(self):
        # R-G-B: [!R] matches G (node 1) and B (node 2)
        matches = find_pattern("RGB", "[!R]")
        assert len(matches) == 2
        node_sets = {m.nodes for m in matches}
        assert frozenset({1}) in node_sets
        assert frozenset({2}) in node_sets

    def test_negated_set_no_match(self):
        # RRR: [!R] matches nothing
        matches = find_pattern("RRR", "[!R]")
        assert len(matches) == 0


class TestFindPatternCycle:

    def test_triangle_pattern(self):
        # Find a triangle in a graph that contains one
        # R(G-1)(B-1) = R-G, R-B, G-B → triangle
        # However the COGILES for triangle is: R-1(G)B-1 (B connects to both R and G)
        G = cogiles.parse("R-1GB-1")
        # Pattern: triangle of any colors
        matches = find_pattern(G, "*-1**-1")
        assert len(matches) == 1
        assert len(matches[0].nodes) == 3

    def test_no_triangle_in_tree(self):
        G = cogiles.parse("R(G)(B)")
        matches = find_pattern(G, "*-1**-1")
        assert len(matches) == 0


class TestFindPatternDisconnected:

    def test_disconnected_graph(self):
        # RR.BB — two components. Pattern R matches 2 times.
        matches = find_pattern("RR.BB", "R")
        assert len(matches) == 2


class TestFindPatternStringInputs:

    def test_both_strings(self):
        matches = find_pattern("R(G)(B)", "R(*)")
        assert len(matches) == 2

    def test_graph_object_pattern_string(self):
        G = cogiles.parse("R(G)(B)")
        matches = find_pattern(G, "R(*)")
        assert len(matches) == 2

    def test_graph_string_pattern_object(self):
        P = parse_pattern("R")
        matches = find_pattern("RGR", P)
        assert len(matches) == 2


class TestFindPatternMapping:

    def test_mapping_direction(self):
        """Mapping should be pattern_node -> graph_node."""
        matches = find_pattern("RGB", "RG")
        assert len(matches) == 1
        m = matches[0]
        # Pattern node 0 (R) maps to graph node 0 (R)
        # Pattern node 1 (G) maps to graph node 1 (G)
        assert m.mapping[0] == 0
        assert m.mapping[1] == 1

    def test_nodes_property(self):
        matches = find_pattern("RGB", "RG")
        assert matches[0].nodes == frozenset({0, 1})


class TestFindPatternEmpty:

    def test_empty_pattern_no_matches(self):
        matches = find_pattern("RGB", ".")
        assert len(matches) == 0
