// WarzonePhone Tauri backend — shared between desktop (macOS/Windows/Linux)
// and Tauri mobile (Android/iOS). Platform-specific audio is cfg-gated.

#![cfg_attr(
    all(not(debug_assertions), target_os = "windows"),
    windows_subsystem = "windows"
)]

// Call engine — now compiled on every platform. On desktop it runs the real
// CPAL/VPIO audio pipeline; on Android the engine calls into the standalone
// wzp-native cdylib (via the wzp_native module) for Oboe-backed audio.
mod engine;

// Android runtime binding to libwzp_native.so (Oboe audio backend, built as
// a standalone cdylib with cargo-ndk to avoid the Tauri staticlib symbol
// leak — see docs/incident-tauri-android-init-tcb.md).
#[cfg(target_os = "android")]
mod wzp_native;

// Android AudioManager bridge (routing earpiece / speaker / BT).
#[cfg(target_os = "android")]
mod android_audio;

// Direct-call history store (persisted JSON in app data dir).
mod history;

// CallEngine has a unified impl on both targets now — the Android branch of
// CallEngine::start() routes audio through the standalone wzp-native cdylib
// (loaded via the wzp_native module below), the desktop branch uses CPAL.
use engine::CallEngine;

use serde::Serialize;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, OnceLock};
use tauri::{Emitter, Manager};
use tokio::sync::Mutex;
use wzp_proto::{MediaTransport, default_signal_version};

// ─── Call-flow debug logs (GUI-gated) ────────────────────────────────
//
// Runtime-toggleable verbose logging for every step in the
// signaling + call setup path. When the user enables "Call flow
// debug logs" in the settings panel, `emit_call_debug!` fires a
// `call-debug-log` Tauri event that JS picks up and renders into a
// rolling debug panel so the user can see exactly where a call
// progressed or stalled — no logcat parsing needed.
//
// Mirrors the existing `wzp_codec::dred_verbose_logs` pattern.

static CALL_DEBUG_LOGS: AtomicBool = AtomicBool::new(false);

#[inline]
fn call_debug_logs_enabled() -> bool {
    CALL_DEBUG_LOGS.load(Ordering::Relaxed)
}

fn set_call_debug_logs_internal(on: bool) {
    CALL_DEBUG_LOGS.store(on, Ordering::Relaxed);
}

/// Emit a `call-debug-log` event to the JS side IF the flag is on.
/// Also mirrors to `tracing::info!` so logcat keeps its copy
/// regardless of the flag — the toggle only controls the GUI
/// overlay, not the underlying Android log stream.
pub(crate) fn emit_call_debug(app: &tauri::AppHandle, step: &str, details: serde_json::Value) {
    tracing::info!(step, ?details, "call-debug");
    if !call_debug_logs_enabled() {
        return;
    }
    let payload = serde_json::json!({
        "ts_ms": std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .map(|d| d.as_millis() as u64)
            .unwrap_or(0),
        "step": step,
        "details": details,
    });
    let _ = app.emit("call-debug-log", payload);
}

/// Short git hash captured at compile time by build.rs.
const GIT_HASH: &str = env!("WZP_GIT_HASH");

/// Resolved by `setup()` once we have a Tauri AppHandle. Holds the
/// platform-correct app data dir (e.g. `/data/data/com.wzp.desktop/files` on
/// Android, `~/Library/Application Support/com.wzp.desktop` on macOS).
static APP_DATA_DIR: OnceLock<PathBuf> = OnceLock::new();

/// Adjective list — keep in sync with the noun list below. Both are powers of
/// 2 friendly so the modulo bias is negligible.
const ALIAS_ADJECTIVES: &[&str] = &[
    "Swift", "Silent", "Brave", "Calm", "Dark", "Fierce", "Ghost", "Iron", "Lucky", "Noble",
    "Quick", "Sharp", "Storm", "Wild", "Cold", "Bright", "Lone", "Red", "Grey", "Frosty", "Dusty",
    "Rusty", "Neon", "Void", "Solar", "Lunar", "Cyber", "Pixel", "Sonic", "Hyper", "Turbo", "Nano",
    "Mega", "Ultra", "Zinc",
];
const ALIAS_NOUNS: &[&str] = &[
    "Wolf", "Hawk", "Fox", "Bear", "Lynx", "Crow", "Viper", "Cobra", "Tiger", "Eagle", "Shark",
    "Raven", "Falcon", "Otter", "Mantis", "Panda", "Jackal", "Badger", "Heron", "Bison", "Condor",
    "Coyote", "Gecko", "Hornet", "Marten", "Osprey", "Parrot", "Puma", "Raptor", "Stork", "Toucan",
    "Walrus",
];

/// Derive a stable human-readable alias from the seed bytes. Same seed →
/// same alias forever, different seeds → effectively random aliases.
fn derive_alias(seed: &wzp_crypto::Seed) -> String {
    let adj_idx = (u16::from_le_bytes([seed.0[0], seed.0[1]]) as usize) % ALIAS_ADJECTIVES.len();
    let noun_idx = (u16::from_le_bytes([seed.0[2], seed.0[3]]) as usize) % ALIAS_NOUNS.len();
    format!("{} {}", ALIAS_ADJECTIVES[adj_idx], ALIAS_NOUNS[noun_idx])
}

#[derive(Clone, Serialize)]
struct CallEvent {
    kind: String,
    message: String,
}

#[derive(Clone, Serialize)]
struct Participant {
    fingerprint: String,
    alias: Option<String>,
    relay_label: Option<String>,
}

#[derive(Clone, Serialize)]
struct CallStatus {
    active: bool,
    mic_muted: bool,
    spk_muted: bool,
    participants: Vec<Participant>,
    encode_fps: u64,
    recv_fps: u64,
    audio_level: u32,
    call_duration_secs: f64,
    fingerprint: String,
    tx_codec: String,
    rx_codec: String,
}

struct AppState {
    engine: Mutex<Option<CallEngine>>,
    signal: Arc<Mutex<SignalState>>,
}

/// Ping result with RTT and server identity hash.
#[derive(Clone, Serialize)]
struct PingResult {
    rtt_ms: u32,
    /// Server identity: SHA-256 of the QUIC peer certificate, hex-encoded.
    server_fingerprint: String,
}

/// Toggle DRED verbose logging at runtime (gates the chatty per-frame
/// reconstruction + parse logs in opus_enc and engine.rs). Wired to the
/// "DRED debug logs" checkbox in the GUI settings panel.
#[tauri::command]
fn set_dred_verbose_logs(enabled: bool) {
    wzp_codec::set_dred_verbose_logs(enabled);
    tracing::info!(enabled, "DRED verbose logs toggled");
}

/// Read the current DRED verbose logging flag (so the GUI can hydrate
/// its checkbox on startup without trusting localStorage alone).
#[tauri::command]
fn get_dred_verbose_logs() -> bool {
    wzp_codec::dred_verbose_logs()
}

/// Phase 3.5 call-flow debug logs toggle. Gates the live
/// `call-debug-log` Tauri events that the GUI renders into a
/// rolling debug panel. Does NOT affect logcat — tracing::info
/// always runs regardless so the Android log stream keeps its
/// copy.
#[tauri::command]
fn set_call_debug_logs(enabled: bool) {
    set_call_debug_logs_internal(enabled);
    tracing::info!(enabled, "call-flow debug logs toggled");
}

#[tauri::command]
fn get_call_debug_logs() -> bool {
    call_debug_logs_enabled()
}

/// Ping a relay to check if it's online, measure RTT, and get server identity.
#[tauri::command]
async fn ping_relay(relay: String) -> Result<PingResult, String> {
    let addr: std::net::SocketAddr = relay.parse().map_err(|e| format!("bad address: {e}"))?;
    let _ = rustls::crypto::ring::default_provider().install_default();
    let bind: std::net::SocketAddr = "[::]:0".parse().unwrap();
    let endpoint = wzp_transport::create_endpoint(bind, None).map_err(|e| format!("{e}"))?;
    let client_cfg = wzp_transport::client_config();

    let start = std::time::Instant::now();
    let conn_result = tokio::time::timeout(
        std::time::Duration::from_secs(3),
        wzp_transport::connect(&endpoint, addr, "ping", client_cfg),
    )
    .await;

    // Always close endpoint to prevent resource leaks
    endpoint.close(0u32.into(), b"done");

    match conn_result {
        Ok(Ok(conn)) => {
            let rtt_ms = start.elapsed().as_millis() as u32;

            let server_fingerprint = conn
                .peer_identity()
                .and_then(|id| id.downcast::<Vec<rustls::pki_types::CertificateDer>>().ok())
                .and_then(|certs| {
                    certs.first().map(|c| {
                        use std::hash::{Hash, Hasher};
                        let mut hasher = std::collections::hash_map::DefaultHasher::new();
                        c.as_ref().hash(&mut hasher);
                        let h = hasher.finish();
                        format!("{h:016x}")
                    })
                })
                .unwrap_or_else(|| {
                    format!(
                        "{:x}",
                        addr.ip().to_string().len() as u64 * 0x9e3779b97f4a7c15
                            + addr.port() as u64
                    )
                });

            conn.close(0u32.into(), b"ping");
            Ok(PingResult {
                rtt_ms,
                server_fingerprint,
            })
        }
        Ok(Err(e)) => Err(format!("{e}")),
        Err(_) => Err("timeout (3s)".into()),
    }
}

/// Return the directory where identity/config should live.
///
/// Resolved at startup from Tauri's `path().app_data_dir()` API which gives
/// us the platform-correct app-private location:
///   - Android: `/data/data/<package_id>/files/com.wzp.desktop`
///   - macOS:   `~/Library/Application Support/com.wzp.desktop`
///   - Linux:   `~/.local/share/com.wzp.desktop`
///
/// Falls back to `$HOME/.wzp` on the desktop side if the OnceLock hasn't been
/// initialised yet (shouldn't happen in normal startup, but keeps the fn
/// total).
fn identity_dir() -> PathBuf {
    if let Some(dir) = APP_DATA_DIR.get() {
        return dir.clone();
    }
    #[cfg(target_os = "android")]
    {
        // Last-resort default. The real path is set in setup() below.
        std::path::PathBuf::from("/data/data/com.wzp.desktop/files")
    }
    #[cfg(not(target_os = "android"))]
    {
        let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
        std::path::PathBuf::from(home).join(".wzp")
    }
}

fn identity_path() -> std::path::PathBuf {
    identity_dir().join("identity")
}

/// Load the persisted seed, or generate-and-persist a new one if missing.
fn load_or_create_seed() -> Result<wzp_crypto::Seed, String> {
    let path = identity_path();
    if path.exists() {
        let hex = std::fs::read_to_string(&path).map_err(|e| format!("read identity: {e}"))?;
        return wzp_crypto::Seed::from_hex(hex.trim()).map_err(|e| format!("{e}"));
    }
    let seed = wzp_crypto::Seed::generate();
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent).map_err(|e| format!("create identity dir: {e}"))?;
    }
    let hex: String = seed.0.iter().map(|b| format!("{b:02x}")).collect();
    std::fs::write(&path, hex).map_err(|e| format!("write identity: {e}"))?;
    Ok(seed)
}

/// Read fingerprint, generating a fresh identity if none exists yet.
#[tauri::command]
fn get_identity() -> Result<String, String> {
    let seed = load_or_create_seed()?;
    Ok(seed
        .derive_identity()
        .public_identity()
        .fingerprint
        .to_string())
}

/// Build/identity info shown on the home screen so the user can prove which
/// build is installed and what their stable alias is.
#[derive(Clone, Serialize)]
struct AppInfo {
    /// Short git commit hash captured at build time.
    git_hash: &'static str,
    /// Stable adjective+noun derived from the seed.
    alias: String,
    /// Full fingerprint, e.g. "abcd:ef01:..."
    fingerprint: String,
    /// App data dir actually in use — useful for debugging EACCES issues.
    data_dir: String,
}

#[tauri::command]
fn get_app_info() -> Result<AppInfo, String> {
    let seed = load_or_create_seed()?;
    let pub_id = seed.derive_identity().public_identity();
    Ok(AppInfo {
        git_hash: GIT_HASH,
        alias: derive_alias(&seed),
        fingerprint: pub_id.fingerprint.to_string(),
        data_dir: identity_dir().to_string_lossy().into_owned(),
    })
}

