"""Simple level resolution coverage check.

For each (G, S) where G is a triangulation on n vertices, run Phase 1 +
Phase 2 across all levels k>=1 (in order k=1, 2, ...). Record the resulting
G' iso-class IF G' is still a triangulation (3n-6 edges, no multi-edges,
planar). Aggregate across all (G, S) to see which iso-classes are reachable
as simple level resolutions.

Question: is every triangulation iso-class on n vertices reached?
"""
import sys, os
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import networkx as nx
from collections import defaultdict
from itertools import product as iproduct
from triangulation_gen import enumerate_all_triangulations
from level_cycles import compute_levels, level_sources
from depth_monovariant_check import (
    faces_of_subgraph, canonical_face, edges_of_face,
    apex_levels_for_edge, identify_outer_face, face_depths,
)
from full_resolution_check import (
    phase1_resolve_isolated, interior_odd_faces,
)


def break_source_triangle(G, source_set):
    """If source is a face (3 vertices) and the source triangle is present
    in G, flip one of its edges. Returns modified G."""
    if len(source_set) != 3:
        return G
    a, b, c = tuple(source_set)
    if not (G.has_edge(a, b) and G.has_edge(b, c) and G.has_edge(a, c)):
        return G
    ip, emb = nx.check_planarity(G)
    if not ip:
        return G
    # Flip an edge of the source triangle.
    for (u, v) in [(a, b), (b, c), (a, c)]:
        f1 = emb.traverse_face(u, v)
        f2 = emb.traverse_face(v, u)
        if len(f1) != 3 or len(f2) != 3:
            continue
        w = next(x for x in f1 if x != u and x != v)
        x = next(y for y in f2 if y != u and y != v)
        if w == x or G.has_edge(w, x):
            continue
        Gp = G.copy()
        Gp.remove_edge(u, v)
        Gp.add_edge(w, x)
        return Gp
    return G


def phase2_outer_incident_choices(G, emb, levels, nodes_k, k,
                                    include_even=False):
    """For each simple cycle of L_k incident to the outer face, list available
    outer-face edge flips. Odd cycles: must flip (one choice required).
    Even cycles (if include_even): optional flip (None choice added)."""
    faces = faces_of_subgraph(G, emb, nodes_k)
    if not faces:
        return []
    outer = identify_outer_face(faces, G, emb, levels, k)
    if outer is None:
        return []
    outer_face = next((f for f in faces if canonical_face(f) == outer), None)
    if outer_face is None:
        return []
    outer_edges = set(frozenset(list(e)) for e in edges_of_face(outer_face))
    per_cycle = []
    for face in faces:
        cf = canonical_face(face)
        if cf == outer:
            continue
        is_odd = (len(face) % 2 == 1)
        if not is_odd and not include_even:
            continue
        face_edges = [frozenset([a, b]) for (a, b) in edges_of_face(face)]
        if not any(e in outer_edges for e in face_edges):
            continue
        choices = []
        for (u, v) in edges_of_face(face):
            if frozenset([u, v]) not in outer_edges:
                continue
            if not G.has_edge(u, v):
                continue
            result = apex_levels_for_edge(G, emb, u, v, levels)
            if result is None:
                continue
            _, (w, x) = result
            multi = G.has_edge(w, x)
            choices.append((multi, u, v, w, x))
        if not choices:
            continue
        choices.sort(key=lambda c: c[0])
        cycle_choices = [(c[1], c[2], c[3], c[4]) for c in choices]
        if not is_odd:
            # Even cycle: prepend None (skip)
            cycle_choices = [None] + cycle_choices
        per_cycle.append(cycle_choices)
    return per_cycle


def apply_phase2_choices(G, choices):
    """Apply a list of (u, v, w, x) flips in sequence; None entries are skips."""
    Gp = G.copy()
    applied = set()
    for choice in choices:
        if choice is None:
            continue
        u, v, w, x = choice
        if frozenset([u, v]) in applied:
            continue
        if Gp.has_edge(u, v):
            Gp.remove_edge(u, v)
            Gp.add_edge(w, x)
            applied.add(frozenset([u, v]))
    return Gp


