"""full_chiral_dag.py — CRT Torus Braid DAG with Coprimality Guard.

Combines axis-swap (topology, YB ✓) and adjustment (resource, FA-changing)
into a unified DAG traversal. The Coprimality Guard ensures all 16 moduli
remain pairwise coprime after every crossing.

References:
  - docs/crt-torus-embedding.md (core CRT Torus embedding)
  - docs/research/unified_crt_torus_dag.md (graded Sidon energy + hierarchy)
  - docs/research/braid_group_action.md (dual-model framework)
"""

import math
from typing import List, Optional, Tuple

# ═══════════════════════════════════════════════════════════════
# §1  SIDON CHECK VIA WRAPPING CRITERION
# ═══════════════════════════════════════════════════════════════

# The Sidon creation theorem (docs/research/sidon_preservation_creation.md):
# For A0 ⊆ ℤ and CRT embedding F, F(A0) is Sidon iff for EVERY sum
# collision a+b = c+d in A0, the CRT lifts wrap M differently:
#   F(a)+F(b) = T + r₁·M,  F(c)+F(d) = T + r₂·M,  r₁ ≠ r₂
# where wrap indicator r = 1 if F(x)+F(y) ≥ M, else 0.
#
# With L_id-only adjustment and M > 2·max(A0), new collisions cannot
# form (M-difference condition is vacuous). The only question is
# whether the existing collisions break.

def crt_sum(a: int, b: int, pairs: List[Tuple[int, int]], S: int) -> Tuple[int, int]:
    """Compute F(a)+F(b) and its wrap indicator.

    Returns (sum, wrap) where wrap = 1 if sum ≥ M, 0 otherwise.
    """
    Fa = crt_embed(a, pairs, S)
    Fb = crt_embed(b, pairs, S)
    total = Fa + Fb
    M = math.prod(m for pair in pairs for m in pair)
    return (total, 1 if total >= M else 0)


def find_collisions(A0: List[int]) -> List[Tuple[int, int, int, int]]:
    """Find all sum collisions in A0.

    Returns list of ((a,b), (c,d), T) where a+b = c+d = T and (a,b) ≠ (c,d).
    """
    n = len(A0)
    sum_map = {}
    collisions = []
    for i in range(n):
        for j in range(i, n):
            s = A0[i] + A0[j]
            if s in sum_map:
                ci, cj = sum_map[s]
                if ci != i or cj != j:
                    collisions.append((A0[ci], A0[cj], A0[i], A0[j], s))
            else:
                sum_map[s] = (i, j)
    return collisions


def sidon_check(
    A0: List[int],
    pairs: List[Tuple[int, int]],
    S: int,
) -> Tuple[bool, int, float]:
    """Check if F(A0) is Sidon under current moduli.

    Returns (is_sidon, broken_count, score).
    - is_sidon: True if all collisions broken
    - broken_count: how many collisions are broken
    - score: 0 if Sidon, else graded residual (lower = closer to Sidon)
    """
    collisions = find_collisions(A0)
    if not collisions:
        return (True, 0, 0.0)

    broken = 0
    for a, b, c, d, T in collisions:
        _, wrap1 = crt_sum(a, b, pairs, S)
        _, wrap2 = crt_sum(c, d, pairs, S)
        if wrap1 != wrap2:
            broken += 1

    if broken == len(collisions):
        return (True, broken, 0.0)

    # Score: fraction of unbroken collisions, scaled to (0, 4].
    total = len(collisions)
    score = 4.0 * (1.0 - broken / total)
    return (False, broken, max(0.0, score))


def sidon_energy(
    A0: List[int],
    pairs: List[Tuple[int, int]],
    S: int,
) -> float:
    """Graded Sidon energy: 0 if Sidon, else ℰ ∈ (0, 4] for non-Sidon."""
    _, _, score = sidon_check(A0, pairs, S)
    return score


