"""Exact SAT formulations for zero forcing in P(n,k).

(a) round_sat: the round-based encoding of Proposition P4 with horizon T = 2n - s.
(b) fort_cover: fort-cover formulation (Proposition P2) with lazy fort generation,
    fort minimisation, dihedral images of every fort (sound: automorphisms map
    forts to forts) and first-force normalisation (four directed-edge orbit
    representatives). UNSAT at bound b proves Z > b; the final static CNF is
    re-solved with proof logging and the DRAT proof is checked by drat-trim.
(c) boundary_sat: exists X with |X| = p and |dX| <= b?  (Krishnan-Ma-Zhang style
    boundary formula adapted to k = 5), with rotation normalisation x_{u0} v x_{v0}.

All CNFs are built with python-sat (sequential-counter cardinality encodings).
"""
import json
import os
import subprocess
import time

import common
from pysat.card import CardEnc, EncType
from pysat.formula import CNF
from pysat.solvers import Solver

from closure_oracle import closure, closure_fast
from graph_constructor import adjacency, adjacency_masks, dihedral_maps, label, u, v

DRAT_TRIM = os.path.join(common.ROOT, "code", "tools", "drat-trim", "drat-trim")


# ----------------------------------------------------------------------------
# helpers
# ----------------------------------------------------------------------------
class VarPool:
    def __init__(self):
        self.top = 0

    def new(self):
        self.top += 1
        return self.top


def atmost(lits, bound, pool):
    enc = CardEnc.atmost(lits=lits, bound=bound, top_id=pool.top, encoding=EncType.seqcounter)
    pool.top = max(pool.top, enc.nv)
    return enc.clauses


def equals(lits, bound, pool):
    enc = CardEnc.equals(lits=lits, bound=bound, top_id=pool.top, encoding=EncType.seqcounter)
    pool.top = max(pool.top, enc.nv)
    return enc.clauses


def is_fort(adjmask, F, N):
    """F: bitmask. Fort iff nonempty and every w outside F has 0 or >= 2 neighbours in F."""
    if F == 0:
        return False
    outside = ((1 << N) - 1) & ~F
    while outside:
        low = outside & -outside
        w = low.bit_length() - 1
        outside ^= low
        x = adjmask[w] & F
        if x and (x & (x - 1)) == 0:
            return False
    return True


def minimise_fort(adjmask, F, N):
    """Greedy: remove vertices while the set stays a fort (result is a fort, subset of F)."""
    changed = True
    while changed:
        changed = False
        rest = F
        while rest:
            low = rest & -rest
            rest ^= low
            G = F & ~low
            if G and is_fort(adjmask, G, N):
                F = G
                changed = True
    return F


def mask_to_list(m):
    out = []
    while m:
        low = m & -m
        out.append(low.bit_length() - 1)
        m ^= low
    return out


def solve_with_proof(clauses, nv, solver_name, cnf_path, proof_path, time_limit=None):
    """Solves a static CNF with proof logging, writes DIMACS + DRAT, runs drat-trim.
    Returns dict with sat, proof_checked, checker_exit, hashes, times."""
    cnf = CNF(from_clauses=clauses)
    cnf.nv = max(cnf.nv, nv)
    cnf.to_file(cnf_path)
    t0 = time.time()
    with Solver(name=solver_name, bootstrap_with=clauses, with_proof=True) as s:
        sat = s.solve()
        model = s.get_model() if sat else None
        proof = None if sat else s.get_proof()
    t_solve = time.time() - t0
    out = {"sat": sat, "solver": solver_name, "solve_seconds": round(t_solve, 3), "cnf_path": os.path.relpath(cnf_path, common.ROOT),
           "cnf_sha256": common.sha256_file(cnf_path), "num_vars": cnf.nv, "num_clauses": len(clauses)}
    if sat:
        out["model"] = model
        return out
    with open(proof_path, "w") as f:
        f.write("\n".join(proof) + "\n")
    out["proof_path"] = os.path.relpath(proof_path, common.ROOT)
    out["proof_sha256"] = common.sha256_file(proof_path)
    out["proof_lines"] = len(proof)
    t1 = time.time()
    cmd = [DRAT_TRIM, cnf_path, proof_path]
    r = subprocess.run(cmd, capture_output=True, text=True)
    out["checker_cmd"] = " ".join(os.path.relpath(c, common.ROOT) if c.startswith("/") else c for c in cmd)
    out["checker_exit"] = r.returncode
    out["checker_verified"] = ("s VERIFIED" in r.stdout)
    out["checker_seconds"] = round(time.time() - t1, 3)
    out["checker_stdout_tail"] = r.stdout.strip().splitlines()[-3:]
    return out


