"""Empirical test of the chain DP for the loose conjecture.

For each (G, cut, side), build the cut tire forest. For each cut tire
T = D(T) (cycle + pendants), enumerate proper 3-edge-colorings of T.
Tree DP bottom-up: at each node, compute the set of out-spoke
colorings compatible with at least one valid child combination.

Verify at roots: R_i != empty, contains a full S_3 orbit.
Verify across cut: R_0 ∩ R_1 != empty, contains a full S_3 orbit.

Per the chain_half_analysis.tex note, this directly tests:
  - Conjecture (non-emptiness preservation): A(T_p) non-empty if
    children's A's are non-empty.
  - Bottom-line: R_0 ∩ R_1 non-empty.
"""
import os
import sys
import itertools
from collections import defaultdict

from sage.all import Graph, graphs

HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
from cut_depth_label import (
    parse_planar_code, HM_FILE, find_six_edge_cut,
    apply_procedure, compute_nice_layout,
)
from cut_tire_tree import build_tree


def order_cycle(cycle_edges):
    """Order edges into a cyclic walk. Returns (verts_in_order,
    edges_in_order) or (None, None) if not a simple cycle.
    edges_in_order[i] connects verts_in_order[i] and verts_in_order[i+1]."""
    n = len(cycle_edges)
    if n < 2:
        return None, None
    e_set = {tuple(sorted(e)) for e in cycle_edges}
    adj = defaultdict(list)
    for u, v in e_set:
        adj[u].append(v)
        adj[v].append(u)
    # Every vertex must have exactly 2 neighbors for a simple cycle
    if any(len(adj[v]) != 2 for v in adj):
        return None, None
    start = next(iter(adj))
    walk = [start]
    prev = None
    cur = start
    while True:
        nxt = [w for w in adj[cur] if w != prev]
        if not nxt:
            return None, None
        nxt = nxt[0]
        if nxt == start and len(walk) == n:
            break
        walk.append(nxt)
        prev = cur
        cur = nxt
        if len(walk) > n:
            return None, None
    if len(walk) != n:
        return None, None
    edges_in_order = [tuple(sorted((walk[i], walk[(i + 1) % n])))
                      for i in range(n)]
    return walk, edges_in_order


def get_tire_data(H, edge_depth, d, face):
    """For cut tire T_d^{(face)}, return (verts_in_order, cycle_edges,
    in_spokes, out_spokes) or None if face is not a simple cycle.

    in_spokes: dict {v: spoke_edge} for depth d+1 spokes
    out_spokes: dict {v: spoke_edge} for depth d-1 spokes
    """
    cycle_e = [tuple(sorted(e)) for e in face]
    walk, edges_ord = order_cycle(cycle_e)
    if walk is None:
        return None
    in_sp, out_sp = {}, {}
    cycle_e_set = set(edges_ord)
    for v in walk:
        for nb in H.neighbors(v):
            e = tuple(sorted((v, nb)))
            if e in cycle_e_set:
                continue
            d_e = edge_depth.get(e)
            if d_e is None:
                continue
            if d_e == d + 1:
                in_sp[v] = e
            elif d_e == d - 1:
                out_sp[v] = e
    return walk, edges_ord, in_sp, out_sp


def proper_3_colorings(verts, cycle_edges, in_sp, out_sp):
    """Enumerate proper 3-edge-colorings of D(T): cycle + spokes.
    Returns list of dicts {edge: color}.

    For each cycle coloring c[i] (i = 0..n-1) with c[i] != c[i+1]:
        At vertex v_i, the two cycle edges adjacent are c[i-1] and c[i].
        Spoke at v_i (if present) gets the third color.
    """
    n = len(cycle_edges)
    out = []
    for assign in itertools.product([0, 1, 2], repeat=n):
        # Proper cycle
        bad = False
        for i in range(n):
            if assign[i] == assign[(i + 1) % n]:
                bad = True
                break
        if bad:
            continue
        col = {cycle_edges[i]: assign[i] for i in range(n)}
        # Determine spoke colors
        for i, v in enumerate(verts):
            c_left = assign[(i - 1) % n]
            c_right = assign[i]
            third = ({0, 1, 2} - {c_left, c_right})
            if len(third) != 1:
                # Degenerate (n=2 case)
                bad = True
                break
            third_c = third.pop()
            if v in in_sp:
                col[in_sp[v]] = third_c
            if v in out_sp:
                col[out_sp[v]] = third_c
        if not bad:
            out.append(col)
    return out