def interior_faces_any_parity(faces, depths, outer_canon, outer_face_edges):
    """Interior faces (depth > 0, no outer-face edges) of any parity."""
    out = []
    for face in faces:
        cf = canonical_face(face)
        if cf == outer_canon:
            continue
        d = depths.get(cf)
        if d is None or d <= 0:
            continue
        oe = sum(1 for (a, b) in edges_of_face(face)
                  if frozenset([a, b]) in outer_face_edges)
        if oe > 0:
            continue
        out.append((cf, face, d, len(face) % 2 == 1))
    return out


def phase1_one_step_choices(G, emb, levels, nodes_k, k, include_even=True):
    """Return all (u, v, w, x) flips Phase 1 might take in one step.
    If include_even, even interior faces are also targets, but only when
    no interior odd face is at maximum depth (we still resolve odd first)."""
    faces = faces_of_subgraph(G, emb, nodes_k)
    if len(faces) < 2:
        return []
    outer = identify_outer_face(faces, G, emb, levels, k)
    if outer is None:
        return []
    outer_face = next((f for f in faces if canonical_face(f) == outer), None)
    outer_face_edges = set()
    if outer_face is not None:
        for (a, b) in edges_of_face(outer_face):
            outer_face_edges.add(frozenset([a, b]))
    depths = face_depths(faces, outer)
    if depths is None:
        depths = {}
    interior_all = interior_faces_any_parity(faces, depths, outer,
                                              outer_face_edges)
    interior_odd = [(cf, f, d) for (cf, f, d, is_odd) in interior_all if is_odd]
    if not interior_odd:
        return []  # Phase 1 stops when no interior odd face remains
    max_d = max(d for _, _, d in interior_odd)
    # Deepest odd faces are the primary candidates.
    deepest = [(cf, f, d) for cf, f, d in interior_odd if d == max_d]
    # Additionally include ALL interior even faces (any depth >= 1) as
    # branching candidates — these are optional restructuring moves.
    if include_even:
        for (cf, f, d, is_odd) in interior_all:
            if not is_odd:
                deepest.append((cf, f, d))
    # For each deepest face, find its own lowest-depth-neighbor flip(s).
    all_flips = []
    for (cf_t, face_t, _) in deepest:
        face_canon = cf_t
        face_edges_set = set(frozenset([a, b])
                              for (a, b) in edges_of_face(face_t))
        per_face = []
        for f in faces:
            cf = canonical_face(f)
            if cf == face_canon or cf == outer:
                continue
            shared = None
            for (a, b) in edges_of_face(f):
                if frozenset([a, b]) in face_edges_set:
                    shared = (a, b)
                    break
            if shared is None:
                continue
            d = depths.get(cf)
            if d is None:
                continue
            u, v = shared
            if not G.has_edge(u, v):
                continue
            result = apex_levels_for_edge(G, emb, u, v, levels)
            if result is None:
                continue
            _, (w, x) = result
            if G.has_edge(w, x):
                continue
            per_face.append((d, u, v, w, x))
        if not per_face:
            continue
        face_min_d = min(f[0] for f in per_face)
        for (d, u, v, w, x) in per_face:
            if d == face_min_d:
                all_flips.append((u, v, w, x))
    return all_flips


def phase1_branch(G, levels, by_level, branch_limit=3, depth_limit=20):
    """Branch on Phase 1 choices; yield all resulting graphs after Phase 1
    completes (or hits depth_limit)."""
    seen = set()
    def rec(Gc, step):
        sig = tuple(sorted(Gc.edges()))
        if sig in seen:
            return
        seen.add(sig)
        if step >= depth_limit:
            yield Gc
            return
        ip, emb = nx.check_planarity(Gc)
        if not ip:
            return
        # Find a level with phase 1 work to do
        for k in sorted(by_level):
            if k == 0:
                continue
            if len(by_level[k]) < 3:
                continue
            nodes_k = by_level[k]
            flips = phase1_one_step_choices(Gc, emb, levels, nodes_k, k)
            if flips:
                for (u, v, w, x) in flips[:branch_limit]:
                    Gp = Gc.copy()
                    Gp.remove_edge(u, v)
                    Gp.add_edge(w, x)
                    yield from rec(Gp, step + 1)
                return
        # No work to do anywhere
        yield Gc
    yield from rec(G, 0)


