#!/usr/bin/env python3
"""
Frame Synchronization Prototype for LDPC Optical Communication

Implements syndrome-based frame synchronization for a continuous stream of
LDPC-encoded codewords over a photon-counting optical channel. The receiver
must find the codeword boundary (offset) in the stream before decoding.

Strategy:
  1. Syndrome screening: try all N candidate offsets, compute hard-decision
     syndrome weight. Valid codeword boundaries produce low syndrome weight;
     random offsets produce ~M/2 (~112).
  2. Full decode: for candidates below SCREENING_THRESHOLD, run iterative
     min-sum decoding. If converged, confirm with consecutive frames.
  3. Re-sync: after lock, if offset drifts by a small slip, search locally
     before falling back to full acquisition.

Usage:
    python3 frame_sync.py                  # Quick demo at lam_s=5.0
    python3 frame_sync.py --lam-s 3.0     # Demo at specific SNR
    python3 frame_sync.py --sweep          # Acquisition sweep over SNR
    python3 frame_sync.py --resync-test    # Re-sync robustness test
"""

import numpy as np
import argparse

from ldpc_sim import (
    build_full_h_matrix, ldpc_encode, poisson_channel, quantize_llr,
    decode_layered_min_sum, compute_syndrome_weight,
    N, K, M, Z, N_BASE, M_BASE, H_BASE,
)

# Screening threshold: offsets with syndrome weight below this get full decode.
# Wrong offsets have syndrome weight ~M/2 = 112; correct offsets at decent SNR
# will be much lower. M/4 = 56, using 50 for margin.
SCREENING_THRESHOLD = 50


def generate_stream(H, n_frames, lam_s, lam_b, offset):
    """
    Generate a continuous LLR stream of concatenated LDPC codewords.

    Inserts a random (or specified) offset of noise-like LLRs at the start,
    simulating the receiver not knowing where codeword boundaries are.

    Args:
        H: full parity-check matrix (M x N)
        n_frames: number of codewords to concatenate
        lam_s: signal photons per slot
        lam_b: background photons per slot
        offset: number of noise LLRs prepended (None = random 0..N-1)

    Returns:
        stream_llr: continuous float LLR stream (length = n_frames * N + offset)
        true_offset: the actual offset used
        info_list: list of info-bit arrays for each frame
    """
    if offset is None:
        offset = np.random.randint(0, N)
    true_offset = offset

    # Noise prefix: random LLRs that do not correspond to any codeword
    if offset > 0:
        prefix = np.random.normal(0, 1, offset)
    else:
        prefix = np.array([], dtype=np.float64)

    # Encode and modulate each frame
    frame_llrs = []
    info_list = []
    for _ in range(n_frames):
        info = np.random.randint(0, 2, K).astype(np.int8)
        codeword = ldpc_encode(info, H)
        llr_float, _ = poisson_channel(codeword, lam_s, lam_b)
        frame_llrs.append(llr_float)
        info_list.append(info)

    stream_llr = np.concatenate([prefix] + frame_llrs)
    return stream_llr, true_offset, info_list


def syndrome_screen(stream_llr, n_offsets=N):
    """
    Screen candidate offsets by hard-decision syndrome weight.

    For each candidate offset, extract a 256-sample window from the stream,
    make hard decisions (positive LLR -> 0, negative LLR -> 1), and compute
    the syndrome weight. Correct offsets yield low syndrome weight; random
    offsets yield ~M/2 (~112).

    Args:
        stream_llr: continuous LLR stream (float)
        n_offsets: number of offsets to try (0..n_offsets-1)

    Returns:
        dict mapping offset -> syndrome weight
    """
    scores = {}
    stream_len = len(stream_llr)
    for off in range(n_offsets):
        end = off + N
        if end > stream_len:
            # Not enough data for a full codeword at this offset
            scores[off] = M  # worst case
            continue
        window = stream_llr[off:end]
        # Hard decision: positive LLR -> bit 0, negative -> bit 1
        hard = [1 if v < 0 else 0 for v in window]
        sw = compute_syndrome_weight(hard)
        scores[off] = sw
    return scores