def s3_orbit(tup):
    """Generate S_3 orbit of a color tuple under permutations of {0,1,2}."""
    orbit = set()
    for perm in itertools.permutations([0, 1, 2]):
        orbit.add(tuple(perm[c] for c in tup))
    return orbit


def has_full_s3_orbit(s):
    """Does set s (of tuples) contain a 6-element S_3 orbit?"""
    for tup in s:
        if len(s3_orbit(tup)) == 6 and s3_orbit(tup) <= s:
            return True
    return False


def chain_dp(faces_by_depth, parent_of, H, edge_depth, side_label):
    """Run the chain DP on the cut tire forest.

    For each tire T_d^{(f)}, compute:
      - tire_data: (verts, cycle_e, in_sp, out_sp)
      - colorings: all proper 3-edge-colorings
      - A(T): set of (out-spoke-color-tuples), restricted by
              compatibility with all children.

    Returns: {node: A(T)}, where each A(T) is a set of tuples.
    Also returns dict of issues found.
    """
    tire_data = {}
    for d, faces in faces_by_depth.items():
        for f_idx, face in enumerate(faces):
            td = get_tire_data(H, edge_depth, d, face)
            if td is not None:
                tire_data[(d, f_idx)] = td

    # Build children list
    children = defaultdict(list)
    for node, p in parent_of.items():
        if p is not None and p != ('cut', None):
            children[p].append(node)

    # Process bottom-up: order nodes by depth descending
    nodes_by_depth = defaultdict(list)
    for node in tire_data:
        nodes_by_depth[node[0]].append(node)
    max_d = max(nodes_by_depth) if nodes_by_depth else 0

    A = {}      # node -> set of out-spoke-color tuples
    fully_S3 = {}  # node -> bool: A(T) contains full S_3 orbit
    issues = []

    for d in range(max_d, 0, -1):
        for node in nodes_by_depth[d]:
            verts, cycle_e, in_sp, out_sp = tire_data[node]
            if not out_sp and d > 1:
                # Internal/leaf without out spokes — degenerate
                A[node] = set()
                continue
            cs = proper_3_colorings(verts, cycle_e, in_sp, out_sp)
            # Order out-spoke positions (by vertex)
            out_sp_verts = sorted(out_sp.keys())
            in_sp_verts = sorted(in_sp.keys())

            # For each child, build map: parent-in-spoke-vertex (= parent
            # boundary vertex with in spoke) -> child-out-spoke-vertex.
            # Equivalent: the in-spoke edge of parent = an out-spoke edge
            # of one of its children. The shared endpoint is the parent's
            # boundary vertex.
            child_outsp_maps = []
            for ch in children.get(node, []):
                if ch not in tire_data:
                    continue
                ch_verts, ch_cycle, ch_in, ch_out = tire_data[ch]
                # Match: parent's in-spoke edge == child's out-spoke edge
                p_in_to_ch_out_v = {}
                for v_p, e_p in in_sp.items():
                    # Find the vertex w of child with out-spoke e_p
                    for v_c, e_c in ch_out.items():
                        if e_p == e_c:
                            p_in_to_ch_out_v[v_p] = (ch, v_c)
                            break
                child_outsp_maps.append((ch, p_in_to_ch_out_v))

            # For each coloring, check compatibility with each child's A
            achievable_out = set()
            for col in cs:
                # Extract parent's in-spoke colors
                p_in_colors_by_v = {v: col[in_sp[v]] for v in in_sp}
                # For each child, verify the corresponding out-spoke
                # color (= our in-spoke color) is in A(child)
                ok = True
                for ch, mapping in child_outsp_maps:
                    if ch not in A:
                        ok = False  # child has no achievable (shouldn't happen)
                        break
                    ch_verts, ch_cycle, ch_in, ch_out = tire_data[ch]
                    ch_out_verts_ordered = sorted(ch_out.keys())
                    # Build the child's out-spoke tuple as we'd need
                    required_ch_out = {}
                    for v_p, (ch_id, v_c) in mapping.items():
                        if ch_id != ch:
                            continue
                        required_ch_out[v_c] = p_in_colors_by_v[v_p]
                    # If some out-spoke vertex of child isn't covered by
                    # any parent in spoke, the value is FREE in A.
                    # So we check: exists tup in A(ch) with tup[i] =
                    # required_ch_out[v] for each i where v is constrained.
                    found_any = False
                    for tup in A[ch]:
                        match = True
                        for i, v in enumerate(ch_out_verts_ordered):
                            if v in required_ch_out and tup[i] != required_ch_out[v]:
                                match = False
                                break
                        if match:
                            found_any = True
                            break
                    if not found_any:
                        ok = False
                        break
                if ok:
                    # Extract parent's out-spoke colors as a tuple
                    if out_sp_verts:
                        tup = tuple(col[out_sp[v]] for v in out_sp_verts)
                        achievable_out.add(tup)
                    else:
                        # No out spokes (root with empty out projection) —
                        # represent as singleton empty tuple
                        achievable_out.add(())
            A[node] = achievable_out
            fully_S3[node] = has_full_s3_orbit(achievable_out)
            if not achievable_out:
                issues.append({
                    'type': 'empty_after_chain',
                    'node': node,
                    'side': side_label,
                    'n_children': len(children.get(node, [])),
                })

    return A, fully_S3, issues, tire_data