def all_source_triangle_breaks(G, source_set):
    """Yield (1) G unchanged, and (2..k) G with each source-triangle edge
    flipped (if applicable)."""
    yield G.copy()
    if len(source_set) != 3:
        return
    a, b, c = tuple(source_set)
    if not (G.has_edge(a, b) and G.has_edge(b, c) and G.has_edge(a, c)):
        return
    ip, emb = nx.check_planarity(G)
    if not ip:
        return
    for (u, v) in [(a, b), (b, c), (a, c)]:
        f1 = emb.traverse_face(u, v)
        f2 = emb.traverse_face(v, u)
        if len(f1) != 3 or len(f2) != 3:
            continue
        w = next(x for x in f1 if x != u and x != v)
        x = next(y for y in f2 if y != u and y != v)
        if w == x or G.has_edge(w, x):
            continue
        Gp = G.copy()
        Gp.remove_edge(u, v)
        Gp.add_edge(w, x)
        yield Gp


def simple_level_resolutions(G, source_set, p1_branch=4, p2_branch=5):
    """Enumerate Phase 1 + Phase 2 outputs with branching, trying all
    source-triangle-break options (including skipping the break)."""
    levels = compute_levels(G, source_set)
    by_level = defaultdict(list)
    for v, lv in levels.items():
        by_level[lv].append(v)
    seen_outputs = set()
    for Gc in all_source_triangle_breaks(G, source_set):
        # Branch on Phase 1
        for G_p1 in phase1_branch(Gc, levels, by_level,
                                    branch_limit=p1_branch):
            per_level_choices = []
            for k in sorted(by_level):
                if k == 0:
                    continue
                if len(by_level[k]) < 3:
                    continue
                nodes_k = by_level[k]
                ip, emb = nx.check_planarity(G_p1)
                if not ip:
                    continue
                pcs = phase2_outer_incident_choices(G_p1, emb, levels,
                                                     nodes_k, k,
                                                     include_even=True)
                pcs = [c[:p2_branch] for c in pcs]
                per_level_choices.extend(pcs)
            if not per_level_choices:
                sig = tuple(sorted(G_p1.edges()))
                if sig not in seen_outputs:
                    seen_outputs.add(sig)
                    yield G_p1
                continue
            total = 1
            for c in per_level_choices:
                total *= len(c)
            if total > 4096:
                choices = [c[0] for c in per_level_choices]
                Gp = apply_phase2_choices(G_p1, choices)
                sig = tuple(sorted(Gp.edges()))
                if sig not in seen_outputs:
                    seen_outputs.add(sig)
                    yield Gp
                continue
            for combo in iproduct(*per_level_choices):
                Gp = apply_phase2_choices(G_p1, combo)
                sig = tuple(sorted(Gp.edges()))
                if sig not in seen_outputs:
                    seen_outputs.add(sig)
                    yield Gp


def simple_level_resolve(G, source_set):
    """Single-output version: yield the first triangulation output (if any),
    else fall back to first output."""
    first = None
    for Gp in simple_level_resolutions(G, source_set):
        if first is None:
            first = Gp
        if is_triangulation(Gp, G.number_of_nodes()):
            return Gp, 'ok'
    if first is None:
        return G.copy(), 'no_output'
    return first, 'ok'


def is_triangulation(G, n):
    if not isinstance(G, nx.Graph):
        return False
    if G.number_of_nodes() != n:
        return False
    if G.number_of_edges() != 3 * n - 6:
        return False
    ip, _ = nx.check_planarity(G)
    return ip