#[tauri::command]
async fn connect(
    state: tauri::State<'_, Arc<AppState>>,
    app: tauri::AppHandle,
    relay: String,
    room: String,
    alias: String,
    os_aec: bool,
    quality: String,
    // Phase 3 hole-punching: peer's server-reflexive address
    // cross-wired by the relay in CallSetup.peer_direct_addr.
    peer_direct_addr: Option<String>,
    // Phase 5.5: peer's LAN host candidates from CallSetup.
    // Optional so the room-join path (which has no peer addrs)
    // can omit it entirely — it's only populated on direct calls.
    peer_local_addrs: Option<Vec<String>>,
    // Phase 8 (Tailscale-inspired): peer's port-mapped external
    // address from NAT-PMP/PCP/UPnP, carried in CallSetup.
    peer_mapped_addr: Option<String>,
    // Debug: when true, skip relay fallback entirely — the call
    // fails if direct P2P doesn't connect. Useful for testing NAT
    // traversal without the relay masking failures.
    direct_only: Option<bool>,
    // Enable birthday attack for hard NAT traversal. Adds ~3s to
    // call setup when peer has symmetric NAT.
    birthday_attack: Option<bool>,
) -> Result<String, String> {
    let force_direct = direct_only.unwrap_or(false);
    let enable_birthday = birthday_attack.unwrap_or(false);
    emit_call_debug(
        &app,
        "connect:start",
        serde_json::json!({
            "relay": relay,
            "room": room,
            "peer_direct_addr": peer_direct_addr,
            "peer_local_addrs": peer_local_addrs,
            "peer_mapped_addr": peer_mapped_addr,
            "direct_only": force_direct,
            "birthday_attack": enable_birthday,
        }),
    );
    let mut engine_lock = state.engine.lock().await;
    if engine_lock.is_some() {
        emit_call_debug(&app, "connect:already_connected", serde_json::json!({}));
        return Err("already connected".into());
    }

    // Phase 3.5: dual-path QUIC race.
    //
    // If the relay cross-wired a peer_direct_addr into the
    // CallSetup, we read our own reflex addr from SignalState
    // (populated earlier by place_call/answer_call's reflect query)
    // and use determine_role() to decide whether we're the
    // Acceptor (smaller addr, listens) or Dialer (larger addr,
    // dials). Both roles also dial the relay in parallel as a
    // fallback. Whichever transport completes first becomes the
    // media transport we hand to CallEngine::start.
    //
    // If ANY of the inputs is missing (no peer_direct_addr, no
    // own_reflex_addr, unparseable addrs, equal addrs), we skip
    // the race entirely and fall back to the pure-relay path —
    // identical to Phase 0 behavior.
    let (own_reflex_addr, signal_endpoint_for_race, ipv6_endpoint_for_race) = {
        let mut sig = state.signal.lock().await;
        (
            sig.own_reflex_addr.clone(),
            sig.endpoint.clone(),
            sig.ipv6_endpoint.take(),
        )
    };
    let peer_addr_parsed: Option<std::net::SocketAddr> =
        peer_direct_addr.as_deref().and_then(|s| s.parse().ok());
    let relay_addr_parsed: Option<std::net::SocketAddr> = relay.parse().ok();
    let role = wzp_client::reflect::determine_role(
        own_reflex_addr.as_deref(),
        peer_direct_addr.as_deref(),
    );

    // Phase 5.6 safety heuristic: only attempt P2P direct when
    // Phase 6 handles the path agreement through MediaPathReport
    // exchange — the pre-Phase-6 heuristic that forced relay-only
    // for different public IPs has been removed. Both sides now
    // race freely and negotiate the result.

    // Phase 5.5: build the full peer candidate bundle (reflex +
    // LAN hosts). The dial_order helper will fan them out in
    // priority order for the D-role race.
    let peer_local_addrs_vec = peer_local_addrs.unwrap_or_default();
    let peer_local_parsed: Vec<std::net::SocketAddr> = peer_local_addrs_vec
        .iter()
        .filter_map(|s| s.parse().ok())
        .collect();

    // Phase 6: tracks whether the agreed path is truly direct P2P
    // (skip handshake) or relay-mediated (must run handshake).
    // Set inside the Phase 6 negotiation block below.
    let mut is_direct_p2p_agreed = false;

    let pre_connected_transport: Option<Arc<wzp_transport::QuinnTransport>> = match (
        role,
        relay_addr_parsed,
    ) {
        (Some(r), Some(relay_sockaddr))
            if peer_addr_parsed.is_some() || !peer_local_parsed.is_empty() =>
        {
            // Phase 8: parse peer_mapped_addr from CallSetup
            let peer_mapped_parsed: Option<std::net::SocketAddr> =
                peer_mapped_addr.as_deref().and_then(|s| s.parse().ok());

            // Phase 8.6: if peer sent a HardNatProbe with sequential
            // allocation, predict their next ports and add as candidates.
            let mut predicted_addrs: Vec<std::net::SocketAddr> = Vec::new();
            {
                let sig = state.signal.lock().await;
                if let Some(ref probe) = sig.peer_hard_nat_probe {
                    if let Some(delta) = parse_sequential_delta(&probe.allocation) {
                        if let Some(&last_port) = probe.port_sequence.first() {
                            let predicted = wzp_client::stun::predict_ports(last_port, delta, 1, 3);
                            for p in predicted {
                                predicted_addrs
                                    .push(std::net::SocketAddr::new(probe.external_ip, p));
                            }
                            tracing::info!(
                                delta,
                                last_port,
                                predicted_count = predicted_addrs.len(),
                                "connect: added predicted ports from HardNatProbe"
                            );
                            emit_call_debug(
                                &app,
                                "connect:hard_nat_predicted",
                                serde_json::json!({
                                    "delta": delta,
                                    "last_port": last_port,
                                    "predicted": predicted_addrs.iter().map(|a| a.to_string()).collect::<Vec<_>>(),
                                }),
                            );
                        }
                    }
                }
            }

            // Phase 8.6: if peer sent birthday attack ports, add
            // them as extra candidates the Dialer can target.
            // Only wait for birthday ports if we know the peer has
            // a non-cone NAT (from HardNatProbe). Otherwise start
            // the race immediately — LAN/cone calls shouldn't wait.
            let mut birthday_addrs: Vec<std::net::SocketAddr> = Vec::new();
            {
                let peer_needs_birthday = enable_birthday && {
                    let sig = state.signal.lock().await;
                    sig.peer_hard_nat_probe
                        .as_ref()
                        .map(|p| p.allocation != "port-preserving")
                        .unwrap_or(false)
                };
                if peer_needs_birthday {
                    // Wait up to 3s for BirthdayStart (Acceptor needs
                    // time to open ports + STUN-probe them).
                    for _ in 0..6 {
                        let sig = state.signal.lock().await;
                        if sig.peer_birthday_ports.is_some() {
                            break;
                        }
                        drop(sig);
                        tokio::time::sleep(std::time::Duration::from_millis(500)).await;
                    }
                }
                let sig = state.signal.lock().await;
                if let Some(ref bday) = sig.peer_birthday_ports {
                    let targets = wzp_client::birthday::generate_dialer_targets(
                        match bday.external_ip {
                            std::net::IpAddr::V4(ip) => ip,
                            _ => std::net::Ipv4Addr::UNSPECIFIED,
                        },
                        &bday.ports,
                        64, // spray up to 64 targets
                    );
                    birthday_addrs = targets;
                    tracing::info!(
                        birthday_targets = birthday_addrs.len(),
                        known_ports = bday.ports.len(),
                        "connect: adding birthday attack targets"
                    );
                    emit_call_debug(
                        &app,
                        "connect:birthday_targets",
                        serde_json::json!({
                            "known_ports": bday.ports,
                            "total_targets": birthday_addrs.len(),
                        }),
                    );
                }
            }

            let mut all_local = peer_local_parsed.clone();
            all_local.extend(predicted_addrs);
            all_local.extend(birthday_addrs);

            let candidates = wzp_client::dual_path::PeerCandidates {
                reflexive: peer_addr_parsed,
                local: all_local,
                mapped: peer_mapped_parsed,
            };
            tracing::info!(
                role = ?r,
                candidates = ?candidates.dial_order(),
                %relay,
                %room,
                own = ?own_reflex_addr,
                "connect: starting dual-path race"
            );
            let own_reflex_parsed: Option<std::net::SocketAddr> =
                own_reflex_addr.as_deref().and_then(|s| s.parse().ok());
            emit_call_debug(
                &app,
                "connect:dual_path_race_start",
                serde_json::json!({
                    "role": format!("{:?}", r),
                    "peer_reflex": peer_addr_parsed.map(|a| a.to_string()),
                    "peer_mapped": peer_mapped_parsed.map(|a| a.to_string()),
                    "peer_local": peer_local_parsed.iter().map(|a| a.to_string()).collect::<Vec<_>>(),
                    "dial_order_raw": candidates.dial_order().iter().map(|a| a.to_string()).collect::<Vec<_>>(),
                    "dial_order_smart": candidates.smart_dial_order(own_reflex_parsed.as_ref()).iter().map(|a| a.to_string()).collect::<Vec<_>>(),
                    "relay_addr": relay_sockaddr.to_string(),
                    "own_reflex_addr": own_reflex_addr,
                }),
            );
            let (path_report_tx, path_report_rx) = tokio::sync::oneshot::channel::<bool>();
            {
                let mut sig = state.signal.lock().await;
                sig.pending_path_report = Some(path_report_tx);
            }

            let room_sni = room.clone();
            let call_sni = format!("call-{room}");
            match wzp_client::dual_path::race(
                r,
                candidates,
                relay_sockaddr,
                room_sni,
                call_sni,
                own_reflex_parsed,
                signal_endpoint_for_race.clone(),
                ipv6_endpoint_for_race.clone(),
            )
            .await
            {
                Ok(race_result) => {
                    let local_direct_ok = race_result.direct_transport.is_some();
                    let local_winner = race_result.local_winner;
                    tracing::info!(
                        ?local_winner,
                        local_direct_ok,
                        has_relay = race_result.relay_transport.is_some(),
                        "connect: race finished, starting Phase 6 negotiation"
                    );
                    emit_call_debug(
                        &app,
                        "connect:dual_path_race_done",
                        serde_json::json!({
                            "local_winner": format!("{:?}", local_winner),
                            "local_direct_ok": local_direct_ok,
                            "has_relay": race_result.relay_transport.is_some(),
                            "candidate_diags": race_result.candidate_diags,
                        }),
                    );

                    // Phase 6: send our report to the peer and
                    // wait for theirs before committing. Both
                    // sides must agree on the same path to
                    // prevent the one-picks-Direct-other-picks-
                    // Relay race condition that causes TX>0 RX=0
                    // on both sides.
                    //
                    // Extract call_id from the room name
                    // ("call-<id>" → "<id>").
                    let call_id_for_report =
                        room.strip_prefix("call-").unwrap_or(&room).to_string();

                    // The oneshot was installed BEFORE the race
                    // (see path_report_tx above) so the peer's
                    // report is already buffered in path_report_rx
                    // if it arrived during the race.
                    let rx = path_report_rx;
                    let peer_direct_ok = {
                        let transport_for_report = {
                            let sig = state.signal.lock().await;
                            sig.transport.as_ref().cloned()
                        };
                        // Send our report
                        if let Some(ref t) = transport_for_report {
                            let report = wzp_proto::SignalMessage::MediaPathReport {
                                version: default_signal_version(),
                                call_id: call_id_for_report.clone(),
                                direct_ok: local_direct_ok,
                                race_winner: format!("{:?}", local_winner),
                            };
                            let _ = t.send_signal(&report).await;
                            emit_call_debug(
                                &app,
                                "connect:path_report_sent",
                                serde_json::json!({
                                    "direct_ok": local_direct_ok,
                                    "race_winner": format!("{:?}", local_winner),
                                }),
                            );
                        }
                        // Wait for peer's report (3s timeout)
                        match tokio::time::timeout(std::time::Duration::from_secs(3), rx).await {
                            Ok(Ok(peer_ok)) => {
                                emit_call_debug(
                                    &app,
                                    "connect:peer_report_received",
                                    serde_json::json!({
                                        "peer_direct_ok": peer_ok,
                                    }),
                                );
                                peer_ok
                            }
                            _ => {
                                // Timeout or channel error — peer
                                // may be on an old build without
                                // Phase 6. Fall back to relay.
                                emit_call_debug(
                                    &app,
                                    "connect:peer_report_timeout",
                                    serde_json::json!({}),
                                );
                                let mut sig = state.signal.lock().await;
                                sig.pending_path_report = None;
                                false
                            }
                        }
                    };

                    // Phase 6 decision: BOTH must agree on direct
                    let use_direct = local_direct_ok && peer_direct_ok;
                    let chosen_path = if use_direct {
                        wzp_client::dual_path::WinningPath::Direct
                    } else {
                        wzp_client::dual_path::WinningPath::Relay
                    };
                    emit_call_debug(
                        &app,
                        "connect:path_negotiated",
                        serde_json::json!({
                            "use_direct": use_direct,
                            "local_direct_ok": local_direct_ok,
                            "peer_direct_ok": peer_direct_ok,
                            "chosen_path": format!("{:?}", chosen_path),
                            "direct_only": force_direct,
                        }),
                    );

                    // direct_only mode: refuse relay fallback
                    if force_direct && !use_direct {
                        let reason = format!(
                            "direct_only: P2P failed (local_ok={local_direct_ok}, peer_ok={peer_direct_ok})"
                        );
                        emit_call_debug(
                            &app,
                            "connect:direct_only_failed",
                            serde_json::json!({
                                "reason": reason,
                                "candidate_diags": race_result.candidate_diags,
                            }),
                        );
                        return Err(reason);
                    }
                    tracing::info!(
                        ?chosen_path,
                        use_direct,
                        local_direct_ok,
                        peer_direct_ok,
                        "connect: Phase 6 path agreed"
                    );

                    // Pick the agreed transport. Tag it with
                    // whether this is truly a direct P2P conn
                    // so CallEngine knows whether to skip the
                    // handshake. Critical: relay transports
                    // delivered via pre_connected MUST still
                    // run perform_handshake — the relay expects
                    // it for participant authentication.
                    is_direct_p2p_agreed = use_direct;
                    if use_direct {
                        // Close losing relay transport so the
                        // relay sees a clean disconnect instead
                        // of waiting 30s for idle timeout.
                        if let Some(loser) = race_result.relay_transport.as_ref() {
                            loser.connection().close(0u32.into(), b"not-selected");
                        }
                        race_result.direct_transport
                    } else {
                        // Close losing direct transport so the
                        // peer's endpoint doesn't retain a
                        // phantom connection that pollutes
                        // future accept() calls.
                        if let Some(loser) = race_result.direct_transport.as_ref() {
                            loser.connection().close(0u32.into(), b"not-selected");
                        }
                        race_result.relay_transport
                    }
                }
                Err(e) => {
                    tracing::warn!(error = %e, "connect: dual-path race failed, falling back to classic relay connect");
                    emit_call_debug(
                        &app,
                        "connect:dual_path_race_failed",
                        serde_json::json!({
                            "error": e.to_string(),
                        }),
                    );
                    None
                }
            }
        }
        _ => {
            tracing::info!(
                has_peer_reflex = peer_direct_addr.is_some(),
                has_peer_local = !peer_local_addrs_vec.is_empty(),
                has_own = own_reflex_addr.is_some(),
                ?role,
                %relay,
                %room,
                "connect: skipping dual-path race (missing inputs), relay-only"
            );
            emit_call_debug(
                &app,
                "connect:dual_path_skipped",
                serde_json::json!({
                    "has_peer_reflex": peer_direct_addr.is_some(),
                    "has_peer_local": !peer_local_addrs_vec.is_empty(),
                    "has_own": own_reflex_addr.is_some(),
                    "role": format!("{:?}", role),
                }),
            );
            None
        }
    };

    // If we previously opened a quinn::Endpoint for the signaling connection
    // (direct-call path), reuse it so the media connection shares the same
    // UDP socket. This side-steps the Android issue where a second
    // quinn::Endpoint silently hangs in the QUIC handshake.
    let reuse_endpoint = state.signal.lock().await.endpoint.clone();
    if reuse_endpoint.is_some() && pre_connected_transport.is_none() {
        tracing::info!("connect: reusing existing signal endpoint for media connection");
    }

    let app_clone = app.clone();
    // Log transport details for debugging direct P2P media issues
    let transport_info = pre_connected_transport.as_ref().map(|t| {
        serde_json::json!({
            "remote": t.remote_address().to_string(),
            "max_datagram": t.max_datagram_size(),
            "close_reason": t.connection().close_reason().map(|r| format!("{r:?}")),
        })
    });
    emit_call_debug(
        &app,
        "connect:call_engine_starting",
        serde_json::json!({
            "is_direct_p2p": is_direct_p2p_agreed,
            "transport": transport_info,
        }),
    );
    let app_for_engine = app.clone();
    match CallEngine::start(
        relay,
        room,
        alias,
        os_aec,
        quality,
        reuse_endpoint,
        pre_connected_transport,
        is_direct_p2p_agreed,
        app_for_engine,
        move |event_kind, message| {
            let _ = app_clone.emit(
                "call-event",
                CallEvent {
                    kind: event_kind.to_string(),
                    message: message.to_string(),
                },
            );
        },
    )
    .await
    {
        Ok(eng) => {
            *engine_lock = Some(eng);
            emit_call_debug(&app, "connect:call_engine_started", serde_json::json!({}));
            Ok("connected".into())
        }
        Err(e) => {
            emit_call_debug(
                &app,
                "connect:call_engine_failed",
                serde_json::json!({ "error": e.to_string() }),
            );
            Err(format!("{e}"))
        }
    }
}