def acquire_sync(stream_llr, H=None, max_confirm=2, max_iter=30):
    """
    Acquire frame synchronization from a continuous LLR stream.

    Strategy:
      1. Syndrome-screen all N candidate offsets
      2. Sort by syndrome weight (best first)
      3. For candidates below SCREENING_THRESHOLD: run full iterative decode
      4. If decode converges: confirm by decoding next max_confirm frames
      5. Return result with cost metrics

    Args:
        stream_llr: continuous LLR stream (float)
        H: parity-check matrix (built if None)
        max_confirm: number of consecutive frames to confirm after first lock
        max_iter: max decoder iterations

    Returns:
        dict with keys:
          locked: bool - whether sync was acquired
          offset: int or None - detected offset
          offsets_screened: int - number of offsets screened
          full_decodes: int - number of full iterative decodes attempted
          screening_cost: float - screening cost in equivalent decode units
          total_equiv_decodes: float - total cost in equivalent decode units
    """
    if H is None:
        H = build_full_h_matrix()

    stream_len = len(stream_llr)

    # Step 1: Syndrome screen all N offsets
    scores = syndrome_screen(stream_llr, n_offsets=N)

    # Step 2: Sort candidates by syndrome weight
    sorted_offsets = sorted(scores.keys(), key=lambda o: scores[o])

    # Screening cost: each screen is ~1/max_iter of a full decode (just hard
    # decisions + syndrome check, no iterative processing)
    screening_cost = N / max_iter  # in equivalent full decodes

    full_decodes = 0
    locked = False
    detected_offset = None

    # Step 3: Try candidates below threshold
    for off in sorted_offsets:
        if scores[off] >= SCREENING_THRESHOLD:
            break  # sorted, so all remaining are worse

        # Full iterative decode at this offset
        end = off + N
        if end > stream_len:
            continue

        window_llr = stream_llr[off:end]
        llr_q = quantize_llr(window_llr)
        decoded, converged, iters, syn_wt = decode_layered_min_sum(
            llr_q, max_iter=max_iter
        )
        full_decodes += 1

        if not converged:
            continue

        # Step 4: Confirm with consecutive frames
        confirmed = True
        for cf in range(1, max_confirm + 1):
            cf_start = off + cf * N
            cf_end = cf_start + N
            if cf_end > stream_len:
                confirmed = False
                break
            cf_window = stream_llr[cf_start:cf_end]
            cf_llr_q = quantize_llr(cf_window)
            cf_decoded, cf_converged, _, _ = decode_layered_min_sum(
                cf_llr_q, max_iter=max_iter
            )
            full_decodes += 1
            if not cf_converged:
                confirmed = False
                break

        if confirmed:
            locked = True
            detected_offset = off
            break

    total_equiv_decodes = screening_cost + full_decodes

    return {
        'locked': locked,
        'offset': detected_offset,
        'offsets_screened': N,
        'full_decodes': full_decodes,
        'screening_cost': screening_cost,
        'total_equiv_decodes': total_equiv_decodes,
    }


