#!/usr/bin/env python3
import argparse
import collections
import importlib.util
import os
import random
import sys
import time


def load_extractor():
    here = os.path.dirname(os.path.abspath(__file__))
    path = os.path.join(here, "hcp_extract.py")
    spec = importlib.util.spec_from_file_location("hcp_extract", path)
    mod = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(mod)
    return mod


class DSU:
    def __init__(self, n):
        self.parent = list(range(n))
        self.size = [1] * n

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

    def union(self, a, b):
        ra = self.find(a)
        rb = self.find(b)
        if ra == rb:
            return False
        if self.size[ra] < self.size[rb]:
            ra, rb = rb, ra
        self.parent[rb] = ra
        self.size[ra] += self.size[rb]
        return True


def edge_key(a, b):
    return (a, b) if a < b else (b, a)


def orient_cycle(cycle, fixed_block=None, preferred_next=None):
    n = len(cycle)
    if fixed_block is None or fixed_block not in cycle:
        return cycle
    i = cycle.index(fixed_block)
    fwd = cycle[i:] + cycle[:i]
    rev = [cycle[i]] + list(reversed(cycle[:i] + cycle[i + 1 :]))
    if preferred_next is not None:
        if n > 1 and fwd[1] == preferred_next:
            return fwd
        if n > 1 and rev[1] == preferred_next:
            return rev
    return fwd


def selected_to_cycle(n, selected):
    adj = [[] for _ in range(n)]
    for a, b in selected:
        adj[a].append(b)
        adj[b].append(a)
    if any(len(x) != 2 for x in adj):
        return None
    cycle = [0]
    prev = -1
    cur = 0
    for _ in range(n - 1):
        a, b = adj[cur]
        nxt = a if a != prev else b
        cycle.append(nxt)
        prev, cur = cur, nxt
    if cycle[-1] not in adj[cycle[0]]:
        return None
    if len(set(cycle)) != n:
        return None
    return cycle


def validate_cycle(n, undirected, cycle):
    if cycle is None or len(cycle) != n or len(set(cycle)) != n:
        return False
    edges = set(undirected)
    for i, a in enumerate(cycle):
        b = cycle[(i + 1) % n]
        if edge_key(a, b) not in edges:
            return False
    return True


def cycle_cover_from_selected(n, selected):
    adj = [[] for _ in range(n)]
    for a, b in selected:
        adj[a].append(b)
        adj[b].append(a)
    if any(len(x) != 2 for x in adj):
        return None, None
    seen = [False] * n
    cycles = []
    for s in range(n):
        if seen[s]:
            continue
        cyc = []
        prev = -1
        cur = s
        while not seen[cur]:
            seen[cur] = True
            cyc.append(cur)
            a, b = adj[cur]
            nxt = a if a != prev else b
            prev, cur = cur, nxt
        cycles.append(cyc)
    return cycles, adj


def greedy_two_factor(n, edges, adj_edges, rng, fixed_edge=None):
    m = len(edges)
    state = [-1] * m
    selected_count = [0] * n
    unknown_count = [len(adj_edges[v]) for v in range(n)]
    q = collections.deque(range(n))

    def set_edge(ei, val):
        if state[ei] != -1:
            return state[ei] == val
        state[ei] = val
        a, b = edges[ei]
        for v in (a, b):
            unknown_count[v] -= 1
            if val:
                selected_count[v] += 1
            if selected_count[v] > 2 or selected_count[v] + unknown_count[v] < 2:
                return False
            q.append(v)
        return True

    def propagate():
        while q:
            v = q.popleft()
            if selected_count[v] == 2:
                for ei in adj_edges[v]:
                    if state[ei] == -1 and not set_edge(ei, 0):
                        return False
            elif selected_count[v] + unknown_count[v] == 2:
                for ei in adj_edges[v]:
                    if state[ei] == -1 and not set_edge(ei, 1):
                        return False
        return True

    if fixed_edge is not None:
        edge_index = {edge_key(a, b): i for i, (a, b) in enumerate(edges)}
        ei = edge_index.get(edge_key(*fixed_edge))
        if ei is not None and not set_edge(ei, 1):
            return None
    if not propagate():
        return None

    undecided = set(i for i in range(m) if state[i] == -1)
    while undecided:
        sample_size = min(96, len(undecided))
        sample = rng.sample(tuple(undecided), sample_size) if len(undecided) > sample_size else tuple(undecided)

        def score(ei):
            a, b = edges[ei]
            slack = (2 - selected_count[a]) + (2 - selected_count[b])
            pressure = unknown_count[a] + unknown_count[b]
            return (slack, pressure, rng.random())

        ei = min(sample, key=score)
        a, b = edges[ei]
        # Include edges more often around degree-2/3 vertices; exclude more often near hubs.
        include_bias = 0.68 if min(len(adj_edges[a]), len(adj_edges[b])) <= 3 else 0.48
        first = 1 if rng.random() < include_bias else 0

        snapshot = (state[:], selected_count[:], unknown_count[:], collections.deque(q), set(undecided))
        if not set_edge(ei, first) or not propagate():
            state, selected_count, unknown_count, q, undecided = snapshot
            if not set_edge(ei, 1 - first) or not propagate():
                return None

        while undecided and state[next(iter(undecided))] != -1:
            # Fast path for clustered removals; the full comprehension below handles the rest.
            undecided.discard(next(iter(undecided)))
        undecided = {i for i in undecided if state[i] == -1}

    if not all(x == 2 for x in selected_count):
        return None
    return {edges[i] for i, x in enumerate(state) if x == 1}


