"""Search for a counterexample to Conjecture 3.6.

For each min-deg-5 triangulation G and each reduced dual H_1, find a proper
3-edge-coloring phi_1 of H_1 that satisfies (i) chord-apex (spike = merged)
and (ii) both Kempe-chain conditions of Lemma 2.7 -- i.e., the kind of
coloring that a minimal counterexample's reduced dual would be forced to
have. Run Algorithm 3.1 to completion from phi_1, get the final colouring
phi_t* on H_t*. Then enumerate all proper 3-edge-colorings of H_t*, find the
Kempe class containing phi_t*, and check whether any coloring in that class
is "all-distinct" (every spike_t != merged_t for t = 1, ..., t*).

If no such all-distinct coloring exists in phi_t*'s Kempe class:
counterexample to Conjecture 3.6 found.

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


def dual_of(G):
    G.is_planar(set_embedding=True)
    faces = G.faces()
    edge_to_faces = {}
    for fi, face in enumerate(faces):
        for u, v in face:
            edge_to_faces.setdefault(frozenset((u, v)), []).append(fi)
    dual_edges = [(fs[0], fs[1]) for fs in edge_to_faces.values() if len(fs) == 2]
    return Graph(dual_edges, multiedges=False, loops=False)


def apply_reduction(G, face, i, v_n_label):
    boundary = [u for (u, v) in face]
    if len(set(boundary)) != 5:
        return 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
        A.append(outer[0])
    if len(set(A)) != 5:
        return None
    if A[(i + 3) % 5] == A[(i + 4) % 5]:
        return None
    H = G.copy()
    for v in boundary:
        H.delete_vertex(v)
    H.add_vertex(v_n_label)
    side_0 = (v_n_label, A[i])
    spike  = (v_n_label, A[(i + 1) % 5])
    side_1 = (v_n_label, A[(i + 2) % 5])
    merged = (A[(i + 3) % 5], A[(i + 4) % 5])
    H.add_edges([side_0, spike, side_1, merged])
    if H.has_multiple_edges() or H.has_loops():
        return None
    if not H.is_planar(set_embedding=True):
        return None
    if not all(H.degree(v) == 3 for v in H.vertex_iterator()):
        return None
    return {
        'H': H, 'A': A, 'boundary': boundary,
        'named': {
            'spike':  frozenset(spike),
            'side_0': frozenset(side_0),
            'side_1': frozenset(side_1),
            'merged': frozenset(merged),
        },
    }


def proper_3_edge_colorings(G):
    edges = list(G.edges(labels=False))
    n = len(edges)
    adj = [[] for _ in range(n)]
    for i in range(n):
        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
    results = []

    def back(k):
        if k == n:
            results.append(tuple(coloring))
            return
        for c in range(3):
            if all(coloring[j] != c for j in adj[k]):
                coloring[k] = c
                back(k + 1)
        coloring[k] = -1

    back(0)
    return edges, results


def edge_idx(edges, e_frozen):
    for i, e in enumerate(edges):
        if frozenset((e[0], e[1])) == e_frozen:
            return i
    return None


def kempe_cycle(edges, coloring, start_idx, color_pair):
    a, b = color_pair
    if coloring[start_idx] not in (a, b):
        return set()
    in_sub = set(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_chord_apex_kempe(edges, col, named):
    idx = {role: edge_idx(edges, ns) for role, ns in named.items()}
    if any(v is None for v in idx.values()):
        return False
    c_spike  = col[idx['spike']]
    c_merged = col[idx['merged']]
    if c_spike != c_merged:
        return False
    c_s0 = col[idx['side_0']]
    c_s1 = col[idx['side_1']]
    kc0 = kempe_cycle(edges, col, idx['spike'], (c_spike, c_s0))
    if idx['side_0'] not in kc0 or idx['merged'] not in kc0:
        return False
    kc1 = kempe_cycle(edges, col, idx['spike'], (c_spike, c_s1))
    if idx['side_1'] not in kc1 or idx['merged'] not in kc1:
        return False
    return True


def find_safe_pentagonal_face(G, protected):
    for face in G.faces():
        if len(face) != 5:
            continue
        boundary = [u for (u, v) in face]
        boundary_edges = [frozenset([u, v]) for (u, v) in face]
        externals = []
        A = []
        ok = True
        for B_k in boundary:
            outer = [w for w in G.neighbor_iterator(B_k) if w not in boundary]
            if len(outer) != 1:
                ok = False
                break
            externals.append(frozenset([B_k, outer[0]]))
            A.append(outer[0])
        if not ok:
            continue
        if any(e in protected for e in boundary_edges + externals):
            continue
        return boundary, externals, A
    return None


def valid_index(f_vec, A):
    for i in range(5):
        if f_vec[(i + 3) % 5] != f_vec[(i + 4) % 5]:
            continue
        if len({f_vec[i], f_vec[(i + 1) % 5], f_vec[(i + 2) % 5]}) != 3:
            continue
        if A[(i + 3) % 5] == A[(i + 4) % 5]:
            continue
        return i
    return None


def run_algorithm_to_completion(H, coloring_dict, named_step1, vn_start=1000):
    """Returns (H_final, coloring_final, list_of_named_dicts)."""
    H = H.copy()
    coloring = dict(coloring_dict)
    all_named = [named_step1]
    E = set(named_step1.values())
    next_vn = vn_start

    while True:
        H.is_planar(set_embedding=True)
        result = find_safe_pentagonal_face(H, E)
        if result is None:
            break
        boundary, externals, A = result
        f_vec = [coloring[e] for e in externals]
        i_t = valid_index(f_vec, A)
        if i_t is None:
            break
        v_n = next_vn
        next_vn += 1
        H_new = H.copy()
        for v in boundary:
            H_new.delete_vertex(v)
        H_new.add_vertex(v_n)
        side_0 = (v_n, A[i_t])
        spike  = (v_n, A[(i_t + 1) % 5])
        side_1 = (v_n, A[(i_t + 2) % 5])
        merged = (A[(i_t + 3) % 5], A[(i_t + 4) % 5])
        H_new.add_edges([side_0, spike, side_1, merged])
        if H_new.has_multiple_edges() or H_new.has_loops():
            break
        if not H_new.is_planar(set_embedding=True):
            break
        H = H_new
        coloring = {e: c for e, c in coloring.items()
                    if not any(u in boundary for u in e)}
        coloring[frozenset(side_0)] = f_vec[i_t]
        coloring[frozenset(spike)]  = f_vec[(i_t + 1) % 5]
        coloring[frozenset(side_1)] = f_vec[(i_t + 2) % 5]
        coloring[frozenset(merged)] = f_vec[(i_t + 3) % 5]
        named = {
            'spike':  frozenset(spike),
            'side_0': frozenset(side_0),
            'side_1': frozenset(side_1),
            'merged': frozenset(merged),
        }
        E |= set(named.values())
        all_named.append(named)
    return H, coloring, all_named


def kempe_class_via_union_find(edges, colorings):
    """For each coloring, attempt every Kempe swap and union. Returns parent
    array of disjoint-set forest."""
    n = len(colorings)
    parent = list(range(n))

    def find(x):
        while parent[x] != x:
            parent[x] = parent[parent[x]]
            x = parent[x]
        return x

    def union(x, y):
        rx, ry = find(x), find(y)
        if rx != ry:
            parent[rx] = ry

    col_idx = {c: i for i, c in enumerate(colorings)}
    for c_idx, col in enumerate(colorings):
        for pair in [(0, 1), (0, 2), (1, 2)]:
            visited = set()
            for start in range(len(edges)):
                if col[start] not in pair or start in visited:
                    continue
                cyc = kempe_cycle(edges, col, start, pair)
                visited |= cyc
                a, b = pair
                new_col = list(col)
                for k in cyc:
                    if new_col[k] == a:
                        new_col[k] = b
                    elif new_col[k] == b:
                        new_col[k] = a
                new_col = tuple(new_col)
                if new_col != col and new_col in col_idx:
                    union(c_idx, col_idx[new_col])
    return [find(i) for i in range(n)]


def is_all_distinct(edges, col, all_named):
    for named in all_named:
        i_spike  = edge_idx(edges, named['spike'])
        i_merged = edge_idx(edges, named['merged'])
        if i_spike is None or i_merged is None:
            return False
        if col[i_spike] == col[i_merged]:
            return False
    return True


def check_one_triangulation(G, n, idx_label, time_budget):
    G_planar = G.is_planar(set_embedding=True)
    if not G_planar:
        return None
    D = dual_of(G)
    D.is_planar(set_embedding=True)
    for face in D.faces():
        if len(face) != 5:
            continue
        for i_red in range(5):
            res = apply_reduction(D, face, i_red, 999)
            if res is None:
                continue
            H_1, named_1 = res['H'], res['named']
            edges_1, colorings_1 = proper_3_edge_colorings(H_1)
            candidates = [col for col in colorings_1
                          if matches_chord_apex_kempe(edges_1, col, named_1)]
            if not candidates:
                continue
            # For the first candidate, run algorithm and check.
            phi_1 = candidates[0]
            coloring_dict = {frozenset((e[0], e[1])): c
                             for e, c in zip(edges_1, phi_1)}
            H_final, phi_final_dict, all_named = run_algorithm_to_completion(
                H_1, coloring_dict, named_1)
            edges_final, colorings_final = proper_3_edge_colorings(H_final)
            # find phi_final as a tuple
            phi_final = tuple(
                phi_final_dict[frozenset((e[0], e[1]))] for e in edges_final
            )
            if phi_final not in colorings_final:
                # not in enumeration, skip
                continue
            print(f"  n={n}, tri#{idx_label}, face_size={len(face)}, "
                  f"i_red={i_red}: H_1 has {len(candidates)} chord+Kempe "
                  f"colorings; algorithm produced H_t* with |V|={H_final.order()}, "
                  f"|E|={H_final.size()}; H_t* has {len(colorings_final)} "
                  f"proper colorings.")
            # Kempe classes
            parent = kempe_class_via_union_find(edges_final, colorings_final)
            phi_final_idx = colorings_final.index(phi_final)
            phi_class = parent[phi_final_idx]
            n_in_class = sum(1 for p in parent if p == phi_class)
            print(f"    phi_final's Kempe class has {n_in_class} colorings; "
                  f"total classes: {len(set(parent))}")
            # search for all-distinct in same class
            for ci, p in enumerate(parent):
                if p != phi_class:
                    continue
                if is_all_distinct(edges_final, colorings_final[ci], all_named):
                    print(f"    found all-distinct coloring in same Kempe "
                          f"class -- Conj 3.6 holds for this instance.")
                    break
            else:
                print(f"    *** NO all-distinct coloring in phi_final's "
                      f"Kempe class -- CONJECTURE 3.6 FAILS HERE ***")
                return {
                    'n': n, 'tri': idx_label, 'face_size': len(face),
                    'i_red': i_red, 'H_final': H_final,
                    'phi_final': phi_final, 'all_named': all_named,
                }
    return None


def main(max_n=16, time_budget_sec=600):
    start = time.time()
    for n in range(12, max_n + 1):
        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) ===")
        try:
            it = graphs.triangulations(n, minimum_degree=5)
        except Exception as ex:
            print(f"  cannot enumerate: {ex}")
            continue
        for idx, G in enumerate(it, start=1):
            if time.time() - start > time_budget_sec:
                print(f"Time budget exhausted mid-n={n}.")
                return
            hit = check_one_triangulation(G, n, idx,
                                          time_budget_sec - (time.time() - start))
            if hit is not None:
                print()
                print(f"=== Counterexample to Conjecture 3.6 ===")
                print(f"  n = {hit['n']}, triangulation #{hit['tri']}, "
                      f"i_red = {hit['i_red']}")
                print(f"  H_t* has |V|={hit['H_final'].order()}, "
                      f"|E|={hit['H_final'].size()}")
                return
            sys.stdout.flush()


if __name__ == '__main__':
    main()