# ----------------------------------------------------------------------------
# (a) round-based encoding (P4)
# ----------------------------------------------------------------------------
def round_sat(n, k, s, solver_name="glucose4", T=None, time_limit=None):
    """Is there a zero-forcing set of size <= s?  Horizon T = 2n - s (P4).
    sat is None if the time limit was hit (unknown). Default solver Glucose 4.1
    (independent code base from the CaDiCaL used in the fort-cover / boundary paths)."""
    import threading
    adj = adjacency(n, k)
    N = 2 * n
    if T is None:
        T = 2 * n - s
    pool = VarPool()
    x = [pool.new() for _ in range(N)]
    b = [[pool.new() for _ in range(T + 1)] for _ in range(N)]
    f = {}
    for w in range(N):
        for bb in adj[w]:
            for t in range(1, T + 1):
                f[(bb, w, t)] = pool.new()
    cl = []
    for w in range(N):
        cl.append([-b[w][0], x[w]])
        cl.append([b[w][0], -x[w]])
        for t in range(1, T + 1):
            fs = [f[(bb, w, t)] for bb in adj[w]]
            cl.append([-b[w][t], b[w][t - 1]] + fs)
            cl.append([b[w][t], -b[w][t - 1]])
            for fv in fs:
                cl.append([b[w][t], -fv])
            for bb in adj[w]:
                fv = f[(bb, w, t)]
                cl.append([-fv, b[bb][t - 1]])
                for w2 in adj[bb]:
                    if w2 != w:
                        cl.append([-fv, b[w2][t - 1]])
        cl.append([b[w][T]])
    cl += atmost(x, s, pool)
    t0 = time.time()
    with Solver(name=solver_name, bootstrap_with=cl) as sv:
        if time_limit:
            timer = threading.Timer(time_limit, lambda: sv.interrupt())
            timer.start()
            sat = sv.solve_limited(expect_interrupt=True)
            timer.cancel()
        else:
            sat = sv.solve()
        model = sv.get_model() if sat else None
    S = None
    if sat:
        S = [w for w in range(N) if model[x[w] - 1] > 0]
    return {"sat": sat, "S": S, "num_vars": pool.top, "num_clauses": len(cl), "T": T,
            "solve_seconds": round(time.time() - t0, 3), "solver": solver_name, "timeout": sat is None}


# ----------------------------------------------------------------------------
# (b) fort-cover with lazy forts, symmetry and first-force normalisation
# ----------------------------------------------------------------------------
def first_force_representatives(n, k):
    """Four directed-edge orbit representatives under D_n: (source, target)."""
    return [(u(0, n), v(0, n)), (u(0, n), u(1, n)), (v(0, n), u(0, n)), (v(0, n), v(k, n))]


def fort_cover(n, k, b, solver_name="cadical195", time_limit=1200.0, normalise=True, symmetric=True,
               seed_forts=None, verbose=False):
    """Decides whether P(n,k) has a zero-forcing set of size <= b.
    Returns dict: sat (True/False/None on timeout), witness S (if sat), forts (list of vertex lists),
    base clauses, nv, iteration statistics."""
    adj = adjacency(n, k)
    adjm = adjacency_masks(n, k)
    N = 2 * n
    full = (1 << N) - 1
    pool = VarPool()
    svar = [pool.new() for _ in range(N)]  # svar[w] = w in S
    base = []
    base += atmost(svar, b, pool)
    if normalise:
        sel = []
        for (src, tgt) in first_force_representatives(n, k):
            c = pool.new()
            sel.append(c)
            base.append([-c, svar[src]])
            base.append([-c, -svar[tgt]])
            for w2 in adj[src]:
                if w2 != tgt:
                    base.append([-c, svar[w2]])
        base.append(sel)
    maps = dihedral_maps(n) if symmetric else [list(range(N))]
    fort_clauses = []
    forts = []
    seen = set()

    def add_fort(F):
        """F bitmask; adds clauses for F and its dihedral images."""
        added = 0
        for p in maps:
            img = 0
            for w in mask_to_list(F):
                img |= 1 << p[w]
            if img in seen:
                continue
            seen.add(img)
            forts.append(img)
            fort_clauses.append([svar[w] for w in mask_to_list(img)])
            added += 1
        return added

    if seed_forts:
        for F in seed_forts:
            add_fort(F)
    t0 = time.time()
    iters = 0
    result = None
    witness = None
    with Solver(name=solver_name, bootstrap_with=base + fort_clauses) as sv:
        while True:
            if time.time() - t0 > time_limit:
                result = None
                break
            iters += 1
            sat = sv.solve()
            if not sat:
                result = False
                break
            model = sv.get_model()
            S = 0
            for w in range(N):
                if model[svar[w] - 1] > 0:
                    S |= 1 << w
            B = closure_fast(adjm, S)
            if B == full:
                result = True
                witness = mask_to_list(S)
                break
            W = full & ~B
            assert is_fort(adjm, W, N)
            F = minimise_fort(adjm, W, N)
            assert is_fort(adjm, F, N) and (F & S) == 0
            before = len(fort_clauses)
            add_fort(F)
            for c in fort_clauses[before:]:
                sv.add_clause(c)
            if verbose and iters % 50 == 0:
                print("  iter %d forts %d elapsed %.1fs" % (iters, len(forts), time.time() - t0), flush=True)
    return {"sat": result, "witness": witness, "forts": [mask_to_list(F) for F in forts], "base_clauses": base,
            "fort_clauses": fort_clauses, "nv": pool.top, "iterations": iters, "wall_seconds": round(time.time() - t0, 3),
            "svar": svar, "solver": solver_name, "normalised": normalise, "symmetric": symmetric}