def run_test(G, name, max_cuts=5):
    """Run chain DP test on G across several 6-edge cuts."""
    from tree_structure_sweep import all_six_edge_cuts
    cuts = all_six_edge_cuts(G, max_cuts=max_cuts)
    print(f'\n=== {name}: V={G.order()}, E={G.size()}, '
          f'{len(cuts)} cuts tested ===', flush=True)
    base_pos = compute_nice_layout(G)
    n_cuts = 0
    n_empty = 0
    n_no_orbit = 0
    n_crossok = 0
    total_issues = []
    for cut_idx, (S, cut_edges) in enumerate(cuts):
        S0 = S
        S1 = frozenset(G.vertices()) - S
        if len(S0) < 4 or len(S1) < 4:
            continue
        A_per_side = {}
        for side, S_side in [('0', S0), ('1', S1)]:
            try:
                H, pos, ed, _, _, _ = apply_procedure(
                    G, S_side, cut_edges, base_pos, side,
                    pendant_start_id=max(G.vertices()) + 1 +
                    (0 if side == '0' else 200))
            except Exception as e:
                print(f'  cut #{cut_idx} side {side}: apply_procedure '
                      f'failed: {e}', flush=True)
                continue
            faces_by_depth, parent_of, _ = build_tree(H, ed)
            A, fully_S3, issues, tire_data = chain_dp(
                faces_by_depth, parent_of, H, ed, side)
            total_issues.extend(issues)
            # Look at root tires (depth-1)
            root_A = []
            for node in A:
                if node[0] == 1:
                    root_A.append((node, A[node]))
            # Combine root A's into R_i: union over roots
            R_i = set()
            for node, a in root_A:
                R_i |= a
            A_per_side[side] = R_i
            if not R_i:
                n_empty += 1
                print(f'  cut #{cut_idx} side {side}: R_i empty!', flush=True)
            elif not any(len(s3_orbit(t)) == 6 and s3_orbit(t) <= R_i
                         for t in R_i):
                n_no_orbit += 1
                print(f'  cut #{cut_idx} side {side}: R_i lacks full S_3 '
                      f'orbit (|R_i|={len(R_i)})', flush=True)
        if '0' in A_per_side and '1' in A_per_side:
            n_cuts += 1
            # Cross-cut check: R_0 and R_1 are on the same 6 cut edges,
            # but indexed by which side's roots. Need bijection.
            # For now, compare structurally: do they share orbits?
            R0, R1 = A_per_side['0'], A_per_side['1']
            common = R0 & R1
            if common:
                n_crossok += 1
            else:
                print(f'  cut #{cut_idx}: R_0 ∩ R_1 = empty '
                      f'(|R_0|={len(R0)}, |R_1|={len(R1)})', flush=True)
    print(f'  cuts tested: {n_cuts}, R_i empty: {n_empty}, '
          f'R_i no full orbit: {n_no_orbit}, R_0 ∩ R_1 non-empty: '
          f'{n_crossok}/{n_cuts}', flush=True)
    return total_issues


def main():
    # Small test: Dodecahedron
    G = graphs.DodecahedralGraph()
    G.is_planar(set_embedding=True)
    issues_dodec = run_test(G, 'Dodecahedron', max_cuts=5)

    # HM #0
    gs = parse_planar_code(HM_FILE)
    issues_hm0 = run_test(gs[0], 'HM_0', max_cuts=5)

    print('\n=== Summary of issues ===', flush=True)
    print(f'Dodecahedron: {len(issues_dodec)} issues')
    print(f'HM_0:         {len(issues_hm0)} issues')


if __name__ == '__main__':
    main()