# ═══════════════════════════════════════════════════════════════
# §2  CRT EMBEDDING
# ═══════════════════════════════════════════════════════════════

def crt_embed(
    a: int,
    pairs: List[Tuple[int, int]],
    S: int,
) -> int:
    """CRT Torus Embedding F: ℤ → ℤ/Mℤ.

    Axis 1: a ↦ a mod L₁ (identity)
    Axes 2…k: a ↦ S − a mod Lᵢ (reflection)

    Reconstructs via CRT to produce a unique integer lift in [0, M).
    """
    residues = []
    moduli = []
    for i, (L_id, L_ref) in enumerate(pairs):
        moduli.append(L_id)
        if i == 0:
            residues.append(a % L_id)
        else:
            residues.append((S - a) % L_id)
        moduli.append(L_ref)
        residues.append((S - a) % L_ref)

    # Iterative CRT
    x = residues[0]
    M = moduli[0]
    for i in range(1, len(moduli)):
        m_i = moduli[i]
        r_i = residues[i]
        # Find k such that x + k·M ≡ r_i (mod m_i)
        # k ≡ (r_i − x) · M⁻¹ (mod m_i)
        inv = pow(M, -1, m_i)
        k = ((r_i - x) * inv) % m_i
        x = x + k * M
        M = M * m_i
    return x


def crt_embed_set(
    A: List[int],
    pairs: List[Tuple[int, int]],
    S: int,
) -> List[int]:
    """Apply CRT Torus Embedding F to every element of A."""
    return sorted([crt_embed(a, pairs, S) for a in A])


# ═══════════════════════════════════════════════════════════════
# §3  COPRIMALITY GUARD
# ═══════════════════════════════════════════════════════════════

def pairwise_coprime(moduli: List[int]) -> bool:
    """Coprimality Guard: check all moduli are pairwise coprime.

    Returns True iff gcd(m_i, m_j) = 1 for all i ≠ j.
    This is the CRITICAL invariant: CRT requires pairwise coprime moduli
    to guarantee injectivity of the torus embedding F.

    Failure mode: adjusting a modulus by ±2 can make it share a factor
    with another modulus (e.g., one hits 7, another was already 14).
    The guard catches this before it corrupts the node.
    """
    n = len(moduli)
    for i in range(n):
        for j in range(i + 1, n):
            if math.gcd(moduli[i], moduli[j]) != 1:
                return False
    return True


# ═══════════════════════════════════════════════════════════════
# §3  CHIRAL PAIRS — INITIALIZATION
# ═══════════════════════════════════════════════════════════════

def _nth_prime(n: int) -> int:
    """Return the n-th prime (0-indexed), generating on the fly."""
    known = [2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47, 53,
             59, 61, 67, 71, 73, 79, 83, 89, 97, 101, 103, 107, 109, 113]
    while len(known) <= n:
        candidate = known[-1] + 2
        while any(candidate % p == 0 for p in known):
            candidate += 2
        known.append(candidate)
    return known[n]


def chiral_pairs(
    n_strands: int = 8,
    band_gap: int = 30,
    base_prime_offset: int = 0,
) -> List[Tuple[int, int]]:
    """Initialize chiral pairs with distinct primes.

    Each strand gets an (L_id, L_ref) pair where both are prime.
    All 2·n_strands moduli are pairwise coprime by construction.

    With L_id-only adjustment (L_ref fixed), the spacing between
    L_id and L_ref doesn't restrict capacity — only the Q16_16
    bound (32767) and L_id > 1 matter.

    Args:
        n_strands:   number of braid strands
        band_gap:    (unused with L_id-only adjustment, kept for API compat)
        base_prime_offset: starting index into prime sequence

    Returns:
        List of (L_id, L_ref) pairs, one per strand
    """
    pairs = []
    idx = base_prime_offset
    for _ in range(n_strands):
        L_id = _nth_prime(idx); idx += 1
        L_ref = _nth_prime(idx); idx += 1
        pairs.append((L_id, L_ref))
    return pairs