def iso_index(G, isos):
    for i, T in enumerate(isos):
        if nx.is_isomorphic(G, T):
            return i
    return None


def is_self_level_resolution(T):
    """Does T admit some level source S such that T is a level resolution
    of (T, S)? I.e., the parity subgraphs of T (under T's own BFS from S)
    are both bipartite."""
    ip, emb = nx.check_planarity(T)
    if not ip:
        return False
    for kind, label, source_set in level_sources(T, emb):
        levels = compute_levels(T, source_set)
        even = [v for v in T.nodes() if levels.get(v, 0) % 2 == 0]
        odd = [v for v in T.nodes() if levels.get(v, 0) % 2 == 1]
        if nx.is_bipartite(T.subgraph(even)) and \
           nx.is_bipartite(T.subgraph(odd)):
            return True
    return False


def run(n):
    isos = enumerate_all_triangulations(n)
    print(f"n={n}: {len(isos)} iso-classes")
    md5 = set()
    for i, T in enumerate(isos):
        if min(T.degree(v) for v in T.nodes()) >= 5:
            md5.add(i)
    print(f"  min-degree-5 iso-classes: {len(md5)}")
    self_resolutions = set()
    for i, T in enumerate(isos):
        if is_self_level_resolution(T):
            self_resolutions.add(i)
    print(f"  self-level-resolutions: {len(self_resolutions)} iso-classes")
    reached_any = set()    # iso-class is the output (any triangulation)
    reached_bip = set()    # iso-class is an output with bipartite parity
    total_pairs = 0
    triangulation_outputs = 0
    bipartite_outputs = 0
    for src_idx, G in enumerate(isos):
        ip, emb = nx.check_planarity(G)
        if not ip:
            continue
        for kind, label, source_set in level_sources(G, emb):
            total_pairs += 1
            for Gp in simple_level_resolutions(G, source_set):
                if not is_triangulation(Gp, n):
                    continue
                triangulation_outputs += 1
                levels = compute_levels(G, source_set)
                even_nodes = [v for v in Gp.nodes()
                              if levels.get(v, 0) % 2 == 0]
                odd_nodes = [v for v in Gp.nodes()
                              if levels.get(v, 0) % 2 == 1]
                is_bip = (nx.is_bipartite(Gp.subgraph(even_nodes)) and
                           nx.is_bipartite(Gp.subgraph(odd_nodes)))
                if is_bip:
                    bipartite_outputs += 1
                tgt = iso_index(Gp, isos)
                if tgt is not None:
                    reached_any.add(tgt)
                    if is_bip:
                        reached_bip.add(tgt)
    # Total iso-classes reached
    all_iso = set(range(len(isos)))
    total_any = len(reached_any)
    total_bip = len(reached_bip)
    missing_total_bip = sorted(all_iso - reached_bip)
    print(f"  (G, S) pairs:           {total_pairs}")
    print(f"  triangulation outputs:  {triangulation_outputs}")
    print(f"  bipartite-parity outputs:{bipartite_outputs}")
    print(f"  TOTAL ISO-CLASSES REACHED:")
    print(f"    any-output:             {total_any} / {len(isos)}")
    print(f"    bipartite-parity:       {total_bip} / {len(isos)}"
          f" (missing: {missing_total_bip[:10] if missing_total_bip else 'none'}"
          f"{'...' if len(missing_total_bip) > 10 else ''})")
    # md5 subset
    needed_md5 = md5 - self_resolutions
    missing_md5 = sorted(needed_md5 - reached_bip)
    print(f"  md5 SUBSET (needed for Conjecture~5.7):")
    print(f"    needed: {len(needed_md5)}; reached bip: "
          f"{len(reached_bip & needed_md5)} / {len(needed_md5)}"
          f" (missing: {missing_md5[:10] if missing_md5 else 'none'})")


if __name__ == "__main__":
    if len(sys.argv) > 1:
        ns = [int(x) for x in sys.argv[1:]]
    else:
        ns = [6, 7, 8]
    for n in ns:
        run(n)