#[tauri::command]
async fn disconnect(state: tauri::State<'_, Arc<AppState>>) -> Result<String, String> {
    let mut engine_lock = state.engine.lock().await;
    if let Some(engine) = engine_lock.take() {
        engine.stop().await;
        Ok("disconnected".into())
    } else {
        Err("not connected".into())
    }
}

#[tauri::command]
async fn toggle_mic(state: tauri::State<'_, Arc<AppState>>) -> Result<bool, String> {
    let engine_lock = state.engine.lock().await;
    if let Some(ref engine) = *engine_lock {
        Ok(engine.toggle_mic())
    } else {
        Err("not connected".into())
    }
}

#[tauri::command]
async fn toggle_speaker(state: tauri::State<'_, Arc<AppState>>) -> Result<bool, String> {
    let engine_lock = state.engine.lock().await;
    if let Some(ref engine) = *engine_lock {
        Ok(engine.toggle_speaker())
    } else {
        Err("not connected".into())
    }
}

#[tauri::command]
async fn get_status(state: tauri::State<'_, Arc<AppState>>) -> Result<CallStatus, String> {
    let engine_lock = state.engine.lock().await;
    if let Some(ref engine) = *engine_lock {
        let status = engine.status().await;
        Ok(CallStatus {
            active: true,
            mic_muted: status.mic_muted,
            spk_muted: status.spk_muted,
            participants: status
                .participants
                .into_iter()
                .map(|p| Participant {
                    fingerprint: p.fingerprint,
                    alias: p.alias,
                    relay_label: p.relay_label,
                })
                .collect(),
            encode_fps: status.frames_sent,
            recv_fps: status.frames_received,
            audio_level: status.audio_level,
            call_duration_secs: status.call_duration_secs,
            fingerprint: status.fingerprint,
            tx_codec: status.tx_codec,
            rx_codec: status.rx_codec,
        })
    } else {
        Ok(CallStatus {
            active: false,
            mic_muted: false,
            spk_muted: false,
            participants: vec![],
            encode_fps: 0,
            recv_fps: 0,
            audio_level: 0,
            call_duration_secs: 0.0,
            fingerprint: String::new(),
            tx_codec: String::new(),
            rx_codec: String::new(),
        })
    }
}

// ─── Audio routing (Android-specific, no-op on desktop) ─────────────────────

/// Switch the call audio between earpiece (`on=false`) and loudspeaker
/// (`on=true`). On Android this calls AudioManager.setSpeakerphoneOn via
/// JNI AND then stops and restarts the Oboe streams so AAudio reconfigures
/// with the new routing — without the restart, changing the speakerphone
/// state mid-call silently tears down the running AAudio streams on some
/// OEMs and both capture + playout stop producing data.
///
/// The Rust send/recv tokio tasks keep running during the ~60ms restart
/// window; they just observe empty reads / writes against the
/// process-global ring buffers, which is fine because the ring state
/// is preserved across stop+start.
#[tauri::command]
#[allow(unused_variables)]
async fn set_speakerphone(on: bool) -> Result<(), String> {
    #[cfg(target_os = "android")]
    {
        android_audio::set_speakerphone(on)?;
        if wzp_native::is_loaded() && wzp_native::audio_is_running() {
            tracing::info!(on, "set_speakerphone: restarting Oboe for route change");
            // Oboe's stop/start are sync C-FFI calls that block for ~400ms
            // on Nothing-class devices (Pixel is faster). Calling them
            // directly from an async Tauri command stalls the tokio
            // executor — the send/recv engine tasks were observed to
            // freeze for ~20 seconds across a few rapid speaker toggles,
            // piling up buffered QUIC datagrams and then flooding them
            // all at once when the runtime finally caught up.
            //
            // Fix: run the audio teardown + reopen on a dedicated
            // blocking thread so the runtime keeps scheduling everything
            // else. AAudio's requestStop returns only after the stream
            // is actually in Stopped state, so no explicit inter-call
            // sleep is needed.
            tokio::task::spawn_blocking(|| {
                wzp_native::audio_stop();
                wzp_native::audio_start()
                    .map_err(|code| format!("audio_start after speakerphone toggle: code {code}"))
            })
            .await
            .map_err(|e| format!("spawn_blocking join: {e}"))??;
            tracing::info!("set_speakerphone: Oboe restarted");
        }
        Ok(())
    }
    #[cfg(not(target_os = "android"))]
    {
        Ok(())
    }
}

/// Query whether the call is currently routed to the loudspeaker.
#[tauri::command]
async fn is_speakerphone_on() -> Result<bool, String> {
    #[cfg(target_os = "android")]
    {
        android_audio::is_speakerphone_on()
    }
    #[cfg(not(target_os = "android"))]
    {
        Ok(false)
    }
}

// ─── Bluetooth SCO routing (Android-specific, no-op on desktop) ─────────────

/// Enable or disable Bluetooth SCO audio routing. Like speakerphone toggling,
/// this requires an Oboe stream restart so AAudio picks up the new route.
///
/// `startBluetoothSco()` is asynchronous — the SCO link takes 500ms-2s to
/// establish. We poll `isBluetoothScoOn()` up to 3 seconds before restarting
/// Oboe, so the streams open against the BT device rather than earpiece.
#[tauri::command]
#[allow(unused_variables)]
async fn set_bluetooth_sco(on: bool) -> Result<(), String> {
    #[cfg(target_os = "android")]
    {
        if on {
            android_audio::start_bluetooth_sco()?;
            // Wait for SCO link to actually connect before restarting Oboe.
            // startBluetoothSco() is async — jumping straight to Oboe restart
            // would open streams against earpiece, not the BT device.
            let mut connected = false;
            for i in 0..50 {
                tokio::time::sleep(std::time::Duration::from_millis(100)).await;
                if android_audio::is_bluetooth_sco_on().unwrap_or(false) {
                    tracing::info!(polls = i + 1, "set_bluetooth_sco: SCO connected");
                    connected = true;
                    break;
                }
            }
            if !connected {
                tracing::warn!(
                    "set_bluetooth_sco: SCO did not connect within 5s, proceeding anyway"
                );
            }
            // Extra delay: even after getCommunicationDevice reports BT,
            // the audio policy needs ~500ms to apply the bt-sco route.
            // Without this, Oboe opens against the old device.
            tokio::time::sleep(std::time::Duration::from_millis(500)).await;
        } else {
            android_audio::stop_bluetooth_sco()?;
        }
        if wzp_native::is_loaded() && wzp_native::audio_is_running() {
            tracing::info!(on, "set_bluetooth_sco: restarting Oboe for route change");
            tokio::task::spawn_blocking(move || {
                wzp_native::audio_stop();
                if on {
                    // BT mode: skip sample rate + input preset on capture
                    // so the system can route to the BT SCO device natively.
                    wzp_native::audio_start_bt()
                        .map_err(|code| format!("audio_start_bt after BT on: code {code}"))
                } else {
                    // Normal mode: restore 48kHz + VoiceCommunication preset.
                    wzp_native::audio_start()
                        .map_err(|code| format!("audio_start after BT off: code {code}"))
                }
            })
            .await
            .map_err(|e| format!("spawn_blocking join: {e}"))??;
            tracing::info!("set_bluetooth_sco: Oboe restarted");
        }
        Ok(())
    }
    #[cfg(not(target_os = "android"))]
    {
        Ok(())
    }
}

/// Check whether a Bluetooth SCO device is currently connected and available.
#[tauri::command]
async fn is_bluetooth_available() -> Result<bool, String> {
    #[cfg(target_os = "android")]
    {
        android_audio::is_bluetooth_available()
    }
    #[cfg(not(target_os = "android"))]
    {
        Ok(false)
    }
}

/// Return the current audio route as a string: "bluetooth", "speaker", or "earpiece".
#[tauri::command]
async fn get_audio_route() -> Result<String, String> {
    #[cfg(target_os = "android")]
    {
        if android_audio::is_bluetooth_sco_on()? {
            return Ok("bluetooth".into());
        }
        if android_audio::is_speakerphone_on()? {
            return Ok("speaker".into());
        }
        Ok("earpiece".into())
    }
    #[cfg(not(target_os = "android"))]
    {
        Ok("earpiece".into())
    }
}

// ─── Call history commands ───────────────────────────────────────────────────

#[tauri::command]
fn get_call_history() -> Vec<history::CallHistoryEntry> {
    history::all()
}

#[tauri::command]
fn get_recent_contacts() -> Vec<history::CallHistoryEntry> {
    history::contacts()
}

#[tauri::command]
fn clear_call_history() -> Result<(), String> {
    history::clear();
    Ok(())
}

// ─── Signaling commands — platform independent ───────────────────────────────