def patch_cycle_cover(n, all_edges, selected, rng, max_rounds=40):
    all_edge_set = set(all_edges)
    selected = set(selected)
    rounds = 0
    while rounds < max_rounds:
        rounds += 1
        cycles, selected_adj = cycle_cover_from_selected(n, selected)
        if cycles is None:
            return None, rounds, None
        if len(cycles) == 1:
            return cycles[0], rounds, 1

        comp = [-1] * n
        for ci, cyc in enumerate(cycles):
            for v in cyc:
                comp[v] = ci

        nonselected = all_edge_set - selected
        old_count = len(cycles)
        moved = False
        # Try every non-selected bridge and see whether a second bridge exists
        # between the neighbors that would merge the two cycle components.
        for a, c in tuple(nonselected):
            ca, cc = comp[a], comp[c]
            if ca == cc:
                continue
            for b in selected_adj[a]:
                if comp[b] != ca:
                    continue
                ab = edge_key(a, b)
                for d in selected_adj[c]:
                    if comp[d] != cc:
                        continue
                    cd = edge_key(c, d)
                    bd = edge_key(b, d)
                    if bd not in nonselected:
                        continue
                    selected.remove(ab)
                    selected.remove(cd)
                    selected.add(edge_key(a, c))
                    selected.add(bd)
                    moved = True
                    break
                if moved:
                    break
            if moved:
                break
        if moved:
            continue

        # No immediate merge: accept a same-cycle 2-opt reshuffle if it keeps
        # the number of cycles unchanged. These plateau moves often expose a
        # direct merge on the next round.
        shuffled = list(nonselected)
        rng.shuffle(shuffled)
        for a, c in shuffled[: min(len(shuffled), 512)]:
            if comp[a] != comp[c]:
                continue
            for b in selected_adj[a]:
                ab = edge_key(a, b)
                for d in selected_adj[c]:
                    cd = edge_key(c, d)
                    bd = edge_key(b, d)
                    if ab == cd or bd not in nonselected:
                        continue
                    add1 = edge_key(a, c)
                    if add1 == bd:
                        continue
                    selected.remove(ab)
                    selected.remove(cd)
                    selected.add(add1)
                    selected.add(bd)
                    new_cycles, _ = cycle_cover_from_selected(n, selected)
                    if new_cycles is not None and len(new_cycles) <= old_count:
                        moved = True
                        break
                    selected.remove(add1)
                    selected.remove(bd)
                    selected.add(ab)
                    selected.add(cd)
                if moved:
                    break
            if moved:
                break
        if not moved:
            return None, rounds, len(cycles)
    cycles, _ = cycle_cover_from_selected(n, selected)
    return None, rounds, len(cycles) if cycles is not None else None


def forced_degree_two_edges(adj):
    forced = set()
    for v, ns in enumerate(adj):
        if len(ns) == 2:
            forced.add(edge_key(v, ns[0]))
            forced.add(edge_key(v, ns[1]))
    return forced


