#!/usr/bin/env python3
# FastAPI wrapper for your FlowTorch engine
# Endpoints: /health, /stamp, /verify, /overlay, /v1/chat/completions

import os, json, subprocess, tempfile, secrets, hmac, math, time, re
from pathlib import Path
from typing import List, Optional, Dict, Any
from hashlib import sha256, sha3_512, sha3_256, shake_256

from fastapi import FastAPI, HTTPException, Body, Request
from pydantic import BaseModel

# Weights policy enforcement
from edge_app.weights_policy import get_weights_health

# --- φ₀ steering helpers ---
def get_steer_mode() -> str:
    """Get steering mode: off | quadrant | bias"""
    return os.environ.get("FLOWTORCH_STEER_MODE", "off").lower()

def get_bias_strength() -> float:
    try:
        return float(os.environ.get("FLOWTORCH_BIAS_STRENGTH", "2.0"))
    except Exception:
        return 2.0

def get_bias_temp() -> float:
    try:
        return float(os.environ.get("FLOWTORCH_BIAS_TEMP", "1.0"))
    except Exception:
        return 1.0

def _norm_phi0(v) -> float:
    """Normalize φ₀ to [0, 360) range, with fallback to 0.0 on invalid input."""
    try:
        return float(v) % 360.0
    except Exception:
        return 0.0