struct SignalState {
    transport: Option<Arc<wzp_transport::QuinnTransport>>,
    /// The quinn::Endpoint backing the signal connection. Reused for the
    /// media connection when a direct call is accepted — Android phones
    /// silently drop packets from a second quinn::Endpoint to the same
    /// relay, so every call after register_signal MUST share this socket.
    endpoint: Option<wzp_transport::Endpoint>,
    /// Phase 7: per-call IPv6 endpoint with IPV6_V6ONLY=1 for
    /// dual-stack P2P. Created at place_call/answer_call time,
    /// consumed by the connect command's dual_path::race.
    ipv6_endpoint: Option<wzp_transport::Endpoint>,
    fingerprint: String,
    signal_status: String,
    incoming_call_id: Option<String>,
    incoming_caller_fp: Option<String>,
    incoming_caller_alias: Option<String>,
    /// Pending `ReflectResponse` channel. When the `get_reflected_address`
    /// Tauri command fires, it drops a `oneshot::Sender<SocketAddr>` here
    /// before sending a `SignalMessage::Reflect`. The spawned recv loop
    /// picks the response off the next bi-stream and fires the sender.
    /// If another Reflect request comes in while one is pending, we
    /// replace the sender — the old receiver sees a `Cancelled` error
    /// and the caller retries.
    pending_reflect: Option<tokio::sync::oneshot::Sender<std::net::SocketAddr>>,
    /// Phase 3.5: this client's own server-reflexive address as last
    /// observed by a Reflect query. Populated by
    /// `try_reflect_own_addr` on success and read by the `connect`
    /// Tauri command to compute the deterministic role for the
    /// dual-path QUIC race against `peer_direct_addr`.
    own_reflex_addr: Option<String>,
    /// The relay address the user currently wants to be registered
    /// against. `Some` means "keep me connected" — the supervisor
    /// will auto-reconnect after unexpected drops. `None` means
    /// "user explicitly deregistered" — do not retry.
    ///
    /// Distinguishing these two cases is what lets relay
    /// restarts + transient network blips be transparent to the
    /// user: the recv loop dies, but because `desired_relay_addr`
    /// is still set, a supervisor task retries the full
    /// connect+register flow with exponential backoff until the
    /// relay is reachable again.
    desired_relay_addr: Option<String>,
    /// Single-flight guard: `true` while the reconnect supervisor
    /// task is actively trying to re-establish the signal
    /// connection. Prevents duplicate supervisors from spawning
    /// (recv loop exit races with a manual register_signal call).
    reconnect_in_progress: bool,
    /// Phase 6: pending MediaPathReport from the peer. When the
    /// connect command sends its own report and waits for the
    /// peer's, it installs a oneshot sender here. The recv loop
    /// fires it when MediaPathReport arrives.
    pending_path_report: Option<tokio::sync::oneshot::Sender<bool>>,
    /// Phase 8.6: peer's HardNatProbe data, if received. The connect
    /// command reads this to generate predicted port candidates for
    /// sequential NATs.
    peer_hard_nat_probe: Option<PeerHardNatInfo>,
    /// Phase 8.6: peer's birthday attack ports, if received.
    peer_birthday_ports: Option<PeerBirthdayInfo>,
}

/// Parsed data from a peer's HardNatBirthdayStart signal.
#[derive(Debug, Clone)]
struct PeerBirthdayInfo {
    external_ip: std::net::IpAddr,
    ports: Vec<u16>,
}

/// Parsed data from a peer's HardNatProbe signal.
#[derive(Debug, Clone)]
struct PeerHardNatInfo {
    external_ip: std::net::IpAddr,
    port_sequence: Vec<u16>,
    allocation: String,
}

/// Parse "sequential(delta=N)" allocation string into the delta value.
fn parse_sequential_delta(allocation: &str) -> Option<i16> {
    let s = allocation.strip_prefix("sequential(delta=")?;
    let s = s.strip_suffix(')')?;
    s.parse().ok()
}

#[tauri::command]
async fn register_signal(
    state: tauri::State<'_, Arc<AppState>>,
    app: tauri::AppHandle,
    relay: String,
) -> Result<String, String> {
    // Set the desired relay and handle the "already registered to
    // a different relay" transition. This is the public entry
    // point — settings-screen changes come through here.
    let already_same = {
        let sig = state.signal.lock().await;
        sig.transport.is_some() && sig.desired_relay_addr.as_deref() == Some(relay.as_str())
    };
    if already_same {
        // Idempotent: user hit "Register" twice on the same relay,
        // or the JS side re-called after a settings save that
        // didn't actually change the relay.
        let sig = state.signal.lock().await;
        return Ok(sig.fingerprint.clone());
    }

    // Tear down any existing registration (different relay → swap).
    internal_deregister(&state.signal, /*keep_desired=*/ false).await;

    // Announce the new desired state so the recv-loop exit path and
    // any running supervisor can see it.
    {
        let mut sig = state.signal.lock().await;
        sig.desired_relay_addr = Some(relay.clone());
    }

    do_register_signal(state.signal.clone(), app, relay).await
}

/// Close the current signal transport + clear derived state.
/// Used by `deregister` (with `keep_desired = false`, clearing
/// `desired_relay_addr`) and by the relay-swap path in
/// `register_signal` (also `keep_desired = false` — the caller
/// is about to set a new desired addr).
async fn internal_deregister(
    signal_state: &Arc<tokio::sync::Mutex<SignalState>>,
    keep_desired: bool,
) {
    let mut sig = signal_state.lock().await;
    if let Some(t) = sig.transport.take() {
        // Dropping the transport Arc closes the quinn connection;
        // calling close() explicitly is a no-op but neat.
        let _ = t.close().await;
    }
    sig.endpoint = None;
    sig.ipv6_endpoint = None;
    sig.signal_status = "idle".into();
    sig.incoming_call_id = None;
    sig.incoming_caller_fp = None;
    sig.incoming_caller_alias = None;
    sig.pending_reflect = None;
    sig.pending_path_report = None;
    sig.own_reflex_addr = None;
    if !keep_desired {
        sig.desired_relay_addr = None;
    }
}