def resync_test(stream_llr, true_offset, slip_amount, H=None,
                search_radius=16, max_iter=30):
    """
    Simulate offset slip and attempt re-synchronization.

    After lock is established, the offset may drift by slip_amount. First
    search locally (within +/- search_radius of the slipped offset), then
    fall back to full acquire_sync if local search fails.

    Args:
        stream_llr: continuous LLR stream
        true_offset: the actual correct offset
        slip_amount: how much the offset has drifted
        H: parity-check matrix (built if None)
        search_radius: local search window half-width
        max_iter: max decoder iterations

    Returns:
        dict with keys:
          locked: bool
          offset: int or None
          slip: int - the slip amount tested
          needed_full_search: bool - whether full acquire was needed
    """
    if H is None:
        H = build_full_h_matrix()

    stream_len = len(stream_llr)
    slipped_offset = true_offset + slip_amount

    # Local search: try offsets near the slipped position
    candidates = []
    for delta in range(-search_radius, search_radius + 1):
        candidate = slipped_offset + delta
        if 0 <= candidate and candidate + N <= stream_len:
            candidates.append(candidate)

    # Screen candidates by syndrome weight
    best_sw = M + 1
    best_off = None
    for off in candidates:
        window = stream_llr[off:off + N]
        hard = [1 if v < 0 else 0 for v in window]
        sw = compute_syndrome_weight(hard)
        if sw < best_sw:
            best_sw = sw
            best_off = off

    # Try full decode on best local candidate
    if best_off is not None and best_sw < SCREENING_THRESHOLD:
        window_llr = stream_llr[best_off:best_off + N]
        llr_q = quantize_llr(window_llr)
        decoded, converged, _, _ = decode_layered_min_sum(
            llr_q, max_iter=max_iter
        )
        if converged:
            return {
                'locked': True,
                'offset': best_off,
                'slip': slip_amount,
                'needed_full_search': False,
            }

    # Local search failed; fall back to full acquisition
    result = acquire_sync(stream_llr, H=H, max_iter=max_iter)
    return {
        'locked': result['locked'],
        'offset': result['offset'],
        'slip': slip_amount,
        'needed_full_search': True,
    }


def run_acquisition_sweep(lam_s_values, lam_b=0.1, n_trials=20, n_frames=5):
    """
    Sweep lambda_s values and measure acquisition performance.

    Args:
        lam_s_values: list of signal photon rates to test
        lam_b: background photon rate
        n_trials: trials per SNR point
        n_frames: frames per stream
    """
    H = build_full_h_matrix()
    print(f"{'lam_s':>8s} {'lock_rate':>10s} {'false_lock':>11s} "
          f"{'avg_cost':>10s} {'avg_decodes':>12s}")
    print("-" * 55)

    for lam_s in lam_s_values:
        n_locked = 0
        n_false_lock = 0
        total_cost = 0.0
        total_decodes = 0

        for trial in range(n_trials):
            stream_llr, true_offset, info_list = generate_stream(
                H, n_frames=n_frames, lam_s=lam_s, lam_b=lam_b, offset=None
            )
            result = acquire_sync(stream_llr, H=H)
            total_cost += result['total_equiv_decodes']
            total_decodes += result['full_decodes']

            if result['locked']:
                if result['offset'] == true_offset:
                    n_locked += 1
                else:
                    n_false_lock += 1

        lock_rate = n_locked / n_trials
        false_rate = n_false_lock / n_trials
        avg_cost = total_cost / n_trials
        avg_decodes = total_decodes / n_trials

        print(f"{lam_s:8.1f} {lock_rate:10.2f} {false_rate:11.2f} "
              f"{avg_cost:10.1f} {avg_decodes:12.1f}")


def run_resync_sweep(lam_s=5.0, lam_b=0.1, n_trials=10, n_frames=5):
    """
    Test re-synchronization at various slip amounts.

    Args:
        lam_s: signal photon rate
        lam_b: background photon rate
        n_trials: trials per slip amount
        n_frames: frames per stream
    """
    H = build_full_h_matrix()
    slip_amounts = [1, 2, 4, 8, 16, 32, 64, 128]

    print(f"Re-sync sweep: lam_s={lam_s}, lam_b={lam_b}, n_trials={n_trials}")
    print(f"{'slip':>6s} {'lock_rate':>10s} {'correct':>8s} "
          f"{'full_search':>12s}")
    print("-" * 40)

    for slip in slip_amounts:
        n_locked = 0
        n_correct = 0
        n_full_search = 0

        for trial in range(n_trials):
            stream_llr, true_offset, info_list = generate_stream(
                H, n_frames=n_frames, lam_s=lam_s, lam_b=lam_b, offset=None
            )
            result = resync_test(
                stream_llr, true_offset, slip, H=H
            )
            if result['locked']:
                n_locked += 1
                if result['offset'] == true_offset:
                    n_correct += 1
            if result['needed_full_search']:
                n_full_search += 1

        lock_rate = n_locked / n_trials
        correct_rate = n_correct / n_trials
        full_search_rate = n_full_search / n_trials

        print(f"{slip:6d} {lock_rate:10.2f} {correct_rate:8.2f} "
              f"{full_search_rate:12.2f}")