# ═══════════════════════════════════════════════════════════════
# §4  AXIS-SWAP (TOPOLOGY, YB-COMPLIANT)
# ═══════════════════════════════════════════════════════════════

def axis_swap(
    pairs: List[Tuple[int, int]],
    s: int,
) -> List[Tuple[int, int]]:
    """Swap reflection moduli of adjacent strands s and s+1.

    This is the braid generator σ_s acting on the reflection axis only.
    The identity moduli are untouched. This is FA-invariant (CRT symmetry)
    and satisfies YB, σ²=id, and far commutativity.

    Args:
        pairs: current list of (L_id, L_ref) per strand
        s:     strand index (0 ≤ s < len(pairs) − 1)

    Returns a NEW list with the reflection moduli swapped.
    """
    if s < 0 or s >= len(pairs) - 1:
        return pairs[:]
    new_pairs = list(pairs)
    L_id_s, L_ref_s = new_pairs[s]
    L_id_s1, L_ref_s1 = new_pairs[s + 1]
    new_pairs[s] = (L_id_s, L_ref_s1)
    new_pairs[s + 1] = (L_id_s1, L_ref_s)
    return new_pairs


# ═══════════════════════════════════════════════════════════════
# §5  ADJUSTMENT (RESOURCE, FA-CHANGING)
# ═══════════════════════════════════════════════════════════════

def adjust(
    pairs: List[Tuple[int, int]],
    s: int,
    direction: str,
) -> Optional[List[Tuple[int, int]]]:
    """Adjust modulus values for strand s — L_id only.

    Design finding from Coprimality Guard (full_chiral_dag.py §3):
    The original ±2/∓1 adjustment on BOTH moduli breaks within-pair
    coprimality after ≤1 crossing (e.g., (13,41)→(15,40) shares
    factor 5). Fix: adjust only L_id, keeping L_ref fixed at its
    initial prime. This guarantees within-pair coprimality since
    gcd(L_id ± 2k, L_ref) = 1 when L_ref is a distinct prime and
    doesn't divide the adjusted L_id.

    Over:   L_id += 2
    Under:  L_id −= 2

    Returns a new list of pairs, or None if:
    - New modulus ≤ 1 (invalid for CRT)
    - Fails the Coprimality Guard (shares factor with another modulus)
    """
    if direction not in ('over', 'under'):
        raise ValueError(f"Invalid direction: {direction}")

    new_pairs = [(a, b) for a, b in pairs]
    L_id, L_ref = new_pairs[s]

    if direction == 'over':
        new_id = L_id + 2
    else:
        new_id = L_id - 2

    if new_id <= 1:
        return None  # modulus invalid

    new_pairs[s] = (new_id, L_ref)

    moduli = [m for pair in new_pairs for m in pair]
    if not pairwise_coprime(moduli):
        return None

    return new_pairs


# ═══════════════════════════════════════════════════════════════
# §6  COUPLED CROSSING
# ═══════════════════════════════════════════════════════════════

def coupled_crossing(
    pairs: List[Tuple[int, int]],
    s: int,
    direction: str,
    A0: List[int],
    S: int,
) -> Optional[Tuple[List[Tuple[int, int]], float]]:
    """One coupled crossing: axis-swap → adjust → verify.

    Returns (new_pairs, new_energy) if successful, None if coprimality fails.
    """
    swapped = axis_swap(pairs, s)
    adjusted = adjust(swapped, s, direction)
    if adjusted is None:
        return None
    E_new = sidon_energy(A0, adjusted, S)
    if not pairwise_coprime([m for pair in adjusted for m in pair]):
        return None
    return (adjusted, E_new)


# ═══════════════════════════════════════════════════════════════
# §7  DIRECTIONAL CAPACITY
# ═══════════════════════════════════════════════════════════════