/// Core register flow, extracted so the Tauri command AND the
/// reconnect supervisor can both call it. Does the connect +
/// RegisterPresence + spawn-recv-loop dance.
///
/// Contract: `signal_state.desired_relay_addr` must already be
/// set to `Some(relay)` by the caller. On recv-loop exit, the
/// spawned task will check `desired_relay_addr` and (if still
/// Some) trigger the reconnect supervisor.
///
/// Explicit `+ Send` on the return type so the reconnect
/// supervisor (which lives inside a `tokio::spawn`) can await
/// this future without hitting auto-trait inference issues.
fn do_register_signal(
    signal_state: Arc<tokio::sync::Mutex<SignalState>>,
    app: tauri::AppHandle,
    relay: String,
) -> impl std::future::Future<Output = Result<String, String>> + Send {
    async move {
        use wzp_proto::{SignalMessage, default_signal_version};

        emit_call_debug(
            &app,
            "register_signal:start",
            serde_json::json!({ "relay": relay }),
        );
        let addr: std::net::SocketAddr = relay.parse().map_err(|e| format!("bad address: {e}"))?;
        let _ = rustls::crypto::ring::default_provider().install_default();

        // Load or create seed automatically — no need to "connect to a room first"
        let seed = load_or_create_seed()?;
        let pub_id = seed.derive_identity().public_identity();
        let fp = pub_id.fingerprint.to_string();
        let identity_pub = *pub_id.signing.as_bytes();
        emit_call_debug(
            &app,
            "register_signal:identity_loaded",
            serde_json::json!({ "fingerprint": fp }),
        );

        // Phase 5: single-socket Nebula-style architecture. The signal
        // endpoint is dual-purpose (client + server config). Every outbound
        // flow — signal, reflect probes, relay media dials, direct-P2P
        // dials — uses this same socket, so port-preserving NATs (MikroTik
        // masquerade is the big one) give us a stable external port that
        // peers can actually dial. The same socket also accepts incoming
        // direct-P2P connections during the dual-path race.
        //
        // Was `None` before Phase 5 — that produced a client-only endpoint
        // with a different internal port than later reflect / dual-path
        // endpoints, which made MikroTik look symmetric and broke direct
        // P2P because the advertised reflex port was not the listening
        // port.
        // 0.0.0.0:0 = IPv4. [::]:0 dual-stack was tried but breaks on
        // Android (IPV6_V6ONLY=1 on some kernels kills IPv4). IPv6
        // host candidates need a separate dedicated socket (future).
        let bind: std::net::SocketAddr = "0.0.0.0:0".parse().unwrap();
        let (server_cfg, _cert_der) = wzp_transport::server_config();
        let endpoint =
            wzp_transport::create_endpoint(bind, Some(server_cfg)).map_err(|e| format!("{e}"))?;
        emit_call_debug(
            &app,
            "register_signal:endpoint_created",
            serde_json::json!({ "bind": bind.to_string(), "build": GIT_HASH }),
        );
        let conn =
            wzp_transport::connect(&endpoint, addr, "_signal", wzp_transport::client_config())
                .await
                .map_err(|e| {
                    emit_call_debug(
                        &app,
                        "register_signal:connect_failed",
                        serde_json::json!({ "error": e.to_string() }),
                    );
                    format!("{e}")
                })?;
        let transport = Arc::new(wzp_transport::QuinnTransport::new(conn));
        emit_call_debug(
            &app,
            "register_signal:quic_connected",
            serde_json::json!({ "relay": relay }),
        );

        // Send alias from seed-derived adjective+noun so other
        // users see a friendly name in the lobby.
        let alias = derive_alias(&seed);
        transport
            .send_signal(&SignalMessage::RegisterPresence {
                version: default_signal_version(),
                identity_pub,
                signature: vec![],
                alias: Some(alias),
            })
            .await
            .map_err(|e| format!("{e}"))?;
        emit_call_debug(
            &app,
            "register_signal:register_presence_sent",
            serde_json::json!({}),
        );

        match transport.recv_signal().await.map_err(|e| format!("{e}"))? {
            Some(SignalMessage::RegisterPresenceAck {
                success: true,
                relay_build,
                ..
            }) => {
                emit_call_debug(
                    &app,
                    "register_signal:ack_received",
                    serde_json::json!({
                        "relay_build": relay_build,
                    }),
                );
            }
            _ => {
                emit_call_debug(&app, "register_signal:ack_failed", serde_json::json!({}));
                return Err("registration failed".into());
            }
        }

        {
            let mut sig = signal_state.lock().await;
            sig.transport = Some(transport.clone());
            sig.endpoint = Some(endpoint.clone());
            sig.fingerprint = fp.clone();
            sig.signal_status = "registered".into();
        }
        // Let the JS side know we've (re-)entered "registered" so any
        // "reconnecting..." banner can clear.
        let _ = app.emit(
            "signal-event",
            serde_json::json!({ "type": "registered", "fingerprint": fp }),
        );

        tracing::info!(%fp, "signal registered, spawning recv loop");
        emit_call_debug(
            &app,
            "register_signal:recv_loop_spawning",
            serde_json::json!({ "fingerprint": fp }),
        );
        let signal_state_loop = signal_state.clone();
        let app_clone = app.clone();
        tokio::spawn(async move {
            // Capture for the exit-path reconnect trigger below.
            let signal_state = signal_state_loop.clone();
            loop {
                match transport.recv_signal().await {
                    Ok(Some(SignalMessage::CallRinging { call_id, .. })) => {
                        tracing::info!(%call_id, "signal: CallRinging");
                        emit_call_debug(
                            &app_clone,
                            "recv:CallRinging",
                            serde_json::json!({ "call_id": call_id }),
                        );
                        let mut sig = signal_state.lock().await;
                        sig.signal_status = "ringing".into();
                        let _ = app_clone.emit(
                            "signal-event",
                            serde_json::json!({"type":"ringing","call_id":call_id}),
                        );
                    }
                    Ok(Some(SignalMessage::DirectCallOffer {
                        caller_fingerprint,
                        caller_alias,
                        call_id,
                        caller_reflexive_addr,
                        caller_build_version,
                        ..
                    })) => {
                        tracing::info!(%call_id, caller = %caller_fingerprint, peer_build = ?caller_build_version, "signal: DirectCallOffer");
                        emit_call_debug(
                            &app_clone,
                            "recv:DirectCallOffer",
                            serde_json::json!({
                                "call_id": call_id,
                                "caller_fp": caller_fingerprint,
                                "caller_alias": caller_alias,
                                "caller_reflexive_addr": caller_reflexive_addr,
                                "peer_build": caller_build_version,
                            }),
                        );
                        let mut sig = signal_state.lock().await;
                        sig.signal_status = "incoming".into();
                        sig.incoming_call_id = Some(call_id.clone());
                        sig.incoming_caller_fp = Some(caller_fingerprint.clone());
                        sig.incoming_caller_alias = caller_alias.clone();
                        // Log as a Missed entry up-front. If the user accepts
                        // the call, answer_call upgrades it to Received via
                        // history::mark_received_if_pending(call_id). If they
                        // reject or ignore, it stays Missed.
                        history::log(
                            call_id.clone(),
                            caller_fingerprint.clone(),
                            caller_alias.clone(),
                            history::CallDirection::Missed,
                        );
                        let _ = app_clone.emit("signal-event", serde_json::json!({"type":"incoming","call_id":call_id,"caller_fp":caller_fingerprint,"caller_alias":caller_alias}));
                        let _ = app_clone.emit("history-changed", ());
                    }
                    Ok(Some(SignalMessage::DirectCallAnswer {
                        call_id,
                        accept_mode,
                        callee_reflexive_addr,
                        callee_build_version,
                        ..
                    })) => {
                        tracing::info!(%call_id, ?accept_mode, peer_build = ?callee_build_version, "signal: DirectCallAnswer (forwarded by relay)");
                        emit_call_debug(
                            &app_clone,
                            "recv:DirectCallAnswer",
                            serde_json::json!({
                                "call_id": call_id,
                                "accept_mode": format!("{:?}", accept_mode),
                                "callee_reflexive_addr": callee_reflexive_addr,
                                "peer_build": callee_build_version,
                            }),
                        );
                    }
                    Ok(Some(SignalMessage::CallSetup {
                        call_id,
                        room,
                        relay_addr,
                        peer_direct_addr,
                        peer_local_addrs,
                        peer_mapped_addr,
                        ..
                    })) => {
                        // Phase 3: peer_direct_addr carries the OTHER party's
                        // reflex addr. Phase 5.5: peer_local_addrs carries
                        // their LAN host candidates (usable for same-LAN
                        // direct dials that can't hairpin through the NAT).
                        tracing::info!(
                            %call_id,
                            %room,
                            %relay_addr,
                            peer_direct = ?peer_direct_addr,
                            peer_local = ?peer_local_addrs,
                            "signal: CallSetup — emitting setup event to JS"
                        );
                        emit_call_debug(
                            &app_clone,
                            "recv:CallSetup",
                            serde_json::json!({
                                "call_id": call_id,
                                "room": room,
                                "relay_addr": relay_addr,
                                "peer_direct_addr": peer_direct_addr,
                                "peer_local_addrs": peer_local_addrs,
                                "peer_mapped_addr": peer_mapped_addr,
                            }),
                        );
                        let mut sig = signal_state.lock().await;
                        sig.signal_status = "setup".into();
                        let _ = app_clone.emit(
                            "signal-event",
                            serde_json::json!({
                                "type": "setup",
                                "call_id": call_id,
                                "room": room,
                                "relay_addr": relay_addr,
                                "peer_direct_addr": peer_direct_addr,
                                "peer_local_addrs": peer_local_addrs,
                                "peer_mapped_addr": peer_mapped_addr,
                            }),
                        );
                    }
                    Ok(Some(SignalMessage::Hangup { reason, .. })) => {
                        tracing::info!(?reason, "signal: Hangup");
                        emit_call_debug(
                            &app_clone,
                            "recv:Hangup",
                            serde_json::json!({ "reason": format!("{:?}", reason) }),
                        );
                        let mut sig = signal_state.lock().await;
                        sig.signal_status = "registered".into();
                        sig.incoming_call_id = None;
                        sig.ipv6_endpoint = None;
                        sig.pending_path_report = None;
                        let _ =
                            app_clone.emit("signal-event", serde_json::json!({"type":"hangup"}));
                    }
                    Ok(Some(SignalMessage::MediaPathReport {
                        call_id,
                        direct_ok,
                        race_winner,
                        ..
                    })) => {
                        // Phase 6: the peer is telling us whether
                        // their direct path succeeded. Fire the
                        // pending oneshot so the connect command can
                        // make the agreed decision.
                        tracing::info!(
                            %call_id,
                            direct_ok,
                            %race_winner,
                            "signal: MediaPathReport from peer"
                        );
                        emit_call_debug(
                            &app_clone,
                            "recv:MediaPathReport",
                            serde_json::json!({
                                "call_id": call_id,
                                "peer_direct_ok": direct_ok,
                                "peer_race_winner": race_winner,
                            }),
                        );
                        let mut sig = signal_state.lock().await;
                        if let Some(tx) = sig.pending_path_report.take() {
                            let _ = tx.send(direct_ok);
                        }
                    }
                    Ok(Some(SignalMessage::CandidateUpdate {
                        call_id,
                        reflexive_addr,
                        local_addrs,
                        mapped_addr,
                        generation,
                        ..
                    })) => {
                        // Phase 8: peer re-gathered candidates after a
                        // network change. Emit to JS for UI notification
                        // and potential transport re-race.
                        tracing::info!(
                            %call_id,
                            generation,
                            reflexive = ?reflexive_addr,
                            mapped = ?mapped_addr,
                            local_count = local_addrs.len(),
                            "signal: CandidateUpdate from peer"
                        );
                        emit_call_debug(
                            &app_clone,
                            "recv:CandidateUpdate",
                            serde_json::json!({
                                "call_id": call_id,
                                "generation": generation,
                                "reflexive_addr": reflexive_addr,
                                "local_addrs": local_addrs,
                                "mapped_addr": mapped_addr,
                            }),
                        );
                        let _ = app_clone.emit(
                            "signal-event",
                            serde_json::json!({
                                "type": "candidate_update",
                                "call_id": call_id,
                                "generation": generation,
                                "reflexive_addr": reflexive_addr,
                                "local_addrs": local_addrs,
                                "mapped_addr": mapped_addr,
                            }),
                        );
                        // TODO Phase 8: use IceAgent.apply_peer_update() +
                        // race_upgrade() to attempt transport hot-swap
                    }
                    Ok(Some(SignalMessage::HardNatProbe {
                        call_id,
                        port_sequence,
                        allocation,
                        probe_time_ms,
                        external_ip,
                        ..
                    })) => {
                        tracing::info!(
                            %call_id,
                            %allocation,
                            ports = ?port_sequence,
                            %external_ip,
                            probe_time_ms,
                            "signal: HardNatProbe from peer"
                        );
                        emit_call_debug(
                            &app_clone,
                            "recv:HardNatProbe",
                            serde_json::json!({
                                "call_id": call_id,
                                "allocation": allocation,
                                "port_sequence": port_sequence,
                                "external_ip": external_ip,
                            }),
                        );
                        // Stash for the connect command to use in port prediction
                        if let Ok(ip) = external_ip.parse::<std::net::IpAddr>() {
                            let mut sig = signal_state.lock().await;
                            sig.peer_hard_nat_probe = Some(PeerHardNatInfo {
                                external_ip: ip,
                                port_sequence: port_sequence.clone(),
                                allocation: allocation.clone(),
                            });
                        }

                        // If peer has a random/symmetric NAT and WE are the
                        // Acceptor, open birthday attack ports and send
                        // BirthdayStart so the peer can spray us.
                        if allocation == "random" || allocation.starts_with("sequential") {
                            let state_bg = signal_state.clone();
                            let app_bg = app_clone.clone();
                            let call_id_bg = call_id.clone();
                            tokio::spawn(async move {
                                let config = wzp_client::birthday::BirthdayConfig::default();
                                let (result, _sockets) =
                                    wzp_client::birthday::open_acceptor_ports(&config).await;
                                if result.succeeded > 0 {
                                    let ext_ports: Vec<u16> =
                                        result.ports.iter().map(|p| p.external_port).collect();
                                    let ext_ip = result
                                        .external_ip
                                        .map(|ip| ip.to_string())
                                        .unwrap_or_default();
                                    emit_call_debug(
                                        &app_bg,
                                        "birthday:acceptor_ports_opened",
                                        serde_json::json!({
                                            "succeeded": result.succeeded,
                                            "external_ip": ext_ip,
                                            "ports": ext_ports,
                                        }),
                                    );
                                    let sig = state_bg.lock().await;
                                    if let Some(ref t) = sig.transport {
                                        let _ = t
                                            .send_signal(
                                                &wzp_proto::SignalMessage::HardNatBirthdayStart {
                                                    version: default_signal_version(),
                                                    call_id: call_id_bg,
                                                    acceptor_port_count: result.succeeded,
                                                    acceptor_ports: ext_ports,
                                                    external_ip: ext_ip,
                                                },
                                            )
                                            .await;
                                    }
                                    // Keep _sockets alive for 10s so NAT mappings persist
                                    tokio::time::sleep(std::time::Duration::from_secs(10)).await;
                                }
                            });
                        }
                    }
                    Ok(Some(SignalMessage::PresenceList { users, .. })) => {
                        tracing::info!(count = users.len(), "signal: PresenceList received");
                        // Emit to JS frontend for lobby user list
                        let user_list: Vec<serde_json::Value> = users
                            .iter()
                            .map(|u| {
                                serde_json::json!({
                                    "fingerprint": u.fingerprint,
                                    "alias": u.alias,
                                })
                            })
                            .collect();
                        let _ = app_clone.emit(
                            "signal-event",
                            serde_json::json!({
                                "type": "presence_list",
                                "users": user_list,
                            }),
                        );
                    }
                    Ok(Some(SignalMessage::UpgradeProposal {
                        call_id,
                        proposal_id,
                        proposed_profile,
                        local_loss_pct,
                        local_rtt_ms,
                        ..
                    })) => {
                        tracing::info!(%call_id, %proposal_id, ?proposed_profile, "signal: UpgradeProposal from peer");
                        emit_call_debug(
                            &app_clone,
                            "recv:UpgradeProposal",
                            serde_json::json!({
                                "call_id": call_id, "proposal_id": proposal_id,
                                "proposed_profile": format!("{proposed_profile:?}"),
                                "peer_loss_pct": local_loss_pct, "peer_rtt_ms": local_rtt_ms,
                            }),
                        );
                        // TODO: auto-accept if our own quality supports it,
                        // or surface to UI for manual accept/reject
                    }
                    Ok(Some(SignalMessage::UpgradeResponse {
                        call_id,
                        proposal_id,
                        accepted,
                        reason,
                        ..
                    })) => {
                        tracing::info!(%call_id, %proposal_id, accepted, ?reason, "signal: UpgradeResponse from peer");
                        emit_call_debug(
                            &app_clone,
                            "recv:UpgradeResponse",
                            serde_json::json!({
                                "call_id": call_id, "proposal_id": proposal_id,
                                "accepted": accepted, "reason": reason,
                            }),
                        );
                        // TODO: if accepted, send UpgradeConfirm + switch encoder
                    }
                    Ok(Some(SignalMessage::UpgradeConfirm {
                        call_id,
                        proposal_id,
                        confirmed_profile,
                        ..
                    })) => {
                        tracing::info!(%call_id, %proposal_id, ?confirmed_profile, "signal: UpgradeConfirm");
                        emit_call_debug(
                            &app_clone,
                            "recv:UpgradeConfirm",
                            serde_json::json!({
                                "call_id": call_id, "proposal_id": proposal_id,
                                "confirmed_profile": format!("{confirmed_profile:?}"),
                            }),
                        );
                        // TODO: switch encoder to confirmed_profile at next frame boundary
                    }
                    Ok(Some(SignalMessage::QualityCapability {
                        call_id,
                        max_profile,
                        loss_pct,
                        rtt_ms,
                        ..
                    })) => {
                        tracing::info!(%call_id, ?max_profile, "signal: QualityCapability from peer");
                        emit_call_debug(
                            &app_clone,
                            "recv:QualityCapability",
                            serde_json::json!({
                                "call_id": call_id,
                                "peer_max_profile": format!("{max_profile:?}"),
                                "peer_loss_pct": loss_pct, "peer_rtt_ms": rtt_ms,
                            }),
                        );
                        // TODO: adjust our encoder to not exceed peer's max_profile
                        // (asymmetric quality — each side encodes at its own best)
                    }
                    Ok(Some(SignalMessage::HardNatBirthdayStart {
                        call_id,
                        acceptor_port_count,
                        acceptor_ports,
                        external_ip,
                        ..
                    })) => {
                        tracing::info!(
                            %call_id,
                            acceptor_port_count,
                            port_count = acceptor_ports.len(),
                            %external_ip,
                            "signal: HardNatBirthdayStart from peer"
                        );
                        emit_call_debug(
                            &app_clone,
                            "recv:HardNatBirthdayStart",
                            serde_json::json!({
                                "call_id": call_id,
                                "acceptor_port_count": acceptor_port_count,
                                "acceptor_ports": acceptor_ports,
                                "external_ip": external_ip,
                            }),
                        );
                        // Stash for the connect command (if still running)
                        // or for a background spray after relay fallback.
                        if let Ok(ip) = external_ip.parse::<std::net::IpAddr>() {
                            let mut sig = signal_state.lock().await;
                            sig.peer_birthday_ports = Some(PeerBirthdayInfo {
                                external_ip: ip,
                                ports: acceptor_ports,
                            });
                        }
                    }
                    Ok(Some(SignalMessage::ReflectResponse { observed_addr, .. })) => {
                        // "STUN for QUIC" response — the relay told us our
                        // own server-reflexive address. If a Tauri command
                        // is currently awaiting this, fire the oneshot;
                        // otherwise log and drop (unsolicited responses
                        // from a confused relay shouldn't crash the loop).
                        tracing::info!(%observed_addr, "signal: ReflectResponse");
                        match observed_addr.parse::<std::net::SocketAddr>() {
                            Ok(parsed) => {
                                let mut sig = signal_state.lock().await;
                                if let Some(tx) = sig.pending_reflect.take() {
                                    // `send` returns Err(addr) only if the
                                    // receiver was dropped (caller timed out
                                    // or canceled). Either way, nothing to
                                    // do — the value is gone.
                                    let _ = tx.send(parsed);
                                } else {
                                    tracing::debug!(%observed_addr, "reflect: unsolicited response (no pending sender)");
                                }
                                let _ = app_clone.emit(
                                "signal-event",
                                serde_json::json!({"type":"reflect","observed_addr":observed_addr}),
                            );
                            }
                            Err(e) => {
                                tracing::warn!(%observed_addr, error = %e, "reflect: relay returned unparseable addr");
                                // Treat unparseable response as a failed
                                // request so the caller doesn't hang.
                                let mut sig = signal_state.lock().await;
                                let _ = sig.pending_reflect.take();
                            }
                        }
                    }
                    Ok(Some(other)) => {
                        tracing::debug!(?other, "signal: unhandled message");
                    }
                    Ok(None) => {
                        tracing::warn!("signal recv returned None — peer closed");
                        break;
                    }
                    Err(wzp_proto::TransportError::Deserialize(e)) => {
                        // Forward-compat: the relay sent us a
                        // SignalMessage variant we don't know yet
                        // (older client against a newer relay).
                        // Log and keep the signal connection alive —
                        // otherwise direct-call registration would
                        // silently die on any protocol bump.
                        tracing::warn!(error = %e, "signal recv: unknown variant, continuing");
                    }
                    Err(e) => {
                        tracing::warn!(error = %e, "signal recv error — breaking loop");
                        break;
                    }
                }
            }
            tracing::warn!("signal recv loop exited — signal_status=idle, transport dropped");
            // Determine whether this was a user-requested close or an
            // unexpected drop. `desired_relay_addr.is_some()` means the
            // user still wants to be registered — spawn the reconnect
            // supervisor with exponential backoff.
            let (should_reconnect, desired_relay, already_reconnecting) = {
                let mut sig = signal_state.lock().await;
                sig.signal_status = "idle".into();
                sig.transport = None;
                (
                    sig.desired_relay_addr.is_some(),
                    sig.desired_relay_addr.clone(),
                    sig.reconnect_in_progress,
                )
            };
            if should_reconnect && !already_reconnecting {
                if let Some(relay) = desired_relay {
                    tracing::info!(%relay, "signal recv loop exited unexpectedly — spawning reconnect supervisor");
                    emit_call_debug(
                        &app_clone,
                        "signal:reconnect_supervisor_spawning",
                        serde_json::json!({ "relay": relay }),
                    );
                    let _ = app_clone.emit(
                        "signal-event",
                        serde_json::json!({ "type": "reconnecting", "relay": relay }),
                    );
                    let state_for_sup = signal_state.clone();
                    let app_for_sup = app_clone.clone();
                    tokio::spawn(async move {
                        signal_reconnect_supervisor(state_for_sup, app_for_sup, relay).await;
                    });
                }
            } else if should_reconnect && already_reconnecting {
                tracing::debug!("signal recv loop exited; reconnect supervisor already running");
            }
        });
        Ok(fp)
    } // end async move
} // end fn do_register_signal

