#!/usr/bin/env python3
"""
dna_qubo_sort.py — Naive QUBO-DNA Encoding via Tm Sorting (Approach A)

Encodes QUBO solutions as DNA sequences where melting temperature (Tm)
is approximately proportional to QUBO energy. The key idea:

  x_i = 0 → low-Tm base (A, Tm=2.0)
  x_i = 1 → high-Tm base (P, Tm=3.5)

With 3 bases per variable for majority-vote decoding redundancy.

For diagonal QUBOs with positive entries, Tm sort ≈ energy sort.
For general QUBOs (with off-diagonal or negative terms), correlation
is degraded — this is expected and documented.

This is the "naive" approach. For better results, see dna_qubo_nn.py
which uses nearest-neighbor stacking thermodynamics.
"""

from __future__ import annotations

import random
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Tuple

from dna_codec import (
    BASE_TO_BITS,
    BITS_TO_BASE,
    HACHIMOJI_BASES,
    TM_CONTRIBUTION,
    encode_binary_vector,
    decode_binary_vector,
    melting_temperature,
    qubo_energy,
    gc_content,
    raw_gc_content,
    dna_to_greek,
    encode_bytes_to_dna,
    decode_dna_to_bytes,
)


# ============================================================
# §1  QUBO-DNA CANDIDATE
# ============================================================

@dataclass
class QuboDnaCandidate:
    """A QUBO solution encoded as a DNA sequence with Tm metadata."""
    x: List[int]
    sequence: str
    energy: float
    tm: float
    gc: float

    def to_dict(self) -> dict:
        return {
            "x": self.x,
            "sequence": self.sequence,
            "energy": round(self.energy, 6),
            "tm": round(self.tm, 4),
            "gc": round(self.gc, 4),
        }


# ============================================================
# §2  ENCODING
# ============================================================

def encode_qubo_candidate(
    x: List[int],
    Q: List[List[float]],
    bits_per_var: int = 3,
) -> QuboDnaCandidate:
    """Encode a QUBO solution as a DNA sequence.

    Uses the simple encoding: 0 → A (low Tm), 1 → P (high Tm).
    Tm is monotonically proportional to the number of 1s in x.

    Args:
        x: binary solution vector
        Q: QUBO matrix (for energy computation)
        bits_per_var: bases per variable (default 3)

    Returns:
        QuboDnaCandidate with all metadata
    """
    sequence = encode_binary_vector(x, bits_per_var)
    energy = qubo_energy(x, Q)
    tm = melting_temperature(sequence)
    gc = raw_gc_content(sequence)

    return QuboDnaCandidate(
        x=x,
        sequence=sequence,
        energy=energy,
        tm=tm,
        gc=gc,
    )


# ============================================================
# §3  CANDIDATE GENERATION
# ============================================================

def generate_all_candidates(
    Q: List[List[float]],
    n_vars: int,
    bits_per_var: int = 3,
) -> List[QuboDnaCandidate]:
    """Generate all QUBO candidates by brute force.

    Args:
        Q: QUBO matrix
        n_vars: number of variables (must be <= 12 for brute force)
        bits_per_var: bases per variable

    Returns:
        List of all QuboDnaCandidate (2^n_vars items)
    """
    candidates = []
    for i in range(2**n_vars):
        x = [(i >> j) & 1 for j in range(n_vars)]
        candidates.append(encode_qubo_candidate(x, Q, bits_per_var))
    return candidates


def generate_random_candidates(
    Q: List[List[float]],
    n_vars: int,
    n_samples: int = 1000,
    seed: int = 42,
    bits_per_var: int = 3,
) -> List[QuboDnaCandidate]:
    """Generate random QUBO candidates.

    Args:
        Q: QUBO matrix
        n_vars: number of variables
        n_samples: number of random samples
        seed: RNG seed
        bits_per_var: bases per variable

    Returns:
        List of QuboDnaCandidate
    """
    rng = random.Random(seed)
    seen = set()
    candidates = []
    for _ in range(n_samples):
        x = tuple(rng.randint(0, 1) for _ in range(n_vars))
        if x in seen:
            continue
        seen.add(x)
        candidates.append(encode_qubo_candidate(list(x), Q, bits_per_var))
    return candidates


# ============================================================
# §4  SORTING
# ============================================================

def sort_by_energy(candidates: List[QuboDnaCandidate]) -> List[QuboDnaCandidate]:
    """Sort candidates by QUBO energy (ascending). Ground truth."""
    return sorted(candidates, key=lambda c: c.energy)


def sort_by_tm(candidates: List[QuboDnaCandidate]) -> List[QuboDnaCandidate]:
    """Sort candidates by melting temperature (ascending)."""
    return sorted(candidates, key=lambda c: c.tm)


def sort_by_gc(candidates: List[QuboDnaCandidate]) -> List[QuboDnaCandidate]:
    """Sort candidates by GC content (ascending)."""
    return sorted(candidates, key=lambda c: c.gc)