def compute_directional_capacities(
    pairs: List[Tuple[int, int]],
    max_across: int = 15,
) -> List[int]:
    """Compute directional capacities per strand, packed into 4-bit word.

    L_id-only adjustment: 2 directions (over/under).
    Bits 0-1: cap_over (over-crossings, limited by Q16_16 bound 32767)
    Bits 2-3: cap_under (under-crossings, limited by L_id > 1)

    Each capacity capped at 3 (2-bit range).

    Args:
        pairs:      current chiral pairs
        max_across: maximum crossings used for normalization

    Returns:
        Packed capacities per strand, as list of ints
    """
    Q16_BOUND = 32767
    capacities = []
    for L_id, L_ref in pairs:
        cap_over = min(3, (Q16_BOUND - L_id) // 2)
        cap_under = min(3, (L_id - 3) // 2) if L_id > 3 else 0

        packed = cap_over | (cap_under << 2)
        capacities.append(packed)
    return capacities


def dag_capacity(capacities: List[int], direction: str) -> int:
    """DAG-level capacity: min of strand capacities in this direction."""
    shift = 0 if direction == 'over' else 2
    vals = [(c >> shift) & 3 for c in capacities]
    return min(vals)


# ═══════════════════════════════════════════════════════════════
# §8  DAG NODE
# ═══════════════════════════════════════════════════════════════

class DAGNode:
    """A node in the CRT torus braid DAG.

    Attributes:
        pairs: chiral pairs (L_id, L_ref) per strand
        A:     current integer set (CRT lifts)
        M:     product of all moduli
        energy: SidonEnergy ℰ of this state
        capacities: 4-directional capacities (8-bit per strand)
        braid_word: list of (strand, direction) crossings from root
        depth: number of crossings from root
        children: child node references (by moduli hash)
    """
    __slots__ = (
        'pairs', 'A0', 'S', 'M', 'energy', 'capacities',
        'braid_word', 'depth', 'children', 'is_sidon', 'broken',
    )

    def __init__(
        self,
        pairs: List[Tuple[int, int]],
        A0: List[int],
        S: int,
        braid_word: Optional[List[Tuple[int, str]]] = None,
        depth: int = 0,
    ):
        self.pairs = pairs
        self.A0 = A0
        self.S = S
        self.M = math.prod(m for pair in pairs for m in pair)
        sidon_ok, self.broken, self.energy = sidon_check(A0, pairs, S)
        self.is_sidon = sidon_ok
        self.capacities = compute_directional_capacities(pairs)
        self.braid_word = braid_word or []
        self.depth = depth
        self.children = []

    @property
    def moduli(self) -> List[int]:
        return [m for pair in self.pairs for m in pair]

    def modulus_hash(self) -> int:
        h = 0
        for m in self.moduli:
            h = h * 31 + m
        return h

    def __repr__(self) -> str:
        return (
            f"DAGNode(depth={self.depth}, M={self.M}, "
            f"ℰ={self.energy:.4f}, "
            f"braid={self.braid_word})"
        )


# ═══════════════════════════════════════════════════════════════
# §9  CHIRAL DAG TRAVERSAL
# ═══════════════════════════════════════════════════════════════

EPSILON = 1e-9


class ChiralDAG:
    """CRT Torus Braid DAG with unified axis-swap × adjustment traversal.

    Usage:
        dag = ChiralDAG(A0=[1, 2, 5, 6], S=7, n_strands=3)
        dag.build(max_steps=8)
        print(dag.summary())
    """

    def __init__(
        self,
        A0: List[int],
        S: int,
        n_strands: int = 8,
        band_gap: int = 60,
    ):
        self.A0 = sorted(A0)
        self.S = S
        self.n_strands = n_strands
        self.band_gap = band_gap
        self.root: Optional[DAGNode] = None
        self.visited: dict = {}
        self.stats = {
            'nodes_created': 0,
            'sidon_nodes': 0,
            'pruned_coprimality': 0,
            'pruned_energy': 0,
            'pruned_exhausted': 0,
            'deduped': 0,
            'phases': [0, 0, 0],
        }

    def _make_root(self) -> DAGNode:
        pairs = chiral_pairs(
            n_strands=self.n_strands,
            band_gap=self.band_gap,
            base_prime_offset=10,
        )
        node = DAGNode(pairs, self.A0, self.S, depth=0)
        self.visited[node.modulus_hash()] = node
        self.stats['nodes_created'] += 1
        return node

    def _maybe_prune(
        self,
        parent: DAGNode,
        child: DAGNode,
    ) -> bool:
        """Check if child should be pruned. Returns True if pruned."""
        # 1. Coprimality invariant: already checked in coupled_crossing,
        #    but re-check for safety.
        moduli = child.moduli
        if not pairwise_coprime(moduli):
            self.stats['pruned_coprimality'] += 1
            return True

        # 2. Monotonicity: SidonEnergy must not increase
        if child.energy > parent.energy + EPSILON:
            self.stats['pruned_energy'] += 1
            return True

        # 3. Sidon reached: accept but don't expand further
        if child.is_sidon:
            self.stats['sidon_nodes'] += 1
            return False  # accept, mark as terminal

        # 4. Capacity exhaustion: DAG-level check
        for d in ['over', 'under']:
            if dag_capacity(child.capacities, d) <= 0:
                self.stats['pruned_exhausted'] += 1
                return True

        return False

    def build(
        self,
        max_steps: int = 30,
        max_nodes: int = 10000,
        use_axis_swap: bool = True,
        use_adjustment: bool = True,
    ) -> None:
        """Build the DAG using BFS with three-phase traversal.

        Phase 1: Graded Sidon search (small bands)
        Phase 2: DAG topology expansion (wide bands)
        Phase 3: Content-addressable dedup (hash-based)
        """
        self.root = self._make_root()
        queue = [self.root]
        self.stats['phases'][0] += 1

        while queue and self.stats['nodes_created'] < max_nodes:
            node = queue.pop(0)

            if node.depth >= max_steps:
                continue

            # Phase transition: when energy is low, widen bands
            if node.energy < 0.5 and self.stats['phases'][1] == 0:
                self.stats['phases'][1] = 1
                self.band_gap = 500

            if node.is_sidon:
                continue  # terminal

            for s in range(self.n_strands):
                for direction in ['over', 'under']:
                    # DAG-level capacity check (fast prune)
                    if dag_capacity(node.capacities, direction) <= 0:
                        self.stats['pruned_exhausted'] += 1
                        continue

                    result = None
                    if use_axis_swap and use_adjustment:
                        result = coupled_crossing(
                            node.pairs, s, direction, self.A0, self.S,
                        )
                    elif use_axis_swap:
                        new_pairs = axis_swap(node.pairs, s)
                        new_E = sidon_energy(self.A0, new_pairs, self.S)
                        if pairwise_coprime([m for pair in new_pairs for m in pair]):
                            result = (new_pairs, new_E)
                    elif use_adjustment:
                        new_pairs = adjust(node.pairs, s, direction)
                        if new_pairs is not None:
                            new_E = sidon_energy(self.A0, new_pairs, self.S)
                            result = (new_pairs, new_E)
                        else:
                            self.stats['pruned_coprimality'] += 1
                    else:
                        continue

                    if result is None:
                        self.stats['pruned_coprimality'] += 1
                        continue

                    new_pairs, new_energy = result

                    child = DAGNode(
                        pairs=new_pairs,
                        A0=self.A0,
                        S=self.S,
                        braid_word=node.braid_word + [(s, direction)],
                        depth=node.depth + 1,
                    )

                    child.energy = new_energy

                    # Pruning gates
                    if self._maybe_prune(node, child):
                        continue

                    # Content-addressable dedup
                    h = child.modulus_hash()
                    if h in self.visited:
                        existing = self.visited[h]
                        if existing.energy <= child.energy:
                            self.stats['deduped'] += 1
                            node.children.append(existing)
                            continue

                    self.visited[h] = child
                    self.stats['nodes_created'] += 1
                    node.children.append(child)
                    queue.append(child)

        self.stats['phases'][2] = 1

    def summary(self) -> str:
        """Return a text summary of the DAG build."""
        sidon_nodes = [
            n for n in self.visited.values()
            if n.is_sidon
        ]
        if sidon_nodes:
            shortest = min(sidon_nodes, key=lambda n: n.depth)
            sidon_str = (
                f"Sidon paths found: {len(sidon_nodes)}\n"
                f"Shortest path: depth={shortest.depth}, "
                f"braid={shortest.braid_word}, "
                f"ℰ={shortest.energy:.4f}\n"
                f"Final moduli: {shortest.moduli}"
            )
        else:
            sidon_str = "No Sidon paths found."

        return (
            f"── ChiralDAG Summary ──\n"
            f"Strands: {self.n_strands}, Band gap: {self.band_gap}\n"
            f"Nodes created: {self.stats['nodes_created']}\n"
            f"Deduped: {self.stats['deduped']}\n"
            f"Pruned — coprimality: {self.stats['pruned_coprimality']}\n"
            f"Pruned — energy: {self.stats['pruned_energy']}\n"
            f"Pruned — exhausted: {self.stats['pruned_exhausted']}\n"
            f"Phases: {self.stats['phases']}\n"
            f"Sidon nodes: {self.stats['sidon_nodes']}\n"
            f"{sidon_str}"
        )

    def to_json(self, path: str) -> None:
        """Export DAG to JSON for visualization."""
        import json
        def _node_to_dict(n: DAGNode) -> dict:
            return {
                'depth': n.depth,
                'pairs': n.pairs,
                'moduli': n.moduli,
                'M': n.M,
                'energy': round(n.energy, 6),
                'is_sidon': n.is_sidon,
                'braid_word': n.braid_word,
                'children': [
                    c.modulus_hash() for c in n.children
                ],
            }
        data = {
            'n_strands': self.n_strands,
            'band_gap': self.band_gap,
            'A0': self.A0,
            'stats': self.stats,
            'nodes': {str(h): _node_to_dict(n)
                      for h, n in self.visited.items()},
        }
        with open(path, 'w') as f:
            json.dump(data, f, indent=2)


# ═══════════════════════════════════════════════════════════════
# §10  MAIN / SELF-TEST
# ═══════════════════════════════════════════════════════════════

if __name__ == '__main__':
    # Working test case (collision 3+13=8+8=16 is breakable via CRT wrapping)
    A0 = [0, 1, 3, 8, 13]
    S = 27

    print("=== 2-strand test ===")
    dag = ChiralDAG(A0=A0, S=S, n_strands=2)
    dag.build(max_steps=15, max_nodes=500)
    print(dag.summary())
    print()

    print("=== 8-strand test ===")
    dag8 = ChiralDAG(A0=A0, S=S, n_strands=8)
    dag8.build(max_steps=15, max_nodes=5000)
    print(dag8.summary())
    print()

    # Q16_16 bound check
    all_mods = [m for n in dag8.visited.values() for m in n.moduli]
    max_m = max(all_mods) if all_mods else 0
    print(f"Max modulus (8-strand): {max_m} {'✓' if max_m < 32767 else '✗ > 32767!'}")

    # Depth distribution of Sidon paths
    sidon_nodes = [n for n in dag8.visited.values() if n.is_sidon]
    if sidon_nodes:
        depths = {}
        for n in sidon_nodes:
            depths[n.depth] = depths.get(n.depth, 0) + 1
        print(f"Sidon depth distribution: {dict(sorted(depths.items()))}")