def main():
    parser = argparse.ArgumentParser(
        description='Frame Synchronization Prototype for LDPC Optical Communication'
    )
    parser.add_argument('--sweep', action='store_true',
                        help='Run acquisition sweep over SNR')
    parser.add_argument('--resync-test', action='store_true',
                        help='Run re-sync robustness test')
    parser.add_argument('--lam-s', type=float, default=5.0,
                        help='Signal photons/slot (default: 5.0)')
    parser.add_argument('--lam-b', type=float, default=0.1,
                        help='Background photons/slot (default: 0.1)')
    parser.add_argument('--n-trials', type=int, default=20,
                        help='Number of trials per test point (default: 20)')
    parser.add_argument('--seed', type=int, default=42,
                        help='Random seed (default: 42)')
    args = parser.parse_args()

    np.random.seed(args.seed)

    if args.sweep:
        print("=== Acquisition Sweep ===")
        lam_s_values = [1.0, 2.0, 3.0, 4.0, 5.0, 7.0, 10.0]
        run_acquisition_sweep(
            lam_s_values, lam_b=args.lam_b, n_trials=args.n_trials
        )

    elif args.resync_test:
        print("=== Re-Sync Robustness Test ===")
        run_resync_sweep(
            lam_s=args.lam_s, lam_b=args.lam_b, n_trials=args.n_trials
        )

    else:
        # Quick demo
        print("=== Frame Sync Demo ===")
        print(f"Code: ({N},{K}), rate {K/N:.3f}, Z={Z}")
        print(f"lam_s={args.lam_s}, lam_b={args.lam_b}")
        print()

        H = build_full_h_matrix()

        # Generate a stream with random offset
        n_frames = 5
        stream_llr, true_offset, info_list = generate_stream(
            H, n_frames=n_frames, lam_s=args.lam_s, lam_b=args.lam_b,
            offset=None
        )
        print(f"Generated stream: {len(stream_llr)} samples, "
              f"{n_frames} frames, true offset={true_offset}")

        # Syndrome screening
        print("\nSyndrome screening (all 256 offsets)...")
        scores = syndrome_screen(stream_llr, n_offsets=N)
        sorted_scores = sorted(scores.items(), key=lambda x: x[1])
        print(f"  Best 5 offsets:")
        for off, sw in sorted_scores[:5]:
            marker = " <-- TRUE" if off == true_offset else ""
            print(f"    offset={off:3d}  syndrome_weight={sw:3d}{marker}")
        wrong_avg = np.mean([
            sw for off, sw in scores.items() if off != true_offset
        ])
        print(f"  Average wrong-offset syndrome weight: {wrong_avg:.1f}")

        # Full acquisition
        print("\nRunning full acquisition...")
        result = acquire_sync(stream_llr, H=H)
        print(f"  Locked: {result['locked']}")
        print(f"  Detected offset: {result['offset']} "
              f"(true: {true_offset}, "
              f"{'CORRECT' if result['offset'] == true_offset else 'WRONG'})")
        print(f"  Full decodes: {result['full_decodes']}")
        print(f"  Screening cost: {result['screening_cost']:.1f} equiv decodes")
        print(f"  Total cost: {result['total_equiv_decodes']:.1f} equiv decodes")

        # Re-sync test at a small slip
        if result['locked']:
            print("\nRe-sync test (slip=4)...")
            resync_result = resync_test(
                stream_llr, true_offset, slip_amount=4, H=H
            )
            print(f"  Locked: {resync_result['locked']}")
            print(f"  Offset: {resync_result['offset']} "
                  f"(true: {true_offset})")
            print(f"  Needed full search: {resync_result['needed_full_search']}")


if __name__ == '__main__':
    main()