def spearman_rank_correlation(values_a: List[float], values_b: List[float]) -> float:
    """Compute Spearman rank correlation.

    Returns 1.0 for perfect positive correlation, -1.0 for perfect negative.
    """
    n = len(values_a)
    if n < 2:
        return 0.0

    def rank(vals):
        sorted_idx = sorted(range(n), key=lambda i: vals[i])
        ranks = [0] * n
        for r, idx in enumerate(sorted_idx):
            ranks[idx] = r
        return ranks

    ra = rank(values_a)
    rb = rank(values_b)
    d_sq = sum((a - b) ** 2 for a, b in zip(ra, rb))
    denom = n * (n * n - 1)
    return 1.0 - (6.0 * d_sq) / denom if denom > 0 else 0.0


# ============================================================
# §5  Tm SORT VERIFICATION
# ============================================================

@dataclass
class TmVerification:
    """Results of Tm sort verification."""
    n_candidates: int
    n_vars: int
    tm_sort_matches_energy: bool
    rank_correlation: float
    top_k_agreement: int
    top_k: int
    min_energy: QuboDnaCandidate
    min_tm: QuboDnaCandidate

    def to_dict(self) -> dict:
        return {
            "n_candidates": self.n_candidates,
            "n_vars": self.n_vars,
            "tm_matches_energy": self.tm_sort_matches_energy,
            "rank_correlation": round(self.rank_correlation, 4),
            "top_k_agreement": self.top_k_agreement,
            "top_k": self.top_k,
            "min_energy": self.min_energy.to_dict(),
            "min_tm": self.min_tm.to_dict(),
        }


def verify_tm_sort(
    Q: List[List[float]],
    n_vars: int,
    n_samples: int = 0,
    seed: int = 42,
    top_k: int = 5,
    bits_per_var: int = 3,
) -> TmVerification:
    """Verify that Tm sort matches energy sort.

    Args:
        Q: QUBO matrix
        n_vars: number of variables
        n_samples: 0 for brute force, else random sampling
        seed: RNG seed
        top_k: number of top candidates to compare
        bits_per_var: bases per variable

    Returns:
        TmVerification with results
    """
    if n_samples == 0 and n_vars <= 12:
        candidates = generate_all_candidates(Q, n_vars, bits_per_var)
    else:
        n_samples = n_samples or 10000
        candidates = generate_random_candidates(Q, n_vars, n_samples, seed, bits_per_var)

    energies = [c.energy for c in candidates]
    tms = [c.tm for c in candidates]
    corr = spearman_rank_correlation(energies, tms)

    energy_sorted = sort_by_energy(candidates)
    if corr < 0:
        tm_sorted = sorted(candidates, key=lambda c: c.tm, reverse=True)
    else:
        tm_sorted = sorted(candidates, key=lambda c: c.tm)

    top_k = min(top_k, len(candidates))
    energy_top = set(tuple(c.x) for c in energy_sorted[:top_k])
    tm_top = set(tuple(c.x) for c in tm_sorted[:top_k])
    agreement = len(energy_top & tm_top)

    return TmVerification(
        n_candidates=len(candidates),
        n_vars=n_vars,
        tm_sort_matches_energy=(energy_sorted[0].x == tm_sorted[0].x),
        rank_correlation=corr,
        top_k_agreement=agreement,
        top_k=top_k,
        min_energy=energy_sorted[0],
        min_tm=tm_sorted[0],
    )


# ============================================================
# §6  SAM / FASTA EXPORT
# ============================================================

def candidates_to_sam(
    candidates: List[QuboDnaCandidate],
    name: str = "qubo_sort",
) -> str:
    """Export candidates to SAM format.

    SAM is a tab-delimited format used in bioinformatics.
    We use optional tags for energy, Tm, and solution vector.

    Args:
        candidates: list of QuboDnaCandidate
        name: sample name

    Returns:
        SAM-formatted string
    """
    lines = [
        "@HD\tVN:1.6\tSO:unsorted",
        f"@PG\tID:{name}\tPN:{name}\tVN:0.1",
    ]
    for i, cand in enumerate(candidates):
        tags = [
            f"TM:f:{cand.tm:.4f}",
            f"EN:f:{cand.energy:.6f}",
            f"GC:f:{cand.gc:.4f}",
            f"XV:Z:{','.join(str(v) for v in cand.x)}",
        ]
        line = (
            f"cand_{i:06d}\t0\t*\t0\t0\t*\t*\t0\t{len(cand.sequence)}\t"
            f"{cand.sequence}\t{'~' * len(cand.sequence)}\t"
            + "\t".join(tags)
        )
        lines.append(line)
    return "\n".join(lines) + "\n"


def candidates_to_fasta(
    candidates: List[QuboDnaCandidate],
    prefix: str = "qubo",
) -> str:
    """Export candidates to FASTA format.

    FASTA is a simple format with header lines (>) followed by sequence.

    Args:
        candidates: list of QuboDnaCandidate
        prefix: prefix for sequence IDs

    Returns:
        FASTA-formatted string
    """
    lines = []
    for i, cand in enumerate(candidates):
        header = f">{prefix}_cand_{i:06d} energy={cand.energy:.6f} tm={cand.tm:.4f}"
        lines.append(header)
        lines.append(cand.sequence)
    return "\n".join(lines) + "\n"