def greedy_no_premature_cycle(n, edges, adj, rng, fixed_edge=None, use_forced=True):
    selected = set()
    deg = [0] * n
    dsu = DSU(n)

    def add(a, b, forced=False):
        k = edge_key(a, b)
        if k in selected:
            return True
        if deg[a] >= 2 or deg[b] >= 2:
            return False
        same = dsu.find(a) == dsu.find(b)
        if same and len(selected) != n - 1:
            return False
        selected.add(k)
        deg[a] += 1
        deg[b] += 1
        if not same:
            dsu.union(a, b)
        return True

    initial = []
    if fixed_edge is not None:
        initial.append(fixed_edge)
    if use_forced:
        initial.extend(sorted(forced_degree_two_edges(adj)))
    for a, b in initial:
        if not add(a, b, forced=True):
            return None

    edge_set = set(edges)

    def options_for(v):
        out = []
        if deg[v] >= 2:
            return out
        for u in adj[v]:
            if deg[u] >= 2:
                continue
            k = edge_key(v, u)
            if k in selected or k not in edge_set:
                continue
            same = dsu.find(v) == dsu.find(u)
            if same and len(selected) != n - 1:
                continue
            out.append((v, u))
        return out

    while len(selected) < n:
        needy = [v for v in range(n) if deg[v] < 2]
        if not needy:
            break
        sample_size = min(len(needy), 96)
        sample = rng.sample(needy, sample_size) if len(needy) > sample_size else needy
        scored = []
        for v in sample:
            opts = options_for(v)
            if not opts:
                return None
            scored.append((len(opts), deg[v], rng.random(), v, opts))
        scored.sort()
        _, _, _, v, opts = scored[0]

        def edge_score(e):
            a, b = e
            # Prefer satisfying constrained endpoints, but keep enough noise for restarts.
            slack = (len(adj[a]) - deg[a]) + (len(adj[b]) - deg[b])
            forced_bonus = (deg[a] == 1) + (deg[b] == 1)
            return (slack - forced_bonus * 2, rng.random())

        opts.sort(key=edge_score)
        a, b = opts[0]
        if not add(a, b):
            return None

    return selected_to_cycle(n, selected)


def posa_path_cycle(n, adj, rng, fixed_edge=None, deadline=None):
    unused = set(range(n))
    if fixed_edge is None:
        start = rng.randrange(n)
        path = [start]
        unused.remove(start)
    else:
        a, b = fixed_edge
        path = [a, b]
        unused.remove(a)
        unused.remove(b)

    def extend_one():
        extended = False
        while True:
            cur = path[-1]
            candidates = [u for u in adj[cur] if u in unused]
            if not candidates:
                return extended
            candidates.sort(key=lambda u: (sum(1 for w in adj[u] if w in unused), rng.random()))
            u = candidates[0]
            path.append(u)
            unused.remove(u)
            extended = True

    extend_one()
    rotations_without_gain = 0
    max_rot = n * 30
    while unused and rotations_without_gain < max_rot:
        if deadline and time.time() > deadline:
            return None
        end = path[-1]
        pivots = [i for i in range(len(path) - 2) if path[i] in adj[end]]
        if not pivots:
            path.reverse()
            extend_one()
            rotations_without_gain += 1
            continue
        rng.shuffle(pivots)
        gained = False
        for i in pivots[:32]:
            new_path = path[: i + 1] + list(reversed(path[i + 1 :]))
            path[:] = new_path
            before = len(path)
            extend_one()
            if len(path) > before:
                gained = True
                break
        rotations_without_gain = 0 if gained else rotations_without_gain + 1

    if unused:
        return None
    if path[-1] in adj[path[0]]:
        return path

    for _ in range(n * 10):
        if deadline and time.time() > deadline:
            return None
        end = path[-1]
        pivots = [i for i in range(len(path) - 2) if path[i] in adj[end]]
        if not pivots:
            return None
        rng.shuffle(pivots)
        for i in pivots[:32]:
            new_path = path[: i + 1] + list(reversed(path[i + 1 :]))
            if new_path[-1] in adj[new_path[0]]:
                return new_path
            path[:] = new_path
    return None