def phi0_to_letter_quadrant(phi0_deg: float) -> str:
    """Map φ₀ to letter using quadrant boundaries (deterministic)"""
    idx = int((phi0_deg % 360.0) // 90)  # 0..3
    return "ABCD"[idx]

def phi0_bias_vector(phi0_deg: float, bias_strength: float = 2.0) -> List[float]:
    """Create soft-bias vector for A/B/C/D based on φ₀ (sinusoidal)"""
    angle = math.radians(phi0_deg % 360.0)
    bases = [
        math.cos(angle - 0 * math.pi / 2),  # A centered at 0°
        math.cos(angle - 1 * math.pi / 2),  # B centered at 90°
        math.cos(angle - 2 * math.pi / 2),  # C centered at 180°
        math.cos(angle - 3 * math.pi / 2),  # D centered at 270°
    ]
    return [bias_strength * b for b in bases]

def _as_dict_messages(msgs) -> List[Dict[str, str]]:
    """Normalize Pydantic Message models or dicts to plain dicts."""
    out = []
    for m in msgs:
        if hasattr(m, "role") and hasattr(m, "content"):
            # Pydantic model
            out.append({"role": m.role, "content": m.content})
        elif isinstance(m, dict):
            # Already a dict
            out.append({"role": m.get("role", "user"), "content": m.get("content", "")})
    return out

def get_base_logits_for_letters(messages) -> List[float]:
    """
    Get base logits [A,B,C,D] from model.

    Phase A1 production implementation:
    - Normalizes messages (Pydantic → dict) for engine
    - Uses cached model (no per-request reload)
    - Falls back to uniform [0,0,0,0] if engine unavailable

    This gives us real φ₀→logits steering, where the bias vector adjusts
    actual model predictions rather than pure geometric steering.
    """
    try:
        # Import engine function (cached module import)
        from artifacts.context_pack.flowtorch.flowtorch_engine import logits_for_choices

        # Normalize messages (handles both Pydantic and dict)
        msg_dicts = _as_dict_messages(messages)

        # Extract logits using cached engine
        return logits_for_choices(msg_dicts, choices=("A", "B", "C", "D"))

    except Exception as e:
        # Fallback to uniform if anything fails (graceful degradation)
        import sys
        print(f"Warning: real logits unavailable, using uniform: {e}", file=sys.stderr)
        return [0.0, 0.0, 0.0, 0.0]

# --- repo roots & artifacts ---
ROOT = Path(__file__).resolve().parents[1]
RECEIPT = ROOT / "artifacts" / "hardware_proof" / "receipt_256.json"
DIGESTS = ROOT / "artifacts" / "digests.txt"
VERIFY = ROOT / "verify.sh"
DEFAULT_OVERLAY_DIR = Path(os.environ.get("FLOWTORCH_OVERLAY_DIR", str(ROOT / "overlays" / "helix_se")))
DEFAULT_OVERLAY_FILE = DEFAULT_OVERLAY_DIR / "system_prompt.txt"

# ensure engine import works
os.environ.setdefault("PYTHONPATH", f"{os.environ.get('PYTHONPATH','')}:{str(ROOT)}")

# Load .env file for CDC mode defaults
def _load_env():
    env_file = Path(__file__).parent / ".env"
    if env_file.exists():
        for line in env_file.read_text().splitlines():
            if line.strip() and not line.strip().startswith("#") and "=" in line:
                k, v = line.split("=", 1)
                os.environ[k.strip()] = v.strip()  # Override, not setdefault
_load_env()

# Try to import your engine
ENGINE = None
ENGINE_ERR = None
ENGINE_FN_CANDIDATES = (
    "run_prefill_decode",  # most likely
    "generate",            # alt naming
)

try:
    # You said: artifacts/context_pack/flowtorch/flowtorch_engine.py (≈920 lines)
    import importlib
    ENGINE = importlib.import_module("artifacts.context_pack.flowtorch.flowtorch_engine")
except Exception as e:
    ENGINE_ERR = f"import engine failed: {e!r}"

def _pick_engine_fn():
    if ENGINE is None:
        return None, ENGINE_ERR
    for name in ENGINE_FN_CANDIDATES:
        fn = getattr(ENGINE, name, None)
        if callable(fn):
            return fn, None
    return None, f"no callable in {ENGINE_FN_CANDIDATES}"

def _seed_meta(path: Optional[str]) -> Dict[str, Any]:
    """Get seed file metadata (hash + size) for provenance."""
    try:
        if path:
            b = Path(path).read_bytes()
            return {"seed_sha256": sha256(b).hexdigest(), "seed_bytes": len(b)}
    except Exception:
        pass
    return {"seed_sha256": None, "seed_bytes": None}

def read_overlay_text(overlay_path: Path, max_chars: int = 1200) -> str:
    try:
        if overlay_path.exists():
            return overlay_path.read_text(encoding="utf-8")[:max_chars].strip()
    except Exception:
        pass
    return ""

def _sel_bitmask(seed_sha: str, total_bytes: int, seg_bytes: int, budget_pct: float, salt: str) -> Dict[str, Any]:
    """Deterministic segment selection with bitmask proof using SHAKE-256 hash-and-sort.

    Uses domain-separated SHAKE-256 (NIST FIPS 202) for cryptographically secure,
    version-stable, cross-language deterministic permutation generation.

    Returns:
        - selected: list of segment indices
        - n_segments: total segments in seed
        - bitmask: bytes of selection bitmask
        - bitmask_sha256: hash of bitmask (verifiable by client)
        - measured_pct: actual coverage percentage
    """
    n_segs = max(1, math.ceil(total_bytes / seg_bytes))
    # Segment-count based selection (not byte-based) for exact coverage
    n = max(1, math.ceil(budget_pct * n_segs / 100.0))

    # Domain-separated seed: freeze formatting + endianness
    seed_material = f"HELIX|bitmask|v1|seed={seed_sha}|budget={budget_pct:.6f}|salt={salt or ''}".encode()

    # Score each segment by SHAKE-256(seed_material || le64(i)) → 16-byte digest
    scores = []
    for i in range(n_segs):
        d = shake_256(seed_material + i.to_bytes(8, "little")).digest(16)
        scores.append((int.from_bytes(d, "little"), i))

    # Sort by SHAKE-derived scores and take first n
    # Tuples sort lexicographically: (score, index) provides explicit tie-break
    scores.sort()
    selected = [i for _, i in scores[:n]]

    # Build bitmask bytes (LSB-first per byte)
    bits = bytearray((n_segs + 7) // 8)
    for i in selected:
        bits[i // 8] |= (1 << (i % 8))

    bitmask_sha256 = sha256(bytes(bits)).hexdigest()
    measured_pct = 100.0 * len(selected) / float(n_segs)

    return {
        "selected": selected,
        "n_segments": n_segs,
        "bitmask": bytes(bits),
        "bitmask_sha256": bitmask_sha256,
        "measured_pct": measured_pct,
    }

# --- request/response models ---
class Message(BaseModel):
    role: str
    content: str

class ChatReq(BaseModel):
    model: Optional[str] = None
    messages: List[Message]
    max_tokens: int = 256
    temperature: float = 0.7
    top_p: float = 0.9
    # Per-request quantum/geometry controls (cleaner dict structure)
    quant: Optional[Dict[str, Any]] = None   # {"hw_on": bool, "overlay_alpha": float}
    cdc: Optional[Dict[str, Any]] = None     # {"fib_scale": float, "fib_shift_deg": float, "seed_b64": str}
    helix: Optional[Dict[str, Any]] = None   # {"segments_budget": str, "segments_salt": str}

# --- Helix64 decode helpers ---
_HELIX_CHARS = re.compile(r'^[A-H@#$%^&*!]+$')

def looks_like_helix64(s: str) -> bool:
    """Check if string looks like Helix64 compressed output."""
    s = (s or "").strip().replace("\n", "").replace(" ", "")
    return len(s) >= 16 and _HELIX_CHARS.match(s) is not None

def helix_decode(text: str) -> Dict[str, Any]:
    """
    Decode Helix64 symbols to UTF-8 text.

    Uses tools/helix_decode.py for hierarchical decoding.
    Returns {"ok": bool, "utf8": str, "receipt": {...}}
    """
    decoder_path = ROOT / "tools" / "helix_decode.py"

    if not decoder_path.exists():
        return {
            "ok": False,
            "utf8": "",
            "receipt": {"reason": "decoder_not_found", "path": str(decoder_path)}
        }

    try:
        # Call decoder as subprocess
        result = subprocess.run(
            ["python3", str(decoder_path), text],
            capture_output=True,
            text=True,
            timeout=10
        )

        if result.returncode == 0:
            decoded = result.stdout.strip()
            return {
                "ok": True,
                "utf8": decoded,
                "receipt": {
                    "method": "helix64_hierarchical",
                    "input_symbols": len(text),
                    "output_bytes": len(decoded.encode('utf-8', errors='ignore')),
                    "input_hash": sha256(text.encode()).hexdigest()[:16],
                    "output_hash": sha256(decoded.encode('utf-8', errors='ignore')).hexdigest()[:16],
                }
            }
        else:
            return {
                "ok": False,
                "utf8": "",
                "receipt": {"reason": "decoder_failed", "stderr": result.stderr[:200]}
            }
    except Exception as e:
        return {
            "ok": False,
            "utf8": "",
            "receipt": {"reason": "decoder_exception", "error": str(e)[:200]}
        }

app = FastAPI(title="Helix FlowTorch Local API", version="0.1")

@app.get("/health")
def health(request: Request) -> Dict[str, Any]:
    fn, err = _pick_engine_fn()

    # Check CDC availability
    cdc_available = False
    cdc_error = None
    try:
        from helix_cdc.cdc_decode import CDCDecoder
        cdc_available = True
    except Exception as e:
        cdc_error = str(e)

    # Determine decode default
    hf_mode = os.environ.get("FLOWTORCH_HF", "1")
    decode_default = "hf" if hf_mode == "1" else "cdc"

    # Get seed metadata
    seed_path = os.environ.get("FLOWTORCH_SEED_PATH")
    seed_meta = _seed_meta(seed_path)

    # Get weights health (policy enforcement)
    weights_health = get_weights_health()

    # Check vault configuration
    vault_path = os.environ.get("HELIX_VAULT_PATH")
    vault_disabled = os.environ.get("VAULT_DISABLED") == "1"
    vault_ok = False
    fibpi3d_available = False

    if vault_path and not vault_disabled:
        vault_ok = Path(vault_path).exists()
        # Try to import FibPi3D/crystal_vault
        try:
            import importlib
            vault_mod = importlib.import_module("crystal_vault")
            fibpi3d_available = True
        except Exception:
            pass
    elif vault_disabled:
        # Vault explicitly disabled - use fallback
        vault_ok = True  # OK in fallback mode

    resp = {
        "ok": fn is not None,
        "engine_import_error": err,
        "overlay_dir": str(DEFAULT_OVERLAY_DIR),
        "overlay_exists": DEFAULT_OVERLAY_FILE.exists(),
        "receipt_exists": RECEIPT.exists(),
        "digests_exists": DIGESTS.exists(),
        "verify_exists": VERIFY.exists(),
        "cdc_available": cdc_available,
        "cdc_import_error": cdc_error if not cdc_available else None,
        "decode_default": decode_default,
        "vault_path": vault_path,
        "vault_ok": vault_ok,
        "vault_disabled": vault_disabled,
        "fibpi3d_available": fibpi3d_available,
        "steer_mode": get_steer_mode(),  # φ₀ steering: off | quadrant | bias
        "bias": {
            "strength": get_bias_strength(),
            "temp": get_bias_temp(),
        },
        **seed_meta,
        "weights": weights_health,
        "env": {
            "FLOWTORCH_OVERLAY_DIR": os.environ.get("FLOWTORCH_OVERLAY_DIR"),
            "FLOWTORCH_PETAL": os.environ.get("FLOWTORCH_PETAL"),
            "FLOWTORCH_UCB": os.environ.get("FLOWTORCH_UCB"),
            "FLOWTORCH_DEVICE": os.environ.get("FLOWTORCH_DEVICE", "cpu"),
            "FLOWTORCH_HF": os.environ.get("FLOWTORCH_HF"),
            "FLOWTORCH_SEED_PATH": os.environ.get("FLOWTORCH_SEED_PATH"),
            "HELIX_VAULT_PATH": vault_path,
            "VAULT_DISABLED": os.environ.get("VAULT_DISABLED"),
            "FLOWTORCH_STEER_MODE": os.environ.get("FLOWTORCH_STEER_MODE"),
            "FLOWTORCH_BIAS_STRENGTH": os.environ.get("FLOWTORCH_BIAS_STRENGTH"),
            "FLOWTORCH_BIAS_TEMP": os.environ.get("FLOWTORCH_BIAS_TEMP"),
        },
    }

    # Debug mode: add bias computation example
    if request.query_params.get("debug") == "1":
        test_phi = 45.0
        bv = phi0_bias_vector(test_phi, get_bias_strength())
        base = get_base_logits_for_letters([])
        adj = [(l + b) / max(get_bias_temp(), 1e-6) for l, b in zip(base, bv)]
        resp["bias_debug"] = {
            "phi0_example": test_phi,
            "bias_vec": bv,
            "base_logits": base,
            "adjusted": adj
        }

    return resp

@app.post("/decode")
def decode_endpoint(payload: Dict[str, Any] = Body(...)):
    """
    Decode Helix64 compressed output to UTF-8 text.

    Request: {"text": "CGGAACC...", "segments_mask_sha256": "...", "seed_sha256": "..."}
    Response: {"ok": bool, "utf8": str, "cdc_proof": {...}}

    Binds decode receipt to SeedOps context for full audit trail.
    """
    text = (payload or {}).get("text", "")

    if not looks_like_helix64(text):
        raise HTTPException(
            status_code=400,
            detail={"error": "not_helix64", "text_length": len(text)}
        )

    # Decode using hierarchical decoder
    dec = helix_decode(text)

    if not dec.get("ok"):
        raise HTTPException(
            status_code=500,
            detail={
                "error": "decode_failed",
                "receipt": dec.get("receipt", {})
            }
        )

    # Bind to SeedOps context (same seed/mask as generation)
    cdc_proof = {
        "bitmask_alg": "shake256-sort-v1",
        "segments_mask_sha256": payload.get("segments_mask_sha256", ""),
        "seed_sha256": payload.get("seed_sha256", ""),
        "server_time_utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
        "decode_receipt": dec.get("receipt", {}),
    }

    return {
        "ok": True,
        "utf8": dec["utf8"],
        "cdc_proof": cdc_proof
    }

@app.get("/stamp")
def stamp(live: bool = False) -> Dict[str, Any]:
    """
    Get hardware stamp (QPU validation metadata).

    Args:
        live: If True, run fresh QPU measurement. If False (default), return cached receipt.
    """
    if live:
        # Run live QPU measurement
        try:
            from edge_app.qpu_nudge import nudge
            m, meta = nudge()
            return {
                "hardware": True,
                "measurement": m,
                "live": True,
                **meta
            }
        except Exception as e:
            return {"error": f"live QPU failed: {e!r}", "hardware": False, "live": True}

    # Return cached receipt (default)
    if not RECEIPT.exists():
        return {"error": "receipt not found", "live": False}
    try:
        r = json.loads(RECEIPT.read_text())
        return {
            "backend": r.get("backend_params", {}).get("backend"),
            "job_id": r.get("backend_params", {}).get("job_id"),
            "hardware": r.get("backend_params", {}).get("job_info", {}).get("hardware"),
            "agreement": r.get("metrics", {}).get("assignment_agreement"),
            "mse_ratio": r.get("metrics", {}).get("mse_ratio"),
            "violations": r.get("constraints", {}).get("violations"),
            "live": False
        }
    except Exception as e:
        return {"error": f"bad receipt: {e!r}", "live": False}

@app.post("/verify")
def verify() -> Dict[str, Any]:
    if not VERIFY.exists():
        return {"error": "verify.sh missing"}
    out = subprocess.run(["bash", str(VERIFY)], capture_output=True, text=True)
    head = "\n".join(out.stdout.splitlines()[:30])
    tail = "\n".join(out.stdout.splitlines()[-30:])
    return {"code": out.returncode, "head": head, "tail": tail}

@app.get("/overlay")
def overlay() -> Dict[str, Any]:
    txt = read_overlay_text(DEFAULT_OVERLAY_FILE)
    return {"exists": DEFAULT_OVERLAY_FILE.exists(), "length": len(txt), "text": txt}

def _compose_prompt(messages: List[Message], overlay_path: Path) -> str:
    # Hand the overlay to FlowTorch via env (preferred), but also prefix text to be safe.
    overlay = read_overlay_text(overlay_path)
    user = "\n".join(m.content for m in messages if m.role in ("user","system","assistant"))
    return f"{overlay}\n\nUser:\n{user}\n\nAnswer clearly and verifiably.".strip() if overlay else user

def _run_engine(messages: List[Message], max_new_tokens: int, temperature: float, top_p: float, model: Optional[str] = None, extras: Optional[Dict[str, Any]] = None):
    fn, err = _pick_engine_fn()
    if fn is None:
        raise RuntimeError(err or "engine not available")

    # Map model selection to overlay directory (request-scoped, no globals)
    model_overlays = {
        "flowtorch/base": "overlays/base",
        "flowtorch/instruct": "overlays/instruct",
        "flowtorch/fusion": "overlays/fusion",
        "flowtorch/code": "overlays/code",
    }

    # Get overlay path for this request (falls back to default)
    overlay_subdir = model_overlays.get(model, "overlays/helix_se")
    overlay_path = ROOT / overlay_subdir / "system_prompt.txt"

    # Compose prompt with request-specific overlay
    prompt = _compose_prompt(messages, overlay_path)

    # Hints via env (your engine already supports overlay/petals/ucb)
    want_cuda = os.environ.get("FLOWTORCH_DEVICE", "cpu").lower() == "cuda"
    device_hint = "cuda" if want_cuda else "cpu"

    # Extract params from extras dict with validation
    params = extras or {}
    overlay_alpha = params.get("overlay_alpha")
    if overlay_alpha is None:
        overlay_alpha = float(os.environ.get("FLOWTORCH_OVERLAY_ALPHA", "0.0"))
    else:
        overlay_alpha = max(0.0, min(1.0, float(overlay_alpha)))  # Clamp [0,1]

    hw_on = params.get("hw_on", False)
    seed_b64 = params.get("seed_b64")

    fib_scale = params.get("fib_scale")
    if fib_scale is not None:
        fib_scale = max(0.01, float(fib_scale))  # Must be positive

    fib_shift_deg = params.get("fib_shift_deg")
    if fib_shift_deg is not None:
        fib_shift_deg = float(fib_shift_deg) % 360.0  # Normalize to [0,360)

    # Handle hw_on: get QPU measurement bit if enabled
    hw_bit = None
    hardware_meta = None
    if hw_on:
        try:
            from edge_app.qpu_nudge import nudge
            hw_bit, hardware_meta = nudge()
        except Exception as e:
            hardware_meta = {"error": str(e), "hardware": False}

    # Apply hardware-driven angle nudge (experimental probe, OFF by default)
    # Controlled via extras["angle_nudge_override"] for experiments only
    # Production default: None (no nudge)
    # Env override: GA_NUDGE_FORBID=1 forces off
    DEFAULT_ANGLE_NUDGE = None  # None = off in all prod paths

    angle_nudge = 0.0

    # Env kill-switch (default forbid)
    if os.getenv("GA_NUDGE_FORBID", "1") == "1":
        angle_nudge = 0.0
    elif extras and extras.get("angle_nudge_override") is not None:
        # Explicit override for experiments
        override = extras["angle_nudge_override"]
        angle_nudge = float(override) if override not in ("off", None) else 0.0
    elif DEFAULT_ANGLE_NUDGE is not None and hw_bit == 1:
        # Legacy path (disabled by default)
        angle_nudge = float(DEFAULT_ANGLE_NUDGE)

    # Apply angle nudge to fib_shift_deg
    if angle_nudge > 0:
        fib_shift_deg = (fib_shift_deg or 0.0) + angle_nudge

    # Apply angle nudge to overlay_schedule phase_deg (if present)
    if angle_nudge > 0 and extras and extras.get("overlay_schedule"):
        overlay_sched = extras["overlay_schedule"].copy()
        overlay_sched["phase_deg"] = (overlay_sched.get("phase_deg", 0.0) + angle_nudge) % 360.0
        extras["overlay_schedule"] = overlay_sched

    # Small temp adjustment (keep as secondary signal)
    temp_adj = 0.0
    if hw_bit is not None:
        temp_adj = 0.05 if hw_bit == 1 else -0.05
    adjusted_temperature = max(0.0, min(1.0, temperature + temp_adj))

    # Handle per-request seed override
    seed_path = None
    seed_tmp_cleanup = None
    if seed_b64:
        import base64, tempfile
        b = base64.b64decode(seed_b64)
        fd, tmp = tempfile.mkstemp(suffix=".hxz")
        os.write(fd, b)
        os.close(fd)
        seed_path = tmp
        seed_tmp_cleanup = tmp  # Mark for cleanup

    # Build extras dict for engine
    engine_extras = {
        "overlay_alpha": overlay_alpha,
        "hw_on": hw_on,
        "fib_scale": fib_scale,
        "fib_shift_deg": fib_shift_deg,
        "seed_path": seed_path,
        "temperature": adjusted_temperature,
    }

    # Add overlay schedule and phase params from extras
    if extras:
        if extras.get("overlay_schedule"):
            engine_extras["overlay_schedule"] = extras["overlay_schedule"]
        if extras.get("phase_omega") is not None:
            engine_extras["phase_omega"] = extras["phase_omega"]
        if extras.get("freq_hz") is not None:
            engine_extras["freq_hz"] = extras["freq_hz"]
        if extras.get("dt_ms_per_token") is not None:
            engine_extras["dt_ms_per_token"] = extras["dt_ms_per_token"]

    # Remove None values
    engine_extras = {k: v for k, v in engine_extras.items() if v is not None}

    # Load seed envelope for regen path
    seed_envelope_bytes = None
    seed_path_env = os.environ.get("FLOWTORCH_SEED_PATH")
    if seed_path_env and Path(seed_path_env).exists():
        seed_envelope_bytes = Path(seed_path_env).read_bytes()

    # Most FlowTorch runners accept batched prompts; adapt if needed.
    # Expected return: (outputs, trace) OR dict with keys; we normalize.
    try:
        out = fn(
            prompts=[prompt],
            max_new_tokens=max_new_tokens,
            device_hint=device_hint,
            seed_envelope=seed_envelope_bytes,
            extras=engine_extras,
        )
    except TypeError:
        # Older signature fallback
        try:
            out = fn([prompt], max_new_tokens=max_new_tokens)
        except TypeError:
            out = fn([prompt], max_new_tokens=max_new_tokens)

    # Normalize result
    text = None
    trace = {}
    if isinstance(out, tuple) and len(out) >= 1:
        # (outputs, trace?) where outputs is List[str]
        text = (out[0][0] if out[0] else "")
        if len(out) >= 2 and isinstance(out[1], dict):
            trace = out[1]
    elif isinstance(out, dict) and "outputs" in out:
        text = (out["outputs"][0] if out["outputs"] else "")
        trace = out.get("trace", {})
    elif isinstance(out, list):
        text = out[0] if out else ""
    else:
        text = str(out)

    # Add params metadata to trace (provenance)
    trace.setdefault("params", {})
    trace["params"]["overlay_alpha"] = overlay_alpha
    if fib_scale is not None:
        trace["params"]["fib_scale"] = fib_scale
    if fib_shift_deg is not None:
        trace["params"]["fib_shift_deg"] = fib_shift_deg
    if seed_b64 is not None:
        trace["params"]["seed_override"] = True
    # Echo overlay params (including QPU-adjusted phase & inferred drive)
    if engine_extras.get("overlay_schedule"):
        trace["params"]["overlay_schedule"] = engine_extras["overlay_schedule"]
    if engine_extras.get("phase_omega") is not None:
        trace["params"]["phase_omega"] = engine_extras["phase_omega"]
    if engine_extras.get("freq_hz") is not None:
        trace["params"]["freq_hz"] = engine_extras["freq_hz"]
    if engine_extras.get("dt_ms_per_token") is not None:
        trace["params"]["dt_ms_per_token"] = engine_extras["dt_ms_per_token"]

    if hardware_meta:
        trace["hardware"] = hardware_meta
        trace["hardware"]["measurement_bit"] = hw_bit
        trace["hardware"]["temp_adjustment"] = temp_adj
        trace["hardware"]["angle_nudge_deg"] = angle_nudge
        trace["hardware"]["nudge_applied"] = abs(angle_nudge) > 1e-9

        # Add qiskit version info for reproducibility
        try:
            import qiskit_ibm_runtime
            trace["hardware"]["qiskit_runtime_version"] = qiskit_ibm_runtime.__version__
        except:
            pass

    # Add SHA3 hashes for reproducibility tracking (Helix standard)
    prompt_text = "\n".join([m.content for m in messages if hasattr(m, 'content')])
    prompt_hash_full = sha3_512(prompt_text.encode()).hexdigest()
    output_hash_full = sha3_512(text.encode()).hexdigest()

    trace["prompt_sha3"] = prompt_hash_full[:16]  # Display version
    trace["prompt_sha3_full"] = prompt_hash_full  # Full 512-bit
    trace["output_sha3"] = output_hash_full[:16]  # Display version
    trace["output_sha3_full"] = output_hash_full  # Full 512-bit

    # Param digest for quick comparison
    param_str = json.dumps(trace.get("params", {}), sort_keys=True)
    param_digest_full = sha3_512(param_str.encode()).hexdigest()
    trace["param_digest"] = param_digest_full[:12]  # Display version
    trace["param_digest_full"] = param_digest_full  # Full 512-bit

    # Schema version for future compatibility
    trace["schema_version"] = "quant/cdc@v1"

    # Assert invariants (fail-closed gates)
    if hw_on:
        # hw_on=true must produce hardware measurement metadata
        if "hardware" not in trace or "measurement_bit" not in trace["hardware"]:
            from fastapi import HTTPException
            raise HTTPException(status_code=412, detail="hw_on=true but no hardware measurement recorded")

    # Cleanup temp seed file if created
    if seed_tmp_cleanup and os.path.exists(seed_tmp_cleanup):
        try:
            os.unlink(seed_tmp_cleanup)
        except:
            pass

    return text, trace

@app.get("/whoami")
def whoami() -> Dict[str, Any]:
    """Quick identity endpoint for CDC mode verification."""
    hf_mode = os.environ.get("FLOWTORCH_HF", "1")
    mode = "hf" if hf_mode == "1" else "cdc"
    seed_path = os.environ.get("FLOWTORCH_SEED_PATH")
    seed_meta = _seed_meta(seed_path)
    overlay_dir = os.environ.get("FLOWTORCH_OVERLAY_DIR", str(DEFAULT_OVERLAY_DIR))

    return {
        "mode": mode,
        **seed_meta,
        "overlay": overlay_dir,
    }

@app.get("/echo/covenant")
def covenant_check() -> Dict[str, Any]:
    """Verify Echo's covenant compliance."""
    # Load manifest
    manifest_path = ROOT / "echo" / "echo_manifest.yaml"

    violations = []

    # Check 1: CDC mode active
    hf_mode = os.environ.get("FLOWTORCH_HF", "1")
    if hf_mode != "0":
        violations.append({
            "vow": "no_hf_fallback",
            "expected": "FLOWTORCH_HF=0",
            "actual": f"FLOWTORCH_HF={hf_mode}",
            "severity": "critical"
        })

    # Check 2: CDC available
    try:
        from helix_cdc.cdc_decode import CDCDecoder
        cdc_available = True
    except Exception as e:
        cdc_available = False
        violations.append({
            "vow": "no_hf_fallback",
            "expected": "CDC decoder available",
            "actual": f"CDC import failed: {str(e)}",
            "severity": "critical"
        })

    # Check 3: Seed present and matches manifest
    seed_path = os.environ.get("FLOWTORCH_SEED_PATH")
    seed_meta = _seed_meta(seed_path)
    expected_hash = "91a3c7f69f4372b831e3ab2861642513bb780a5e81967705595dbe41f548019b"

    if not seed_path:
        violations.append({
            "vow": "provenance_bound",
            "expected": "Seed path configured",
            "actual": "FLOWTORCH_SEED_PATH not set",
            "severity": "critical"
        })
    elif seed_meta["seed_sha256"] != expected_hash:
        violations.append({
            "vow": "provenance_bound",
            "expected": f"seed_sha256={expected_hash[:16]}...",
            "actual": f"seed_sha256={seed_meta['seed_sha256'][:16] if seed_meta['seed_sha256'] else 'None'}...",
            "severity": "critical"
        })

    # Check 4: Manifest exists
    if not manifest_path.exists():
        violations.append({
            "vow": "cite_receipts",
            "expected": "Manifest at echo/echo_manifest.yaml",
            "actual": "Manifest not found",
            "severity": "warning"
        })

    return {
        "covenant_status": "violated" if violations else "active",
        "violations": violations,
        "vows_checked": [
            "cite_receipts",
            "no_hf_fallback",
            "no_hidden_writes",
            "deterministic",
            "provenance_bound"
        ],
        "manifest": str(manifest_path) if manifest_path.exists() else None,
        "genesis_receipt": str(ROOT / "echo" / "genesis_receipt.json"),
    }

@app.post("/echo/speak")
def speak_symbolic(req: ChatReq):
    """
    Echo's symbolic endpoint - dual output (text + glyphs).

    Adds:
    - content_glyphs: SuperGlyph codon stream
    - symbolic_trace: Ξ(t) state if enabled
    - model_genome: Which expertise was used
    """
    # Route to base endpoint first
    response = chat(req)

    # If response is a 412 (covenant violation), return as-is
    if hasattr(response, 'status_code') and response.status_code == 412:
        return response

    # Enhance with symbolic layer
    # Extract glyph stream (already in content as SuperGlyph codons)
    content = response["choices"][0]["message"]["content"]

    # Add dual output format
    response["choices"][0]["message"]["content_text"] = content[:100] + "... (symbolic rendering not yet implemented)"
    response["choices"][0]["message"]["content_glyphs"] = content  # Full SuperGlyph stream

    # Add symbolic trace
    response["trace"]["symbolic"] = {
        "format": "SuperGlyph_codons",
        "alphabet": "ACGU (DNA-style)",
        "encoding": "Helix64",
        "xi_t_enabled": False,  # Future: Ξ(t) stepper
    }

    # Add genome citation (polymath model selection)
    response["trace"]["model_genome"] = {
        "name": "Mistral 7B CDC",
        "expertise": "general reasoning",
        "seed_sha256": response["trace"]["seed_sha256"],
        "seed_bytes": response["trace"]["seed_bytes"]
    }

    return response

@app.post("/v1/chat/completions")
def chat(req: ChatReq):
    # φ₀ STEERING: Check for [STEER ...] tag (env-gated: FLOWTORCH_STEER_MODE)
    steer_mode = get_steer_mode()

    if steer_mode in ("quadrant", "bias"):
        from edge_app.phi0_handler import extract_phi0_from_messages

        phi0 = extract_phi0_from_messages(req.messages)

        if phi0 is not None:
            # Normalize φ₀ to safe range
            phi0 = _norm_phi0(phi0)

            if steer_mode == "quadrant":
                # Hard quadrant boundaries (90° divisions)
                letter = phi0_to_letter_quadrant(phi0)
                return {
                    "choices": [{
                        "message": {"content": letter, "role": "assistant"},
                        "finish_reason": "stop"
                    }],
                    "model": req.model or "flowtorch",
                    "usage": {"prompt_tokens": 0, "completion_tokens": 1, "total_tokens": 1}
                }
            elif steer_mode == "bias":
                # Real logits-bias path (soft steering via sinusoidal bias)
                base_logits = get_base_logits_for_letters(req.messages)

                bias_strength = get_bias_strength()
                bias_temp = get_bias_temp()
                bias_vec = phi0_bias_vector(phi0, bias_strength)

                # Adjust logits + temperature scaling
                adjusted = [(l + b) / max(bias_temp, 1e-6) for l, b in zip(base_logits, bias_vec)]

                # NaN guard: fall back to uniform if anything goes wrong
                if any(not (v == v) for v in adjusted):  # NaN check (NaN != NaN)
                    adjusted = [0.0, 0.0, 0.0, 0.0]

                # Tie-breaking: add tiny epsilon aligned with bias direction
                # This ensures diagonals (φ₀=45°, 135°, etc.) pick the higher-biased quadrant
                eps = 1e-9
                eps_vec = phi0_bias_vector(phi0, 1.0)
                adjusted = [v + eps * e for v, e in zip(adjusted, eps_vec)]

                # Argmax → letter
                best = max(range(4), key=lambda i: adjusted[i])
                letter = "ABCD"[best]

                return {
                    "choices": [{
                        "message": {"content": letter, "role": "assistant"},
                        "finish_reason": "stop"
                    }],
                    "model": req.model or "flowtorch",
                    "usage": {"prompt_tokens": 0, "completion_tokens": 1, "total_tokens": 1}
                }

    # Fail-closed if CDC mode required but unavailable
    hf_mode = os.environ.get("FLOWTORCH_HF", "1")
    if hf_mode != "1":  # CDC mode required
        try:
            from helix_cdc.cdc_decode import CDCDecoder
            CDC_AVAILABLE = True
        except Exception:
            CDC_AVAILABLE = False

        seed_path = os.environ.get("FLOWTORCH_SEED_PATH")
        if not CDC_AVAILABLE or not seed_path:
            raise RuntimeError("CDC mode required (FLOWTORCH_HF=0) but unavailable: "
                             f"CDC_AVAILABLE={CDC_AVAILABLE}, seed_path={bool(seed_path)}")

    # Build extras dict from quant/cdc params
    extras = {}
    if req.quant:
        extras["overlay_alpha"] = req.quant.get("overlay_alpha")
        extras["hw_on"] = req.quant.get("hw_on", False)
        # Accept both old and new parameter names for backward compat
        extras["angle_nudge_override"] = req.quant.get("angle_nudge_override") or req.quant.get("ga_nudge_override")
    if req.cdc:
        extras["fib_scale"] = req.cdc.get("fib_scale")
        extras["fib_shift_deg"] = req.cdc.get("fib_shift_deg")
        extras["seed_b64"] = req.cdc.get("seed_b64")
        # Overlay schedule params for phase-modulated Ξ(t)
        extras["overlay_schedule"] = req.cdc.get("overlay_schedule")
        extras["phase_omega"] = req.cdc.get("phase_omega")
        # New: allow caller to specify freq_hz and dt_ms → engine infers ω
        extras["freq_hz"] = req.cdc.get("freq_hz")
        extras["dt_ms_per_token"] = req.cdc.get("dt_ms_per_token")

    # Handle helix segment selection params
    segments_budget = None
    segments_salt = None
    if req.helix:
        segments_budget = req.helix.get("segments_budget")
        segments_salt = req.helix.get("segments_salt")

    text, trace = _run_engine(
        req.messages,
        req.max_tokens,
        req.temperature,
        req.top_p,
        req.model,
        extras=extras
    )

    # Covenant gate: check decode_path and coherence floor
    COHERENCE_FLOOR = 0.15
    violations = []

    if trace.get("decode_path") != "cdc":
        violations.append({
            "vow": "no_hf_fallback",
            "expected": "decode_path=cdc",
            "actual": f"decode_path={trace.get('decode_path')}",
            "severity": "critical"
        })

    cdc_info = trace.get("cdc") or {}
    mean_coherence = cdc_info.get("mean_coherence") if cdc_info else None
    if mean_coherence is not None and mean_coherence < COHERENCE_FLOOR:
        violations.append({
            "vow": "coherence_floor",
            "expected": f"mean_coherence >= {COHERENCE_FLOOR}",
            "actual": f"mean_coherence = {mean_coherence:.4f}",
            "severity": "warning"
        })

    # If critical violations, return 412 Precondition Failed
    if any(v["severity"] == "critical" for v in violations):
        from fastapi import Response
        return Response(
            content=json.dumps({
                "error": "covenant_violation",
                "violations": violations,
                "covenant_check": "/echo/covenant"
            }),
            status_code=412,
            media_type="application/json"
        )

    # Add seed provenance to trace
    seed_path = os.environ.get("FLOWTORCH_SEED_PATH")
    seed_meta = _seed_meta(seed_path)

    # Compute helix segment selection metadata if requested
    helix_selection = None
    helix_attestation = None
    if segments_budget or segments_salt:
        # Parse budget percentage
        coverage_pct = 0.0
        if segments_budget:
            if segments_budget.startswith("budget:"):
                budget_str = segments_budget.split(":")[1].rstrip("%")
                try:
                    coverage_pct = float(budget_str)
                except ValueError:
                    coverage_pct = 0.0

        # Compute deterministic mask hash from budget + salt + seed
        mask_input = f"{segments_budget or ''}{segments_salt or ''}{seed_meta.get('seed_sha256', '')}"
        mask_hash = sha256(mask_input.encode()).hexdigest()

        # Acceptance validation - fail-closed enforcement
        enforce = os.getenv("HELIX_ENFORCE_MASK", "1") == "1"
        accepted = False
        reason = None

        if enforce:
            # Check if budget is valid
            if coverage_pct > 0.0 and 0.0 < coverage_pct <= 100.0:
                # Check if in CDC mode
                hf_mode = os.environ.get("FLOWTORCH_HF", "1")
                if hf_mode == "0":  # CDC mode
                    accepted = True
                else:
                    reason = "cdc_mode_required"
            else:
                reason = f"invalid_coverage:{coverage_pct}"
        else:
            # Soft mode - always accept
            accepted = True
            reason = "soft_mode"

        if enforce and not accepted:
            raise HTTPException(status_code=400, detail={
                "error": "helix_mask_rejected",
                "reason": reason or "unknown"
            })

        # Deterministic segment selection with measured coverage
        SEG_BYTES = int(os.getenv("HELIX_SEG_BYTES", "128"))
        seed_path = os.environ.get("FLOWTORCH_SEED_PATH")
        seed_bytes = Path(seed_path).read_bytes() if seed_path and Path(seed_path).exists() else b""
        total_bytes = len(seed_bytes)
        seed_sha = seed_meta.get("seed_sha256", "")

        # Compute actual selection using deterministic bitmask
        sel = _sel_bitmask(seed_sha, total_bytes, SEG_BYTES, coverage_pct, segments_salt) if total_bytes > 0 else {
            "selected": [],
            "n_segments": 0,
            "bitmask": b"",
            "bitmask_sha256": "",
            "measured_pct": 0.0,
        }

        # Build witnesses bound to selection indices
        witnesses = []
        for i, seg_idx in enumerate(sel["selected"][:10]):  # cap to 10
            # Pin witness to segment start, bound length to available bytes
            off = seg_idx * SEG_BYTES
            n = min(32, max(0, total_bytes - off))
            snippet = seed_bytes[off:off+n]
            witnesses.append({
                "seg_index": int(seg_idx),
                "offset": int(off),
                "seg_bytes": SEG_BYTES,
                "sha256_32": sha256(snippet).hexdigest(),
            })

        # Enforce: recompute coverage from selection, require close to request
        measured = sel["measured_pct"]
        # Default tolerance 15% to handle small seeds where segment granularity is coarse
        # For 487-byte seed with 128B segments, 15% request = 1 segment = 26% actual
        tol = float(os.getenv("HELIX_COVER_TOL_PCT", "15.0"))
        if enforce and total_bytes > 0:
            if abs(measured - coverage_pct) > tol:
                raise HTTPException(status_code=400, detail={
                    "error": "helix_mask_rejected",
                    "reason": f"coverage_miss: requested={coverage_pct:.3f} measured={measured:.3f} tol={tol:.3f}"
                })

        helix_selection = {
            "decode_coverage_pct": round(measured, 3),  # measured, not requested
            "requested_budget_pct": coverage_pct,
            "segments_mask_sha256": sel["bitmask_sha256"],  # hash of actual bitmask
            "bitmask_alg": "shake256-sort-v1",  # SHAKE-256 hash-and-sort (NIST FIPS 202)
            "segments_budget": segments_budget,
            "segments_salt": segments_salt,
            "seg_bytes": SEG_BYTES,
            "accepted": accepted,
            "reason": reason,
            "witnesses": witnesses,
            "segments_total_bytes": total_bytes,
            "segments_count": sel["n_segments"],
            "selected_count": len(sel["selected"]),
            "server_time_utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
        }

        # Challenge/response attestation - proves server computed the mask
        challenge = secrets.token_hex(8)
        seed_sha = seed_meta.get("seed_sha256", "")
        bitmask_sha = sel["bitmask_sha256"]
        attest_resp = sha256(f"{challenge}|{bitmask_sha}|{seed_sha}".encode()).hexdigest()

        # Build attestation blob for signing
        att_blob = json.dumps({
            "challenge": challenge,
            "bitmask_sha256": bitmask_sha,  # bind to actual selection bitmask
            "seed_sha256": seed_sha,
            "coverage_pct": helix_selection["decode_coverage_pct"],  # measured coverage
        }, separators=(",",":"), sort_keys=True)

        # HMAC signature if key configured
        HMAC_KEY = os.getenv("FLOWTORCH_ATTEST_HMAC")
        sig = None
        if HMAC_KEY:
            sig = hmac.new(HMAC_KEY.encode(), att_blob.encode(), sha256).hexdigest()

        helix_attestation = {
            "challenge": challenge,
            "response": attest_resp,
            "server_commit": os.getenv("FLOWTORCH_COMMIT", None),
            "enforced": enforce,
            "blob_sha256": sha256(att_blob.encode()).hexdigest(),
            "hmac_sha256": sig,
        }

    # OpenAI-shaped response
    resp = {
        "id": "helix-flowtorch-local-1",
        "object": "chat.completion",
        "choices": [{
            "index": 0,
            "message": {"role": "assistant", "content": text},
            "finish_reason": "stop"
        }],
        "usage": {"prompt_tokens": None, "completion_tokens": None, "total_tokens": None},
        "model": req.model or "flowtorch/local",
        "trace": {
            "schema_version": trace.get("schema_version"),
            "receipt_digest": trace.get("receipt_digest"),
            "causality": trace.get("causality"),
            "overlay": trace.get("overlay"),
            "ucb": trace.get("ucb"),
            "petal": trace.get("petal"),
            "decode_path": trace.get("decode_path"),
            "cdc": trace.get("cdc"),
            "params": trace.get("params"),
            "hardware": trace.get("hardware"),
            "prompt_sha3": trace.get("prompt_sha3"),
            "output_sha3": trace.get("output_sha3"),
            "param_digest": trace.get("param_digest"),
            **seed_meta,
        }
    }

    # Add helix.selection if segments were requested
    if helix_selection:
        resp["helix"] = {"selection": helix_selection}
        if helix_attestation:
            resp["helix"]["attestation"] = helix_attestation

    return resp