/// Supervisor task: loops with exponential backoff, calling
/// `do_register_signal` until the relay comes back online. Exits
/// as soon as one attempt succeeds (the newly-spawned recv loop
/// owns the connection from that point on) OR the user clears
/// `desired_relay_addr` via `deregister`.
///
/// Backoff schedule: 1s, 2s, 4s, 8s, 15s, 30s (capped). Reset on
/// success or exit.
async fn signal_reconnect_supervisor(
    signal_state: Arc<tokio::sync::Mutex<SignalState>>,
    app: tauri::AppHandle,
    initial_relay: String,
) {
    // Claim the single-flight slot so a second exit-path trigger
    // or a manual register_signal doesn't spawn a duplicate.
    {
        let mut sig = signal_state.lock().await;
        if sig.reconnect_in_progress {
            tracing::debug!("reconnect supervisor: another already running, exiting");
            return;
        }
        sig.reconnect_in_progress = true;
    }

    let backoff_schedule_ms: [u64; 6] = [1_000, 2_000, 4_000, 8_000, 15_000, 30_000];
    let mut attempt: usize = 0;
    let mut current_relay = initial_relay;

    loop {
        // Has the user cleared the desired relay? If so, exit.
        let (desired, transport_is_some) = {
            let sig = signal_state.lock().await;
            (sig.desired_relay_addr.clone(), sig.transport.is_some())
        };
        let Some(desired) = desired else {
            tracing::info!("reconnect supervisor: desired_relay_addr cleared, exiting");
            break;
        };

        // Has something else already re-registered us (manual
        // register_signal won the race)? If so, exit.
        if transport_is_some {
            tracing::info!("reconnect supervisor: transport already set by another path, exiting");
            break;
        }

        // Has the desired relay changed under us? Switch to the new one.
        if desired != current_relay {
            tracing::info!(old = %current_relay, new = %desired, "reconnect supervisor: desired relay changed");
            current_relay = desired.clone();
            attempt = 0;
        }

        // Back off before the retry (skip on attempt 0 so the first
        // reconnect kicks in fast).
        if attempt > 0 {
            let idx = (attempt - 1).min(backoff_schedule_ms.len() - 1);
            let wait_ms = backoff_schedule_ms[idx];
            tracing::info!(
                attempt,
                wait_ms,
                relay = %current_relay,
                "reconnect supervisor: backing off"
            );
            emit_call_debug(
                &app,
                "signal:reconnect_backoff",
                serde_json::json!({ "attempt": attempt, "wait_ms": wait_ms, "relay": current_relay }),
            );
            tokio::time::sleep(std::time::Duration::from_millis(wait_ms)).await;
        }
        attempt += 1;

        // One-shot attempt. do_register_signal will set the
        // transport + spawn a fresh recv loop on success.
        //
        // CRITICAL: release our single-flight guard BEFORE
        // do_register_signal spawns the new recv loop, because that
        // recv loop's exit path also checks `reconnect_in_progress`
        // to decide whether to spawn a supervisor of its own. If we
        // held it here and later exited, the slot would be released
        // too late for the next drop to trigger a fresh supervisor.
        {
            let mut sig = signal_state.lock().await;
            sig.reconnect_in_progress = false;
        }

        emit_call_debug(
            &app,
            "signal:reconnect_attempt",
            serde_json::json!({ "attempt": attempt, "relay": current_relay }),
        );
        match do_register_signal(signal_state.clone(), app.clone(), current_relay.clone()).await {
            Ok(fp) => {
                tracing::info!(%fp, relay = %current_relay, "reconnect supervisor: success");
                emit_call_debug(
                    &app,
                    "signal:reconnect_ok",
                    serde_json::json!({ "fingerprint": fp, "relay": current_relay }),
                );
                return; // recv loop now owns the connection
            }
            Err(e) => {
                tracing::warn!(error = %e, relay = %current_relay, "reconnect supervisor: attempt failed");
                emit_call_debug(
                    &app,
                    "signal:reconnect_failed",
                    serde_json::json!({ "attempt": attempt, "error": e, "relay": current_relay }),
                );
                // Re-claim the single-flight slot for the next iteration.
                let mut sig = signal_state.lock().await;
                sig.reconnect_in_progress = true;
            }
        }
    }

    // Loop exited — clean up the slot if we still hold it.
    let mut sig = signal_state.lock().await;
    sig.reconnect_in_progress = false;
}

#[tauri::command]
async fn place_call(
    state: tauri::State<'_, Arc<AppState>>,
    app: tauri::AppHandle,
    target_fp: String,
) -> Result<(), String> {
    use wzp_proto::{SignalMessage, default_signal_version};

    emit_call_debug(
        &app,
        "place_call:start",
        serde_json::json!({ "target_fp": target_fp }),
    );

    // Phase 3 hole-punching: query our own reflex addr BEFORE the
    // offer so we can advertise it. Best-effort — a failed reflect
    // (old relay, transient error) falls back to `None` which
    // means the callee's CallSetup will have peer_direct_addr=None
    // and the whole call goes through the relay path unchanged.
    //
    // Critical: this call does its own state.signal.lock() usage and
    // MUST NOT be wrapped in an outer lock, or the recv loop's
    // ReflectResponse handler will deadlock on the same mutex.
    emit_call_debug(
        &app,
        "place_call:reflect_query_start",
        serde_json::json!({}),
    );
    let state_inner: Arc<AppState> = (*state).clone();
    let own_reflex = try_reflect_own_addr(&state_inner).await.ok().flatten();
    if let Some(ref a) = own_reflex {
        tracing::info!(%a, "place_call: learned own reflex addr for hole-punching advertisement");
        emit_call_debug(
            &app,
            "place_call:reflect_query_ok",
            serde_json::json!({ "addr": a }),
        );
    } else {
        tracing::info!("place_call: no reflex addr available, falling back to relay-only");
        emit_call_debug(&app, "place_call:reflect_query_none", serde_json::json!({}));
    }

    // Phase 5.5 + 7: gather LAN host candidates. Create a
    // per-call IPv6 endpoint so we can advertise v6 candidates
    // with the correct port.
    let caller_local_addrs: Vec<String> = {
        let mut sig = state.signal.lock().await;
        let v4_port = sig
            .endpoint
            .as_ref()
            .and_then(|ep| ep.local_addr().ok())
            .map(|la| la.port())
            .unwrap_or(0);

        // Phase 7: create IPv6 endpoint, trying same port as v4.
        // Close any leftover from a previous call first.
        if let Some(old) = sig.ipv6_endpoint.take() {
            old.close(0u32.into(), b"new-call");
        }
        let (sc, _) = wzp_transport::server_config();
        let v6_ep = wzp_transport::create_ipv6_endpoint(v4_port, Some(sc)).ok();
        let v6_port = v6_ep
            .as_ref()
            .and_then(|ep| ep.local_addr().ok())
            .map(|a| a.port());
        if let Some(ref ep) = v6_ep {
            tracing::info!(
                v4_port,
                v6_port,
                v6_local = ?ep.local_addr().ok(),
                "place_call: IPv6 endpoint created for dual-stack P2P"
            );
        }
        sig.ipv6_endpoint = v6_ep;

        wzp_client::reflect::local_host_candidates(v4_port, v6_port)
            .into_iter()
            .map(|a| a.to_string())
            .collect()
    };
    emit_call_debug(
        &app,
        "place_call:host_candidates",
        serde_json::json!({
            "local_addrs": caller_local_addrs,
        }),
    );

    // Phase 8: attempt port mapping for symmetric NAT traversal.
    // This is best-effort — if the router doesn't support NAT-PMP/PCP/UPnP,
    // we fall back to reflexive + host candidates only.
    let caller_mapped_addr: Option<String> = {
        let v4_port = state
            .signal
            .lock()
            .await
            .endpoint
            .as_ref()
            .and_then(|ep| ep.local_addr().ok())
            .map(|la| la.port())
            .unwrap_or(0);
        if v4_port > 0 {
            match wzp_client::portmap::acquire_port_mapping(v4_port, None).await {
                Ok(mapping) => {
                    let addr = mapping.external_addr.to_string();
                    tracing::info!(%addr, protocol = ?mapping.protocol, "place_call: port mapping acquired");
                    emit_call_debug(
                        &app,
                        "place_call:portmap_ok",
                        serde_json::json!({
                            "addr": addr, "protocol": format!("{:?}", mapping.protocol),
                        }),
                    );
                    Some(addr)
                }
                Err(e) => {
                    tracing::debug!(error = %e, "place_call: port mapping unavailable (normal on most networks)");
                    None
                }
            }
        } else {
            None
        }
    };

    let sig = state.signal.lock().await;
    let transport = sig.transport.as_ref().ok_or("not registered")?;
    let call_id = format!(
        "{:016x}",
        std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap()
            .as_nanos()
    );
    tracing::info!(%call_id, %target_fp, reflex = ?own_reflex, mapped = ?caller_mapped_addr, "place_call: sending DirectCallOffer");
    transport
        .send_signal(&SignalMessage::DirectCallOffer {
            version: default_signal_version(),
            caller_fingerprint: sig.fingerprint.clone(),
            caller_alias: None,
            target_fingerprint: target_fp.clone(),
            call_id: call_id.clone(),
            identity_pub: [0u8; 32],
            ephemeral_pub: [0u8; 32],
            signature: vec![],
            supported_profiles: vec![wzp_proto::QualityProfile::GOOD],
            caller_reflexive_addr: own_reflex.clone(),
            caller_local_addrs: caller_local_addrs.clone(),
            caller_mapped_addr: caller_mapped_addr.clone(),
            caller_build_version: Some(GIT_HASH.to_string()),
        })
        .await
        .map_err(|e| {
            emit_call_debug(
                &app,
                "place_call:send_failed",
                serde_json::json!({ "error": e.to_string() }),
            );
            format!("{e}")
        })?;
    emit_call_debug(
        &app,
        "place_call:offer_sent",
        serde_json::json!({
            "call_id": call_id,
            "target_fp": target_fp,
            "caller_reflexive_addr": own_reflex,
        }),
    );

    // Phase 8.6: spawn background port allocation detection + HardNatProbe.
    // This runs AFTER the offer is sent so it doesn't delay call setup.
    // The probe result arrives at the peer before or during the connect
    // command, giving both sides time to compute predicted ports.
    {
        let state_bg = (*state).clone();
        let call_id_bg = call_id.clone();
        tokio::spawn(async move {
            let stun_config = wzp_client::stun::StunConfig {
                servers: vec![
                    "stun.l.google.com:19302".into(),
                    "stun1.l.google.com:19302".into(),
                    "stun.cloudflare.com:3478".into(),
                ],
                timeout: std::time::Duration::from_secs(2),
            };
            let result = wzp_client::stun::detect_port_allocation(&stun_config).await;
            let alloc_str = result.allocation.to_string();
            tracing::info!(
                allocation = %alloc_str,
                ports = ?result.observed_ports,
                "place_call: port allocation detected, sending HardNatProbe"
            );
            let sig = state_bg.signal.lock().await;
            if let Some(ref t) = sig.transport {
                let _ = t
                    .send_signal(&SignalMessage::HardNatProbe {
                        version: default_signal_version(),
                        call_id: call_id_bg,
                        port_sequence: result.observed_ports,
                        allocation: alloc_str,
                        probe_time_ms: std::time::SystemTime::now()
                            .duration_since(std::time::UNIX_EPOCH)
                            .unwrap_or_default()
                            .as_millis() as u64,
                        external_ip: result
                            .external_ip
                            .map(|ip| ip.to_string())
                            .unwrap_or_default(),
                    })
                    .await;
            }
        });
    }

    history::log(call_id, target_fp, None, history::CallDirection::Placed);
    let _ = app.emit("history-changed", ());
    Ok(())
}