# ============================================================
# §7  DEMO QUBOs
# ============================================================

def demo_max_cut(n: int = 6, seed: int = 42) -> List[List[float]]:
    """Generate a Max-Cut QUBO.

    Max-Cut: E(x) = Σ_{(i,j) in E} (x_i + x_j - 2*x_i*x_j)
    Equivalent to: Q_ii = degree(i), Q_ij = -2 for edges
    """
    rng = random.Random(seed)
    Q = [[0.0] * n for _ in range(n)]

    # Create a random graph (Erdos-Renyi with p=0.5)
    for i in range(n):
        for j in range(i + 1, n):
            if rng.random() < 0.5:
                Q[i][i] += 1.0
                Q[j][j] += 1.0
                Q[i][j] = -2.0
                Q[j][i] = -2.0

    return Q


def demo_number_partition(seed: int = 42) -> List[List[float]]:
    """Generate a small Number Partitioning QUBO.

    NPP: partition numbers into two sets with equal sum.
    Q_ii = a_i^2, Q_ij = 2*a_i*a_j
    """
    rng = random.Random(seed)
    numbers = [rng.randint(1, 20) for _ in range(6)]
    n = len(numbers)
    Q = [[0.0] * n for _ in range(n)]

    for i in range(n):
        Q[i][i] = numbers[i] ** 2
        for j in range(i + 1, n):
            Q[i][j] = 2.0 * numbers[i] * numbers[j]
            Q[j][i] = 2.0 * numbers[i] * numbers[j]

    return Q


# ============================================================
# §8  FULL PIPELINE
# ============================================================

def run_qubo_dna_sort(
    Q: List[List[float]],
    n_vars: int,
    n_samples: int = 0,
    seed: int = 42,
    bits_per_var: int = 3,
) -> dict:
    """Run the full QUBO-DNA sort pipeline.

    Args:
        Q: QUBO matrix
        n_vars: number of variables
        n_samples: 0 for brute force, else sampling
        seed: RNG seed
        bits_per_var: bases per variable

    Returns:
        Summary dict with verification results
    """
    print(f"QUBO-DNA Sort Pipeline")
    print(f"  Variables: {n_vars}")

    if n_samples == 0 and n_vars <= 12:
        candidates = generate_all_candidates(Q, n_vars, bits_per_var)
        print(f"  Brute force: {len(candidates)} candidates")
    else:
        n_samples = n_samples or 10000
        candidates = generate_random_candidates(Q, n_vars, n_samples, seed, bits_per_var)
        print(f"  Random sampling: {len(candidates)} candidates")

    # Calculate correlation to determine sorting direction
    energies = [c.energy for c in candidates]
    tms = [c.tm for c in candidates]
    corr = spearman_rank_correlation(energies, tms)

    # Sort
    energy_sorted = sort_by_energy(candidates)
    if corr < 0:
        tm_sorted = sorted(candidates, key=lambda c: c.tm, reverse=True)
    else:
        tm_sorted = sorted(candidates, key=lambda c: c.tm)

    print(f"\n  Min energy: {energy_sorted[0].energy:.4f}, x={energy_sorted[0].x}")
    print(f"  Min Tm:     {tm_sorted[0].tm:.4f}, x={tm_sorted[0].x}")

    # Verify
    verification = verify_tm_sort(Q, n_vars, n_samples, seed, bits_per_var)
    print(f"\n  Tm sort = Energy sort: {verification.tm_sort_matches_energy}")
    print(f"  Rank correlation: {verification.rank_correlation:.4f}")
    print(f"  Top-{verification.top_k} agreement: {verification.top_k_agreement}/{verification.top_k}")

    return {
        "n_vars": n_vars,
        "n_candidates": len(candidates),
        "energy_optimal": energy_sorted[0].to_dict(),
        "tm_optimal": tm_sorted[0].to_dict(),
        "verification": verification.to_dict(),
    }


# ============================================================
# §9  CLI
# ============================================================

if __name__ == "__main__":
    print("=" * 60)
    print("QUBO-DNA Sort: Naive Tm Encoding")
    print("=" * 60)

    # Demo 1: Diagonal QUBO (ideal case)
    print("\n--- Demo 1: Diagonal QUBO (6 vars) ---")
    n = 6
    Q = [[3.0 if i == j else 0.0 for j in range(n)] for i in range(n)]
    r1 = run_qubo_dna_sort(Q, n)

    # Demo 2: Max-Cut
    print("\n--- Demo 2: Max-Cut (6 vars) ---")
    Q2 = demo_max_cut(6, seed=42)
    r2 = run_qubo_dna_sort(Q2, 6)

    # Demo 3: Ising (negative diagonal)
    print("\n--- Demo 3: Ising Chain (6 vars) ---")
    Q3 = [[-1.0 if i == j else (-0.5 if abs(i-j) == 1 else 0.0) for j in range(6)] for i in range(6)]
    r3 = run_qubo_dna_sort(Q3, 6)

    print("\n" + "=" * 60)
    print("DONE")
    print("=" * 60)