# ----------------------------------------------------------------------------
# (c) boundary formulas
# ----------------------------------------------------------------------------
def boundary_clauses(n, k, p, bmax, normalise=True):
    """CNF: exists X, |X| = p, |dX| <= bmax (z_w >= [w in dX]). Returns (clauses, x, z, nv)."""
    adj = adjacency(n, k)
    N = 2 * n
    pool = VarPool()
    x = [pool.new() for _ in range(N)]
    z = [pool.new() for _ in range(N)]
    cl = []
    for w in range(N):
        for t in adj[w]:
            cl.append([x[w], -x[t], z[w]])  # (not x_w and x_t) -> z_w
        cl.append([-z[w], -x[w]])  # z_w -> not x_w (so z counts only boundary vertices)
    cl += equals(x, p, pool)
    cl += atmost(z, bmax, pool)
    if normalise:
        cl.append([x[u(0, n)], x[v(0, n)]])
    return cl, x, z, pool.top


def boundary_of(n, k, X):
    adj = adjacency(n, k)
    Xs = set(X)
    return sorted({w for x in X for w in adj[x] if w not in Xs})


def boundary_sat(n, k, p, bmax, solver_name="cadical195"):
    cl, x, z, nv = boundary_clauses(n, k, p, bmax)
    t0 = time.time()
    with Solver(name=solver_name, bootstrap_with=cl) as sv:
        sat = sv.solve()
        model = sv.get_model() if sat else None
    X = None
    if sat:
        X = [w for w in range(2 * n) if model[x[w] - 1] > 0]
        assert len(X) == p and len(boundary_of(n, k, X)) <= bmax
    return {"sat": sat, "X": X, "boundary": (boundary_of(n, k, X) if X else None), "num_vars": nv,
            "num_clauses": len(cl), "solve_seconds": round(time.time() - t0, 3)}


def beta(n, k, p, start=11, solver_name="cadical195"):
    """Exact beta_p(n) = min |dX| over |X| = p, if <= start; else returns start+1 meaning '>= start+1'."""
    best = None
    bX = None
    bnd = start
    while True:
        r = boundary_sat(n, k, p, bnd, solver_name)
        if not r["sat"]:
            break
        best = len(r["boundary"])
        bX = r["X"]
        bnd = best - 1
        if bnd < 0:
            break
    return {"n": n, "k": k, "p": p, "beta": best if best is not None else start + 1, "exact": best is not None,
            "X": bX, "boundary": (boundary_of(n, k, bX) if bX else None)}


if __name__ == "__main__":
    # smoke tests on known cases
    for (n, k, expect) in [(5, 2, 5), (11, 5, 6)]:
        r1 = round_sat(n, k, expect)
        r0 = round_sat(n, k, expect - 1)
        f1 = fort_cover(n, k, expect)
        f0 = fort_cover(n, k, expect - 1)
        print("P(%d,%d): round_sat(%d)=%s round_sat(%d)=%s fort(%d)=%s fort(%d)=%s iters=%d/%d" % (
            n, k, expect, r1["sat"], expect - 1, r0["sat"], expect, f1["sat"], expect - 1, f0["sat"], f1["iterations"], f0["iterations"]))
        assert r1["sat"] and not r0["sat"] and f1["sat"] and not f0["sat"]
    print("boundary P(19,4) p=16 b=9:", boundary_sat(19, 4, 16, 9)["sat"], " (Krishnan-Ma-Zhang example: expected True)")
    print("boundary P(20,4) p=16 b=9:", boundary_sat(20, 4, 16, 9)["sat"], " (expected False)")
    print("SAT smoke tests passed")