#[tauri::command]
async fn answer_call(
    state: tauri::State<'_, Arc<AppState>>,
    app: tauri::AppHandle,
    call_id: String,
    mode: i32,
) -> Result<(), String> {
    use wzp_proto::{SignalMessage, default_signal_version};
    let accept_mode = match mode {
        0 => wzp_proto::CallAcceptMode::Reject,
        1 => wzp_proto::CallAcceptMode::AcceptTrusted,
        _ => wzp_proto::CallAcceptMode::AcceptGeneric,
    };
    emit_call_debug(
        &app,
        "answer_call:start",
        serde_json::json!({
            "call_id": call_id,
            "accept_mode": format!("{:?}", accept_mode),
        }),
    );

    // Phase 3 hole-punching: only AcceptTrusted reveals our reflex
    // addr. Privacy-mode (AcceptGeneric) and Reject explicitly do
    // NOT — leaking the callee's IP back to the caller in those
    // modes would defeat the entire point of AcceptGeneric.
    //
    // Like place_call, we MUST NOT hold state.signal.lock() across
    // the reflect await or the recv loop's ReflectResponse handler
    // will deadlock on the same mutex.
    let own_reflex = if accept_mode == wzp_proto::CallAcceptMode::AcceptTrusted {
        emit_call_debug(
            &app,
            "answer_call:reflect_query_start",
            serde_json::json!({}),
        );
        let state_inner: Arc<AppState> = (*state).clone();
        let r = try_reflect_own_addr(&state_inner).await.ok().flatten();
        if let Some(ref a) = r {
            tracing::info!(%call_id, %a, "answer_call: learned own reflex addr for AcceptTrusted");
            emit_call_debug(
                &app,
                "answer_call:reflect_query_ok",
                serde_json::json!({ "addr": a }),
            );
        } else {
            tracing::info!(%call_id, "answer_call: no reflex addr for AcceptTrusted, falling back to relay-only");
            emit_call_debug(
                &app,
                "answer_call:reflect_query_none",
                serde_json::json!({}),
            );
        }
        r
    } else {
        // Reject / AcceptGeneric: keep the IP private.
        emit_call_debug(
            &app,
            "answer_call:privacy_mode_skip_reflect",
            serde_json::json!({}),
        );
        None
    };

    // Phase 5.5 + 7: gather LAN host candidates (AcceptTrusted
    // only — privacy mode keeps LAN addrs hidden).
    let callee_local_addrs: Vec<String> = if accept_mode == wzp_proto::CallAcceptMode::AcceptTrusted
    {
        let mut sig = state.signal.lock().await;
        let v4_port = sig
            .endpoint
            .as_ref()
            .and_then(|ep| ep.local_addr().ok())
            .map(|la| la.port())
            .unwrap_or(0);

        // Phase 7: create IPv6 endpoint. Close leftover first.
        if let Some(old) = sig.ipv6_endpoint.take() {
            old.close(0u32.into(), b"new-call");
        }
        let (sc, _) = wzp_transport::server_config();
        let v6_ep = wzp_transport::create_ipv6_endpoint(v4_port, Some(sc)).ok();
        let v6_port = v6_ep
            .as_ref()
            .and_then(|ep| ep.local_addr().ok())
            .map(|a| a.port());
        if let Some(ref ep) = v6_ep {
            tracing::info!(
                v4_port,
                v6_port,
                v6_local = ?ep.local_addr().ok(),
                "answer_call: IPv6 endpoint created for dual-stack P2P"
            );
        }
        sig.ipv6_endpoint = v6_ep;

        wzp_client::reflect::local_host_candidates(v4_port, v6_port)
            .into_iter()
            .map(|a| a.to_string())
            .collect()
    } else {
        Vec::new()
    };
    emit_call_debug(
        &app,
        "answer_call:host_candidates",
        serde_json::json!({
            "local_addrs": callee_local_addrs,
        }),
    );

    // Phase 8: attempt port mapping (AcceptTrusted only — privacy mode
    // keeps the mapped addr hidden too).
    let callee_mapped_addr: Option<String> =
        if accept_mode == wzp_proto::CallAcceptMode::AcceptTrusted {
            let v4_port = state
                .signal
                .lock()
                .await
                .endpoint
                .as_ref()
                .and_then(|ep| ep.local_addr().ok())
                .map(|la| la.port())
                .unwrap_or(0);
            if v4_port > 0 {
                match wzp_client::portmap::acquire_port_mapping(v4_port, None).await {
                    Ok(mapping) => {
                        tracing::info!(
                            addr = %mapping.external_addr,
                            protocol = ?mapping.protocol,
                            "answer_call: port mapping acquired"
                        );
                        Some(mapping.external_addr.to_string())
                    }
                    Err(e) => {
                        tracing::debug!(error = %e, "answer_call: port mapping unavailable");
                        None
                    }
                }
            } else {
                None
            }
        } else {
            None
        };

    let sig = state.signal.lock().await;
    let transport = sig.transport.as_ref().ok_or_else(|| {
        tracing::warn!("answer_call: not registered (no transport)");
        "not registered".to_string()
    })?;
    tracing::info!(%call_id, ?accept_mode, reflex = ?own_reflex, mapped = ?callee_mapped_addr, "answer_call: sending DirectCallAnswer");
    transport
        .send_signal(&SignalMessage::DirectCallAnswer {
            version: default_signal_version(),
            call_id: call_id.clone(),
            accept_mode,
            identity_pub: None,
            ephemeral_pub: None,
            signature: None,
            chosen_profile: Some(wzp_proto::QualityProfile::GOOD),
            callee_reflexive_addr: own_reflex.clone(),
            callee_local_addrs: callee_local_addrs.clone(),
            callee_mapped_addr,
            callee_build_version: Some(GIT_HASH.to_string()),
        })
        .await
        .map_err(|e| {
            tracing::error!(%call_id, error = %e, "answer_call: send_signal failed");
            emit_call_debug(
                &app,
                "answer_call:send_failed",
                serde_json::json!({ "error": e.to_string() }),
            );
            format!("{e}")
        })?;
    tracing::info!(%call_id, "answer_call: DirectCallAnswer sent successfully");
    emit_call_debug(
        &app,
        "answer_call:answer_sent",
        serde_json::json!({
            "call_id": call_id,
            "accept_mode": format!("{:?}", accept_mode),
            "callee_reflexive_addr": own_reflex,
        }),
    );
    // Upgrade the pending "Missed" entry to "Received" if the user
    // accepted (mode != Reject). Mode 0 = Reject → leave as Missed.
    if mode != 0 && history::mark_received_if_pending(&call_id) {
        let _ = app.emit("history-changed", ());
    }

    // Phase 8.6: send HardNatProbe (AcceptTrusted only — same
    // privacy gate as reflexive addr).
    if accept_mode == wzp_proto::CallAcceptMode::AcceptTrusted {
        let state_bg = (*state).clone();
        let call_id_bg = call_id.clone();
        tokio::spawn(async move {
            let stun_config = wzp_client::stun::StunConfig {
                servers: vec![
                    "stun.l.google.com:19302".into(),
                    "stun1.l.google.com:19302".into(),
                    "stun.cloudflare.com:3478".into(),
                ],
                timeout: std::time::Duration::from_secs(2),
            };
            let result = wzp_client::stun::detect_port_allocation(&stun_config).await;
            let alloc_str = result.allocation.to_string();
            tracing::info!(
                allocation = %alloc_str,
                ports = ?result.observed_ports,
                "answer_call: port allocation detected, sending HardNatProbe"
            );
            let sig = state_bg.signal.lock().await;
            if let Some(ref t) = sig.transport {
                let _ = t
                    .send_signal(&wzp_proto::SignalMessage::HardNatProbe {
                        version: default_signal_version(),
                        call_id: call_id_bg,
                        port_sequence: result.observed_ports,
                        allocation: alloc_str,
                        probe_time_ms: std::time::SystemTime::now()
                            .duration_since(std::time::UNIX_EPOCH)
                            .unwrap_or_default()
                            .as_millis() as u64,
                        external_ip: result
                            .external_ip
                            .map(|ip| ip.to_string())
                            .unwrap_or_default(),
                    })
                    .await;
            }
        });
    }

    Ok(())
}

/// Internal reflect helper shared by `get_reflected_address` and the
/// hole-punching path in `place_call` / `answer_call`.
///
/// Must be called WITHOUT holding `state.signal.lock()` — the recv
/// loop acquires the same lock to fire the oneshot, so holding it
/// across the await would deadlock.
///
/// Returns `Ok(Some(addr))` on success, `Ok(None)` if reflect is
/// unsupported / timed out / transport failed (caller should
/// gracefully continue with a relay-only path), or `Err` on
/// "not registered" which is a hard precondition failure.
///
/// Phase 8 (Tailscale-inspired): if relay-based reflection fails,
/// falls back to public STUN servers for independent reflexive
/// discovery. This handles the case where the relay is overloaded
/// or temporarily unreachable for reflect but the call can still
/// proceed with STUN-discovered addresses.
async fn try_reflect_own_addr(state: &Arc<AppState>) -> Result<Option<String>, String> {
    use wzp_proto::{SignalMessage, default_signal_version};
    let (tx, rx) = tokio::sync::oneshot::channel::<std::net::SocketAddr>();
    let transport = {
        let mut sig = state.signal.lock().await;
        sig.pending_reflect = Some(tx);
        sig.transport
            .as_ref()
            .ok_or_else(|| "not registered".to_string())?
            .clone()
    };
    if let Err(e) = transport.send_signal(&SignalMessage::Reflect).await {
        let mut sig = state.signal.lock().await;
        sig.pending_reflect = None;
        tracing::warn!(error = %e, "try_reflect_own_addr: send_signal failed, falling back to STUN");
        return try_stun_fallback(state).await;
    }
    match tokio::time::timeout(std::time::Duration::from_millis(1000), rx).await {
        Ok(Ok(addr)) => {
            // Phase 3.5: cache the result on SignalState so the
            // `connect` command can read it later for role
            // determination without another reflect round-trip.
            let s = addr.to_string();
            {
                let mut sig = state.signal.lock().await;
                sig.own_reflex_addr = Some(s.clone());
            }
            Ok(Some(s))
        }
        Ok(Err(_canceled)) => {
            tracing::warn!("try_reflect_own_addr: oneshot canceled, falling back to STUN");
            try_stun_fallback(state).await
        }
        Err(_elapsed) => {
            let mut sig = state.signal.lock().await;
            sig.pending_reflect = None;
            tracing::warn!("try_reflect_own_addr: 1s timeout, falling back to STUN");
            try_stun_fallback(state).await
        }
    }
}

/// STUN fallback for reflexive address discovery when relay-based
/// reflection fails. Queries public STUN servers independently.
async fn try_stun_fallback(state: &Arc<AppState>) -> Result<Option<String>, String> {
    let stun_config = wzp_client::stun::StunConfig {
        servers: vec![
            "stun.l.google.com:19302".into(),
            "stun1.l.google.com:19302".into(),
        ],
        timeout: std::time::Duration::from_secs(2),
    };
    match wzp_client::stun::discover_reflexive(&stun_config).await {
        Ok(addr) => {
            let s = addr.to_string();
            tracing::info!(addr = %s, "STUN fallback: discovered reflexive address");
            {
                let mut sig = state.signal.lock().await;
                sig.own_reflex_addr = Some(s.clone());
            }
            Ok(Some(s))
        }
        Err(e) => {
            tracing::warn!(error = %e, "STUN fallback also failed, continuing without reflex addr");
            Ok(None)
        }
    }
}

