"""Search for a proper 3-edge-coloring of a reduced dual satisfying:

  (i)   color(spike) == color(merged)
  (ii)  the {c_spike, c_side_0}-Kempe cycle through the spike contains
        side_0 AND merged
  (iii) the {c_spike, c_side_1}-Kempe cycle through the spike contains
        side_1 AND merged

Iterates over all min-degree-5 simple planar triangulations on n vertices
(via plantri through Sage), then every pentagonal face of the dual G', then
every index i in {0,...,4} for the reduced-dual construction, then every
proper 3-edge-coloring of the resulting reduced dual. Stops on the first hit.

The icosahedron (n = 12) is the only n = 12 triangulation with min degree 5
and was already known to fail (see check_dodecahedron_kempe.py).

Run with:
    sage experiments/search_kempe_property.py
"""
from sage.all import Graph
from sage.graphs.graph_generators import graphs
import sys
import time


def dual_of(G):
    """Combinatorial dual of a planar G with the embedding set on G."""
    faces = G.faces()
    edge_to_faces = {}
    for fi, face in enumerate(faces):
        for u, v in face:
            e = frozenset((u, v))
            edge_to_faces.setdefault(e, []).append(fi)
    dual_edges = []
    for e, fs in edge_to_faces.items():
        if len(fs) == 2:
            dual_edges.append((fs[0], fs[1]))
    return Graph(dual_edges, multiedges=False, loops=False)


def apply_reduction(G, face, i):
    """Apply Definition 2.1 at the pentagonal `face` (list of directed edges)
    with index i, returning (H, named) or (None, None) if the construction
    breaks."""
    boundary = [u for (u, v) in face]
    if len(set(boundary)) != 5:
        return None, None
    A = []
    for B_k in boundary:
        outer = [w for w in G.neighbor_iterator(B_k) if w not in boundary]
        if len(outer) != 1:
            return None, None
        A.append(outer[0])
    if len(set(A)) != 5:
        return None, None
    H = G.copy()
    for v in boundary:
        H.delete_vertex(v)
    v_n = '__v_n__'
    H.add_vertex(v_n)
    side_0 = (v_n, A[i % 5])
    spike  = (v_n, A[(i + 1) % 5])
    side_1 = (v_n, A[(i + 2) % 5])
    merged = (A[(i + 3) % 5], A[(i + 4) % 5])
    # If merged would be a self-loop or duplicate of an existing edge, skip
    if A[(i + 3) % 5] == A[(i + 4) % 5]:
        return None, None
    H.add_edges([side_0, spike, side_1, merged])
    if H.has_multiple_edges() or H.has_loops():
        return None, None
    if not H.is_planar(set_embedding=True):
        return None, None
    # Cubic check
    if not all(H.degree(v) == 3 for v in H.vertex_iterator()):
        return None, None
    named = {
        'spike':  frozenset(spike),
        'side_0': frozenset(side_0),
        'side_1': frozenset(side_1),
        'merged': frozenset(merged),
    }
    return H, named


def proper_3_edge_colorings(G):
    edges = list(G.edges(labels=False))
    n_edges = len(edges)
    adj = [[] for _ in range(n_edges)]
    for i in range(n_edges):
        u, v = edges[i][0], edges[i][1]
        for j in range(i):
            x, y = edges[j][0], edges[j][1]
            if u in (x, y) or v in (x, y):
                adj[i].append(j)
                adj[j].append(i)
    coloring = [-1] * n_edges

    def back(k):
        if k == n_edges:
            yield tuple(coloring)
            return
        for c in range(3):
            if all(coloring[j] != c for j in adj[k]):
                coloring[k] = c
                yield from back(k + 1)
        coloring[k] = -1

    return edges, back(0)


def kempe_cycle(edges, coloring, start_idx, color_pair):
    a, b = color_pair
    if coloring[start_idx] not in (a, b):
        return None
    in_sub = [i for i in range(len(edges)) if coloring[i] in (a, b)]
    visited = {start_idx}
    stack = [start_idx]
    while stack:
        cur = stack.pop()
        u, v = edges[cur][0], edges[cur][1]
        for j in in_sub:
            if j in visited:
                continue
            x, y = edges[j][0], edges[j][1]
            if u in (x, y) or v in (x, y):
                visited.add(j)
                stack.append(j)
    return visited


def matches(edges, col, named_idx):
    c_spike  = col[named_idx['spike']]
    c_merged = col[named_idx['merged']]
    if c_spike != c_merged:
        return False
    c_s0 = col[named_idx['side_0']]
    c_s1 = col[named_idx['side_1']]
    kc0 = kempe_cycle(edges, col, named_idx['spike'], (c_spike, c_s0))
    if named_idx['side_0'] not in kc0 or named_idx['merged'] not in kc0:
        return False
    kc1 = kempe_cycle(edges, col, named_idx['spike'], (c_spike, c_s1))
    if named_idx['side_1'] not in kc1 or named_idx['merged'] not in kc1:
        return False
    return True


def report(edges, col, named_idx, named, info):
    print()
    print("*** MATCH FOUND ***")
    for k, v in info.items():
        print(f"  {k} = {v}")
    print("Named edges and their colours:")
    for role in ('spike', 'side_0', 'side_1', 'merged'):
        e = edges[named_idx[role]]
        print(f"  {role:7s}: edge {tuple(e)}, colour {col[named_idx[role]]}")
    print(f"Full coloring (indexed by edge order):")
    for i, e in enumerate(edges):
        print(f"  [{col[i]}] {tuple(e)}")


def search(max_n=16, time_budget_sec=600):
    start = time.time()
    n = 12
    while n <= max_n:
        elapsed = time.time() - start
        if elapsed > time_budget_sec:
            print(f"\nTime budget {time_budget_sec}s exhausted at n = {n}.")
            return
        print(f"\n=== n = {n} ===  (elapsed {elapsed:.1f}s)")
        tcount = 0
        total_reductions = 0
        total_colorings = 0
        try:
            it = graphs.triangulations(n, minimum_degree=5)
        except Exception as ex:
            print(f"  cannot enumerate triangulations: {ex}")
            n += 1
            continue
        for G in it:
            tcount += 1
            if not G.is_planar(set_embedding=True):
                continue
            D = dual_of(G)
            if not D.is_planar(set_embedding=True):
                continue
            faces_D = D.faces()
            pentagonal = [f for f in faces_D if len(f) == 5]
            for face in pentagonal:
                for i_red in range(5):
                    H, named = apply_reduction(D, face, i_red)
                    if H is None:
                        continue
                    total_reductions += 1
                    edges, gen = proper_3_edge_colorings(H)
                    # Find indices of named edges
                    named_idx = {}
                    for ii, e in enumerate(edges):
                        es = frozenset((e[0], e[1]))
                        for role, ns in named.items():
                            if es == ns:
                                named_idx[role] = ii
                    if len(named_idx) != 4:
                        continue
                    for col in gen:
                        total_colorings += 1
                        if matches(edges, col, named_idx):
                            report(edges, col, named_idx, named, {
                                'n': n,
                                'triangulation_index': tcount,
                                'face_size': len(face),
                                'i_red': i_red,
                                'reduced_dual_V': H.order(),
                                'reduced_dual_E': H.size(),
                            })
                            return
            sys.stdout.flush()
        print(f"  n = {n}: {tcount} triangulation(s), "
              f"{total_reductions} reductions, "
              f"{total_colorings} colorings; no hit.")
        n += 1
    print(f"\nSearch exhausted up to n = {max_n}.")


if __name__ == '__main__':
    search()