def run_search(cnf, seconds, seed, force_root_edge=False, output_cycle=None):
    hcp_extract = load_extractor()
    _, _, _, layout, edges_by_var, _, n, undirected = hcp_extract.extract(cnf)
    adj = [[] for _ in range(n)]
    all_edges = [edge_key(a, b) for a, b in undirected]
    edge_index = {e: i for i, e in enumerate(all_edges)}
    adj_edges = [[] for _ in range(n)]
    for a, b in undirected:
        adj[a].append(b)
        adj[b].append(a)
        ei = edge_index[edge_key(a, b)]
        adj_edges[a].append(ei)
        adj_edges[b].append(ei)
    for ns in adj:
        ns.sort()

    fixed_block = layout.get("fixed_block")
    preferred_next = None
    fixed_edge = None
    if layout.get("low_units"):
        low = abs(layout["low_units"][0])
        e = edges_by_var.get(low)
        if e is not None and fixed_block in e:
            preferred_next = e[0] if e[1] == fixed_block else e[1]
            if force_root_edge:
                fixed_edge = edge_key(fixed_block, preferred_next)

    deadline = time.time() + seconds
    attempts = collections.Counter()
    rng = random.Random(seed)
    start = time.time()
    cycle = None
    method = None
    best_cover_cycles = None

    while time.time() < deadline and cycle is None:
        attempts["two_factor"] += 1
        selected = greedy_two_factor(n, all_edges, adj_edges, rng, fixed_edge=fixed_edge)
        if selected is not None:
            cover, _ = cycle_cover_from_selected(n, selected)
            if cover is not None:
                cover_count = len(cover)
                if best_cover_cycles is None or cover_count < best_cover_cycles:
                    best_cover_cycles = cover_count
            c, patch_rounds, remaining = patch_cycle_cover(n, all_edges, selected, rng)
            attempts["patch_rounds"] += patch_rounds
            if remaining is not None:
                attempts[f"patch_stop_{remaining}"] += 1
            if validate_cycle(n, undirected, c):
                cycle, method = c, "two_factor_patch"
                break

        if attempts["two_factor"] % 10 == 0:
            attempts["greedy_forced"] += 1
            c = greedy_no_premature_cycle(n, undirected, adj, rng, fixed_edge=fixed_edge, use_forced=True)
            if validate_cycle(n, undirected, c):
                cycle, method = c, "greedy_forced"
                break
        if attempts["two_factor"] % 30 == 0:
            attempts["greedy_plain"] += 1
            c = greedy_no_premature_cycle(n, undirected, adj, rng, fixed_edge=fixed_edge, use_forced=False)
            if validate_cycle(n, undirected, c):
                cycle, method = c, "greedy_plain"
                break
        if attempts["two_factor"] % 60 == 0:
            attempts["posa"] += 1
            c = posa_path_cycle(n, adj, rng, fixed_edge=fixed_edge, deadline=deadline)
            if validate_cycle(n, undirected, c):
                cycle, method = c, "posa"
                break

    elapsed = time.time() - start
    deg_hist = dict(sorted(collections.Counter(len(ns) for ns in adj).items()))
    print(f"instance {os.path.basename(cnf)}")
    print(f"blocks {n} undirected {len(undirected)} degree_hist {deg_hist}")
    print(f"fixed_block {fixed_block} preferred_next {preferred_next} force_root_edge {force_root_edge}")
    print(f"attempts {dict(attempts)}")
    print(f"best_cover_cycles {best_cover_cycles}")
    if cycle is None:
        print(f"result FAIL elapsed {elapsed:.3f}s")
        return 1

    cycle = orient_cycle(cycle, fixed_block=fixed_block, preferred_next=preferred_next)
    ok = validate_cycle(n, undirected, cycle)
    root_ok = cycle[0] == fixed_block if fixed_block is not None else True
    next_ok = preferred_next is None or (len(cycle) > 1 and cycle[1] == preferred_next)
    print(f"result SAT elapsed {elapsed:.3f}s method {method} valid {ok}")
    print(f"root_oriented {root_ok} preferred_next_ok {next_ok}")
    print("cycle_prefix", " ".join(map(str, cycle[:24])))
    if output_cycle:
        with open(output_cycle, "w") as f:
            f.write(" ".join(map(str, cycle)))
            f.write("\n")
        print(f"wrote_cycle {output_cycle}")
    return 0 if ok else 2


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("cnf")
    ap.add_argument("--seconds", type=float, default=30.0)
    ap.add_argument("--seed", type=int, default=1)
    ap.add_argument("--force-root-edge", action="store_true")
    ap.add_argument("--output-cycle")
    args = ap.parse_args()
    return run_search(args.cnf, args.seconds, args.seed, args.force_root_edge, args.output_cycle)


if __name__ == "__main__":
    raise SystemExit(main())