/// "STUN for QUIC" — ask the relay what our own public address looks
/// like from its side of the TLS-authenticated signal connection.
///
/// Wire flow:
///   1. We install a `oneshot::Sender` in `SignalState.pending_reflect`
///      (replacing any stale one — last request wins).
///   2. We release the state lock and send `SignalMessage::Reflect`
///      over the existing transport. The relay opens a fresh bi-stream
///      on its side to respond, which the spawned recv loop picks up.
///   3. The recv loop's `ReflectResponse` match arm takes the sender
///      back out and fires it with the parsed `SocketAddr`.
///   4. We await the receiver with a 1s timeout so a non-reflecting
///      relay (pre-Phase-1 build) doesn't hang the UI forever.
///
/// Returns the addr as a string so it can cross the Tauri IPC
/// boundary unchanged — JS-side can display it directly or parse it
/// with `new URL(...)` / a regex if needed.
#[tauri::command]
async fn get_reflected_address(state: tauri::State<'_, Arc<AppState>>) -> Result<String, String> {
    use wzp_proto::{SignalMessage, default_signal_version};
    let (tx, rx) = tokio::sync::oneshot::channel::<std::net::SocketAddr>();
    let transport = {
        let mut sig = state.signal.lock().await;
        // Drop any older pending sender — we don't support more than
        // one in-flight Reflect per connection. A prior request whose
        // receiver has timed out will be cleaned up here automatically.
        sig.pending_reflect = Some(tx);
        sig.transport
            .as_ref()
            .ok_or_else(|| "not registered".to_string())?
            .clone()
    };
    if let Err(e) = transport.send_signal(&SignalMessage::Reflect).await {
        // Clean up the pending sender so the next attempt doesn't see
        // a stale channel. Re-acquire the lock inline since we already
        // released it above to release `transport` back to the caller.
        let mut sig = state.signal.lock().await;
        sig.pending_reflect = None;
        return Err(format!("send Reflect: {e}"));
    }

    // 1s is plenty for a same-datacenter relay (< 50ms RTT) and also
    // the ceiling for "something's wrong, tell the user" — any older
    // relay will never reply at all. 1100ms in the integration test.
    match tokio::time::timeout(std::time::Duration::from_millis(1000), rx).await {
        Ok(Ok(addr)) => Ok(addr.to_string()),
        Ok(Err(_canceled)) => {
            // The recv loop dropped the sender (relay returned
            // unparseable addr, or loop exited mid-request).
            Err("reflect channel canceled (signal loop exited or parse error)".into())
        }
        Err(_elapsed) => {
            // Timeout — strip the pending sender so the next attempt
            // starts clean. Old (pre-Phase-1) relays will land here.
            let mut sig = state.signal.lock().await;
            sig.pending_reflect = None;
            Err("reflect timeout (relay may not support reflection)".into())
        }
    }
}

/// Phase 2 of the "STUN for QUIC" rollout — probe multiple relays
/// in parallel to classify this client's NAT type. See
/// `wzp_client::reflect` for the per-probe logic and the pure
/// classifier.
///
/// This does NOT touch the registered `SignalState` — each probe
/// opens a fresh throwaway QUIC endpoint so the OS gives it a
/// fresh ephemeral source port. Sharing one endpoint across probes
/// would make a symmetric NAT look like a cone NAT, which is
/// exactly the failure mode we're trying to detect.
///
/// NAT detection with selectable mode.
///
/// `mode`:
///   - `"relay"` — relay-based Reflect only (original Phase 1-2 behavior)
///   - `"stun"` — public STUN servers only (no relay needed)
///   - `"both"` (default) — relay + STUN in parallel (highest confidence)
#[tauri::command]
async fn detect_nat_type(
    state: tauri::State<'_, Arc<AppState>>,
    relays: Vec<RelayArg>,
    mode: Option<String>,
) -> Result<serde_json::Value, String> {
    let mut parsed = Vec::with_capacity(relays.len());
    for r in relays {
        let addr: std::net::SocketAddr = r
            .address
            .parse()
            .map_err(|e| format!("bad relay address {:?}: {e}", r.address))?;
        parsed.push((r.name, addr));
    }

    let shared_endpoint = state.signal.lock().await.endpoint.clone();
    let stun_config = wzp_client::stun::StunConfig::default();

    let mode_str = mode.as_deref().unwrap_or("both");
    tracing::info!(
        mode = mode_str,
        relay_count = parsed.len(),
        "detect_nat_type: starting"
    );

    let detection = match mode_str {
        "relay" => {
            // Original behavior: relay-based Reflect only
            wzp_client::reflect::detect_nat_type(parsed, 1500, shared_endpoint).await
        }
        "stun" => {
            // Public STUN servers only — no relay connection needed
            let probes = wzp_client::stun::probe_stun_servers(&stun_config).await;
            let (nat_type, consensus_addr) = wzp_client::reflect::classify_nat(&probes);
            wzp_client::reflect::NatDetection {
                probes,
                nat_type,
                consensus_addr,
            }
        }
        _ => {
            // "both" — relay + STUN in parallel (default, highest confidence)
            wzp_client::reflect::detect_nat_type_with_stun(
                parsed,
                1500,
                shared_endpoint,
                &stun_config,
            )
            .await
        }
    };
    serde_json::to_value(&detection).map_err(|e| format!("serialize: {e}"))
}

/// Run comprehensive network diagnostic (STUN + relay + portmap + IPv6).
#[tauri::command]
async fn run_netcheck(
    state: tauri::State<'_, Arc<AppState>>,
    relays: Vec<RelayArg>,
) -> Result<serde_json::Value, String> {
    let mut relay_addrs = Vec::with_capacity(relays.len());
    for r in relays {
        let addr: std::net::SocketAddr = r
            .address
            .parse()
            .map_err(|e| format!("bad relay address {:?}: {e}", r.address))?;
        relay_addrs.push((r.name, addr));
    }

    let local_port = state
        .signal
        .lock()
        .await
        .endpoint
        .as_ref()
        .and_then(|ep| ep.local_addr().ok())
        .map(|la| la.port())
        .unwrap_or(0);

    let config = wzp_client::netcheck::NetcheckConfig {
        stun_config: wzp_client::stun::StunConfig::default(),
        relays: relay_addrs,
        timeout: std::time::Duration::from_secs(5),
        test_portmap: true,
        test_ipv6: true,
        local_port,
    };

    let report = wzp_client::netcheck::run_netcheck(&config).await;
    serde_json::to_value(&report).map_err(|e| format!("serialize: {e}"))
}

/// Deserialization shim for the relay list coming from JS. The
/// `wzp-settings.relays` array in localStorage has more fields
/// (rtt, serverFingerprint, knownFingerprint) but we only need
/// name + address here.
#[derive(serde::Deserialize)]
struct RelayArg {
    name: String,
    address: String,
}

#[tauri::command]
async fn get_signal_status(
    state: tauri::State<'_, Arc<AppState>>,
) -> Result<serde_json::Value, String> {
    let sig = state.signal.lock().await;
    Ok(
        serde_json::json!({"status":sig.signal_status,"fingerprint":sig.fingerprint,"incoming_call_id":sig.incoming_call_id,"incoming_caller_fp":sig.incoming_caller_fp}),
    )
}

/// Tear down the signal connection so the user goes back to idle. Called
/// when the user clicks "Deregister" on the direct-call screen. The
/// spawned recv loop will break out naturally when the transport closes,
/// AND — critically — clearing `desired_relay_addr` here tells that
/// exit path NOT to spawn a reconnect supervisor.
#[tauri::command]
async fn deregister(state: tauri::State<'_, Arc<AppState>>) -> Result<(), String> {
    internal_deregister(&state.signal, /*keep_desired=*/ false).await;
    tracing::info!("deregister: user-requested, desired_relay_addr cleared");
    Ok(())
}

/// End the current call, telling the peer via a signal-plane
/// `Hangup` message before tearing down the local media engine.
///
/// Prior to this command existing, the hangup button just called
/// `disconnect` which stopped the local engine but didn't notify
/// the peer — so the OTHER party stayed on the call screen with
/// nothing to hear. The relay DOES notice the media connection
/// closing but doesn't forward anything to the peer on its own,
/// so a real `SignalMessage::Hangup` is the only reliable signal.
///
/// Best-effort: if the signal transport is down (e.g. the relay
/// dropped us mid-call), we still tear down the engine locally
/// and return success. The peer's CallEngine will eventually
/// notice the media side dying and the signal-event hangup
/// handler will fire on receiving it from their signal loop if
/// the relay is still up on their side.
#[tauri::command]
async fn hangup_call(
    state: tauri::State<'_, Arc<AppState>>,
    app: tauri::AppHandle,
) -> Result<(), String> {
    use wzp_proto::{SignalMessage, default_signal_version};

    emit_call_debug(&app, "hangup_call:start", serde_json::json!({}));

    // Step 1: send Hangup over the signal channel so the relay
    // forwards it to the peer. Do this FIRST so the peer gets
    // the notification even if the engine shutdown takes a beat.
    {
        let sig = state.signal.lock().await;
        if let Some(ref transport) = sig.transport {
            match transport
                .send_signal(&SignalMessage::Hangup {
                    version: default_signal_version(),
                    reason: wzp_proto::HangupReason::Normal,
                    call_id: None,
                })
                .await
            {
                Ok(()) => {
                    tracing::info!("hangup_call: Hangup signal sent to relay");
                    emit_call_debug(&app, "hangup_call:signal_sent", serde_json::json!({}));
                }
                Err(e) => {
                    tracing::warn!(error = %e, "hangup_call: failed to send Hangup signal");
                    emit_call_debug(
                        &app,
                        "hangup_call:signal_send_failed",
                        serde_json::json!({ "error": e.to_string() }),
                    );
                }
            }
        } else {
            tracing::debug!("hangup_call: no signal transport, skipping Hangup send");
            emit_call_debug(
                &app,
                "hangup_call:no_signal_transport",
                serde_json::json!({}),
            );
        }
    }

    // Step 2: tear down the local media engine.
    let mut engine_lock = state.engine.lock().await;
    if let Some(engine) = engine_lock.take() {
        engine.stop().await;
        emit_call_debug(&app, "hangup_call:engine_stopped", serde_json::json!({}));
    } else {
        emit_call_debug(&app, "hangup_call:no_engine", serde_json::json!({}));
    }
    Ok(())
}

// ─── App entry point ─────────────────────────────────────────────────────────

/// Shared Tauri app builder. Used by the desktop `main.rs` and the mobile
/// entry point below.
pub fn run() {
    tracing_subscriber::fmt().init();

    let state = Arc::new(AppState {
        engine: Mutex::new(None),
        signal: Arc::new(Mutex::new(SignalState {
            transport: None,
            endpoint: None,
            ipv6_endpoint: None,
            fingerprint: String::new(),
            signal_status: "idle".into(),
            incoming_call_id: None,
            incoming_caller_fp: None,
            incoming_caller_alias: None,
            pending_reflect: None,
            own_reflex_addr: None,
            desired_relay_addr: None,
            reconnect_in_progress: false,
            pending_path_report: None,
            peer_hard_nat_probe: None,
            peer_birthday_ports: None,
        })),
    });

    tauri::Builder::default()
        .plugin(tauri_plugin_shell::init())
        .plugin(tauri_plugin_notification::init())
        .manage(state)
        .setup(|app| {
            // Resolve the platform-correct app data dir once at startup so
            // every command can read/write the seed without juggling AppHandle.
            let data_dir = app
                .path()
                .app_data_dir()
                .map(|p| p.join(".wzp"))
                .unwrap_or_else(|_| identity_dir());
            // create_dir_all is a no-op if it already exists.
            if let Err(e) = std::fs::create_dir_all(&data_dir) {
                tracing::warn!("failed to create app data dir {data_dir:?}: {e}");
            }
            tracing::info!("app data dir: {data_dir:?}");
            let _ = APP_DATA_DIR.set(data_dir);

            // Load the standalone wzp-native cdylib (Oboe audio bridge) and
            // cache its exported function pointers. The library handle is
            // kept alive in a 'static OnceLock for the lifetime of the
            // process, so CallEngine::start() can invoke its audio FFI
            // from anywhere. See src/wzp_native.rs and the incident report
            // in docs/incident-tauri-android-init-tcb.md.
            #[cfg(target_os = "android")]
            {
                match wzp_native::init() {
                    Ok(()) => {
                        tracing::info!(
                            "wzp-native loaded: version={} msg=\"{}\"",
                            wzp_native::version(),
                            wzp_native::hello()
                        );
                    }
                    Err(e) => {
                        tracing::warn!("wzp-native init failed: {e}");
                    }
                }
            }

            Ok(())
        })
        .invoke_handler(tauri::generate_handler![
            ping_relay,
            get_identity,
            get_app_info,
            connect,
            disconnect,
            toggle_mic,
            toggle_speaker,
            get_status,
            register_signal,
            place_call,
            answer_call,
            get_signal_status,
            get_reflected_address,
            detect_nat_type,
            run_netcheck,
            hangup_call,
            deregister,
            set_speakerphone,
            is_speakerphone_on,
            set_bluetooth_sco,
            is_bluetooth_available,
            get_audio_route,
            get_call_history,
            get_recent_contacts,
            clear_call_history,
            set_dred_verbose_logs,
            get_dred_verbose_logs,
            set_call_debug_logs,
            get_call_debug_logs,
        ])
        .run(tauri::generate_context!())
        .expect("error while running WarzonePhone");
}

/// Tauri mobile entry point (Android/iOS). On desktop this is a no-op —
/// `main.rs` calls `run()` directly.
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn mobile_entry() {
    run();
}
