//! The four probe subcommands.
//!
//! `scan`, `inspect` and `monitor` are read-only and talk to `btleplug`
//! directly, so they never take FTMS control and can be run safely while
//! poking at an unfamiliar device. `set` goes through [`FtmsClient`], which
//! means it exercises the same rate limiting, clamping, acknowledgement
//! handling and SAF-2 shutdown that the app will use.

use std::time::{Duration, Instant};

use anyhow::{anyhow, Context, Result};
use bikecontrol_ble::capabilities::{
    FitnessMachineFeature, InclinationRange, PowerRange, ResistanceLevelRange,
};
use bikecontrol_ble::client::{ControlOutcome, FtmsClient, FtmsConfig, FtmsEvent};
use bikecontrol_ble::control_point::ResultCode;
use bikecontrol_ble::indoor_bike_data::{self, hex, IndoorBikeData};
use bikecontrol_ble::scan::{self, DiscoveredDevice, ScanKind, TrainerSelector};
use bikecontrol_ble::{uuids, zwift, FtmsError};
use bikecontrol_core::types::ControlTarget;
use btleplug::api::{CharPropFlags, Characteristic, Peripheral as _};
use btleplug::platform::Peripheral;
use futures::StreamExt;
use uuid::Uuid;

use crate::cli::Device;

impl Device {
    fn selector(&self) -> TrainerSelector {
        match self {
            Device::Address(a) => TrainerSelector::Address(a.clone()),
            Device::Name(n) => TrainerSelector::NameContains(n.clone()),
        }
    }
}

// ---------------------------------------------------------------------------
// scan
// ---------------------------------------------------------------------------

/// FR-1.1: list peripherals with name, address, RSSI and advertised services.
pub async fn scan_cmd(duration: Duration, all: bool) -> Result<()> {
    let adapter = scan::default_adapter()
        .await
        .context("no Bluetooth adapter — is the radio on?")?;
    let kind = if all {
        ScanKind::All
    } else {
        ScanKind::FitnessMachines
    };

    println!(
        "Scanning for {} s ({})...",
        duration.as_secs(),
        if all {
            "everything"
        } else {
            "fitness machines only — pass --all to see every peripheral"
        }
    );

    let devices = scan::scan(&adapter, duration, kind).await?;

    if devices.is_empty() {
        println!("\nNothing found.");
        println!(
            "The trainer only advertises once it is awake (A-4): pedal it for a few seconds\n\
             and scan again. A Zwift Click wakes on a button press."
        );
        return Ok(());
    }

    println!("\n{} device(s):\n", devices.len());
    for d in &devices {
        print_device(d);
    }
    Ok(())
}

fn print_device(d: &DiscoveredDevice) {
    let kind = if d.is_fitness_machine() {
        "  [FTMS trainer]"
    } else if d.is_zwift_device() {
        "  [Zwift device]"
    } else {
        ""
    };
    println!("{}  {}{}", d.address, d.label(), kind);
    println!(
        "    rssi: {}   tx power: {}",
        d.rssi.map(|v| format!("{v} dBm")).unwrap_or("?".into()),
        d.tx_power.map(|v| format!("{v} dBm")).unwrap_or("?".into())
    );
    if d.services.is_empty() {
        println!("    services: (none advertised)");
    } else {
        println!("    services:");
        for s in &d.services {
            println!("      {}{}", s, named(*s));
        }
    }
    for (id, data) in &d.manufacturer_data {
        println!("    manufacturer 0x{id:04x} ({id}): {}", hex(data));
    }
    for (uuid, data) in &d.service_data {
        println!("    service data {uuid}: {}", hex(data));
    }
    println!();
}

fn named(uuid: Uuid) -> String {
    uuids::well_known_name(uuid)
        .map(|n| format!("  ({n})"))
        .unwrap_or_default()
}

// ---------------------------------------------------------------------------
// inspect
// ---------------------------------------------------------------------------

/// TASK-1: enumerate everything, and decode the capability characteristics.
pub async fn inspect(device: &Device, scan_timeout: Duration) -> Result<()> {
    let peripheral = connect(device, scan_timeout).await?;

    if let Some(d) = scan::describe(&peripheral).await {
        println!("Connected to {} ({})\n", d.address, d.label());
    }

    println!("=== Services and characteristics ===\n");
    let mut has_ftms = false;
    for service in peripheral.services() {
        if service.uuid == uuids::FITNESS_MACHINE_SERVICE {
            has_ftms = true;
        }
        println!(
            "service {}{}{}",
            service.uuid,
            named(service.uuid),
            if service.primary { "  [primary]" } else { "" }
        );
        for ch in &service.characteristics {
            println!(
                "    char {}{}\n         properties: {}",
                ch.uuid,
                named(ch.uuid),
                properties(ch.properties)
            );
            // Reading is safe: every readable characteristic here is
            // informational, and it is exactly what Phase 0 needs to see.
            if ch.properties.contains(CharPropFlags::READ) {
                match peripheral.read(ch).await {
                    Ok(v) => println!("         value: {}  {}", hex(&v), as_text(&v)),
                    Err(e) => println!("         value: <unreadable: {e}>"),
                }
            }
        }
        println!();
    }

    if !has_ftms {
        println!(
            "!! This peripheral does not expose the Fitness Machine Service (0x1826).\n\
             !! It is not an FTMS trainer, or it needs waking.\n"
        );
    }

    println!("=== Fitness Machine Feature (0x2ACC) ===\n");
    match read_char(&peripheral, uuids::FITNESS_MACHINE_FEATURE).await {
        Some(raw) => match FitnessMachineFeature::decode(&raw) {
            Ok(f) => print_feature(&raw, f),
            Err(e) => println!("  raw {}: could not decode ({e})\n", hex(&raw)),
        },
        None => println!("  not present or unreadable\n"),
    }

    println!("=== Supported Resistance Level Range (0x2AD6) ===\n");
    match read_char(&peripheral, uuids::SUPPORTED_RESISTANCE_LEVEL_RANGE).await {
        Some(raw) => match ResistanceLevelRange::decode(&raw) {
            Ok(r) => {
                let (lo, hi, inc) = r.scaled();
                println!("  raw bytes:   {}", hex(&raw));
                println!("  minimum:     {}", r.min);
                println!("  maximum:     {}", r.max);
                println!("  increment:   {}", r.increment);
                println!(
                    "  if the spec's 0.1 resolution applies: {lo} .. {hi} step {inc}"
                );
                println!(
                    "\n  NOTE: resistance level is a trainer-specific unit. Whether the D100\n\
                     means raw integers or tenths is TASK-1/TASK-3 — compare these numbers\n\
                     with what `set resistance=<N>` actually does, and with the resistance\n\
                     level reported back in Indoor Bike Data.\n"
                );
            }
            Err(e) => println!("  raw {}: could not decode ({e})\n", hex(&raw)),
        },
        None => println!("  not present or unreadable\n"),
    }

    println!("=== Supported Power Range (0x2AD8) ===\n");
    match read_char(&peripheral, uuids::SUPPORTED_POWER_RANGE).await {
        Some(raw) => match PowerRange::decode(&raw) {
            Ok(p) => println!(
                "  raw bytes: {}\n  {} .. {} W, step {} W\n",
                hex(&raw),
                p.min_w,
                p.max_w,
                p.increment_w
            ),
            Err(e) => println!("  raw {}: could not decode ({e})\n", hex(&raw)),
        },
        None => println!("  not present or unreadable\n"),
    }

    println!("=== Supported Inclination Range (0x2AD5) ===\n");
    match read_char(&peripheral, uuids::SUPPORTED_INCLINATION_RANGE).await {
        Some(raw) => match InclinationRange::decode(&raw) {
            Ok(i) => println!(
                "  raw bytes: {}\n  {} .. {} %, step {} %\n",
                hex(&raw),
                i.min_percent(),
                i.max_percent(),
                i.increment_percent()
            ),
            Err(e) => println!("  raw {}: could not decode ({e})\n", hex(&raw)),
        },
        None => println!("  not present or unreadable\n"),
    }

    disconnect(&peripheral).await;
    Ok(())
}

fn print_feature(raw: &[u8], f: FitnessMachineFeature) {
    println!("  raw bytes:      {}", hex(raw));
    println!("  machine field:  0x{:08x}", f.machine);
    println!("  target field:   0x{:08x}\n", f.target);

    println!("  Measures:");
    let m = f.machine_feature_names();
    if m.is_empty() {
        println!("    (none)");
    }
    for name in m {
        println!("    - {name}");
    }

    println!("\n  Accepts as targets:");
    let t = f.target_feature_names();
    if t.is_empty() {
        println!("    (none)");
    }
    for name in t {
        println!("    - {name}");
    }

    println!("\n  Answers to the questions Phase 0 is asking:");
    println!(
        "    SetTargetInclination (0x03):              {}",
        yes_no(f.supports_inclination_target())
    );
    println!(
        "    SetTargetResistanceLevel (0x04):          {}",
        yes_no(f.supports_resistance_target())
    );
    println!(
        "    SetTargetPower (0x05):                    {}",
        yes_no(f.supports_power_target())
    );
    println!(
        "    SetIndoorBikeSimulationParameters (0x11): {}   <-- A-1",
        yes_no(f.supports_simulation())
    );
    println!(
        "\n  The 0x11 bit is only what the trainer *claims*. Confirm it with\n\
         `probe set <addr> sim=4.0`, which writes the op code regardless.\n"
    );
}

fn yes_no(b: bool) -> &'static str {
    if b {
        "advertised"
    } else {
        "NOT advertised"
    }
}

// ---------------------------------------------------------------------------
// monitor
// ---------------------------------------------------------------------------

/// TASK-1: raw hex next to decoded fields, so a decoder bug is obvious.
pub async fn monitor(device: &Device, duration: Duration, scan_timeout: Duration) -> Result<()> {
    let peripheral = connect(device, scan_timeout).await?;

    let bike_data = find_characteristic(&peripheral, uuids::INDOOR_BIKE_DATA).ok_or_else(|| {
        anyhow!("this peripheral has no Indoor Bike Data characteristic (0x2AD2)")
    })?;

    let mut notifications = peripheral.notifications().await?;
    peripheral.subscribe(&bike_data).await?;

    println!(
        "Subscribed to Indoor Bike Data (0x2AD2) for {} s.\n\
         Pedal the trainer — most trainers send nothing at all when stationary.\n\
         Press Ctrl-C to stop early.\n",
        duration.as_secs()
    );

    let start = Instant::now();
    let mut count: u64 = 0;
    let mut failures: u64 = 0;

    let deadline = tokio::time::sleep(duration);
    tokio::pin!(deadline);

    loop {
        tokio::select! {
            _ = &mut deadline => break,
            _ = tokio::signal::ctrl_c() => {
                println!("\nInterrupted.");
                break;
            }
            n = notifications.next() => {
                let Some(n) = n else {
                    println!("\nNotification stream ended (the trainer disconnected).");
                    break;
                };
                if n.uuid != uuids::INDOOR_BIKE_DATA {
                    continue;
                }
                count += 1;
                let t = start.elapsed().as_secs_f32();
                println!("[{t:7.2}s] #{count}  raw 2ad2: {}", hex(&n.value));
                match indoor_bike_data::decode(&n.value) {
                    Ok(d) => print_decoded(&d, n.value.len()),
                    Err(e) => {
                        failures += 1;
                        println!("           DECODE FAILED: {e}");
                    }
                }
                println!();
            }
        }
    }

    println!(
        "\n{count} packet(s) in {:.1} s ({:.2} Hz), {failures} decode failure(s).",
        start.elapsed().as_secs_f32(),
        count as f32 / start.elapsed().as_secs_f32().max(0.001)
    );
    if count > 0 && failures == 0 {
        println!("Decoder agrees with the trainer on every packet.");
    }

    let _ = peripheral.unsubscribe(&bike_data).await;
    disconnect(&peripheral).await;
    Ok(())
}

fn print_decoded(d: &IndoorBikeData, len: usize) {
    println!(
        "           flags:     0x{:04x}  ({})",
        d.flags,
        flag_names(d.flags)
    );
    let row = |label: &str, value: Option<String>| {
        if let Some(v) = value {
            println!("           {label:<10} {v}");
        }
    };
    row("speed:", d.instant_speed_kph.map(|v| format!("{v:.2} km/h")));
    row("avg speed:", d.average_speed_kph.map(|v| format!("{v:.2} km/h")));
    row("cadence:", d.instant_cadence_rpm.map(|v| format!("{v:.1} rpm")));
    row("avg cad:", d.average_cadence_rpm.map(|v| format!("{v:.1} rpm")));
    row("distance:", d.total_distance_m.map(|v| format!("{v} m")));
    row("resist:", d.resistance_level.map(|v| v.to_string()));
    row("power:", d.instant_power_w.map(|v| format!("{v} W")));
    row("avg power:", d.average_power_w.map(|v| format!("{v} W")));
    row("energy:", d.total_energy_kcal.map(|v| format!("{v} kcal")));
    row("kcal/h:", d.energy_per_hour_kcal.map(|v| v.to_string()));
    row("kcal/min:", d.energy_per_minute_kcal.map(|v| v.to_string()));
    row("hr:", d.heart_rate_bpm.map(|v| format!("{v} bpm")));
    row("met:", d.metabolic_equivalent.map(|v| format!("{v:.1}")));
    row("elapsed:", d.elapsed_time_s.map(|v| format!("{v} s")));
    row("remaining:", d.remaining_time_s.map(|v| format!("{v} s")));

    if d.consumed != len {
        println!(
            "           !! consumed {} of {len} bytes — {} trailing byte(s) unaccounted for",
            d.consumed,
            len - d.consumed
        );
    }
}

fn flag_names(flags: u16) -> String {
    use indoor_bike_data::flag as f;
    let mut names = Vec::new();
    // Bit 0 is inverted: speed is present when it is CLEAR.
    if flags & f::MORE_DATA == 0 {
        names.push("InstantaneousSpeed(bit0 clear)");
    } else {
        names.push("MoreData(bit0 set: no speed)");
    }
    for (bit, name) in [
        (f::AVERAGE_SPEED, "AvgSpeed"),
        (f::INSTANTANEOUS_CADENCE, "Cadence"),
        (f::AVERAGE_CADENCE, "AvgCadence"),
        (f::TOTAL_DISTANCE, "TotalDistance"),
        (f::RESISTANCE_LEVEL, "Resistance"),
        (f::INSTANTANEOUS_POWER, "Power"),
        (f::AVERAGE_POWER, "AvgPower"),
        (f::EXPENDED_ENERGY, "Energy"),
        (f::HEART_RATE, "HeartRate"),
        (f::METABOLIC_EQUIVALENT, "MET"),
        (f::ELAPSED_TIME, "ElapsedTime"),
        (f::REMAINING_TIME, "RemainingTime"),
    ] {
        if flags & bit != 0 {
            names.push(name);
        }
    }
    if flags & 0xE000 != 0 {
        names.push("<reserved bits set>");
    }
    names.join(" | ")
}

// ---------------------------------------------------------------------------
// set
// ---------------------------------------------------------------------------

/// TASK-2, and the experiment that answers A-1.
pub async fn set(
    device: &Device,
    target: ControlTarget,
    simulation: bool,
    hold: Duration,
    scan_timeout: Duration,
) -> Result<()> {
    let config = FtmsConfig {
        use_simulation_mode: simulation,
        scan_timeout,
        // Discovery: write the op code even when the feature bit is clear, so
        // the trainer's own response settles the question rather than our
        // reading of its advertisement.
        ignore_advertised_features: true,
        ..FtmsConfig::default()
    };

    println!("Connecting and requesting FTMS control...");
    let client = FtmsClient::connect(device.selector(), config).await?;
    println!(
        "Control acquired on {} ({}).\n",
        client.address(),
        client.name().unwrap_or("no name")
    );

    let caps = client.capabilities();
    if let Some(f) = caps.feature {
        println!("Trainer advertises target support: {:?}\n", f.target_feature_names());
    }

    let mut events = client.events();
    let mut telemetry = client.telemetry();

    let (op, note) = describe_write(&target, simulation);
    println!("Writing {op} ({note})...");

    let outcome = client.set_target(target).await;
    report_outcome(&outcome, simulation);

    // Drain the indication that came back, so the raw result code is visible
    // even when the write succeeded.
    while let Ok(event) = events.try_recv() {
        if let FtmsEvent::ControlResponse { op, result } = event {
            println!(
                "  indication: op {:?}, result {} (0x{:02x})",
                op,
                result,
                result.as_u8()
            );
        }
    }

    if outcome.is_ok() {
        println!(
            "\nHolding for {} s — check whether the resistance actually changed at the pedals.\n\
             (TASK-2's exit criterion is a *felt* change, not an acknowledged write.)\n\
             Ctrl-C to stop early.\n",
            hold.as_secs()
        );

        let deadline = tokio::time::sleep(hold);
        tokio::pin!(deadline);
        loop {
            tokio::select! {
                _ = &mut deadline => break,
                _ = tokio::signal::ctrl_c() => {
                    println!("\nInterrupted.");
                    break;
                }
                sample = telemetry.recv() => {
                    if let Ok(s) = sample {
                        println!(
                            "  {:6.1}s  power {:>5}  cadence {:>6}  speed {:>7}  resistance {:>5}",
                            s.elapsed_ms as f32 / 1000.0,
                            s.power_w.map(|v| format!("{v} W")).unwrap_or("-".into()),
                            s.cadence_rpm.map(|v| format!("{v:.0} rpm")).unwrap_or("-".into()),
                            s.speed_kph.map(|v| format!("{v:.1} kph")).unwrap_or("-".into()),
                            s.resistance_level.map(|v| v.to_string()).unwrap_or("-".into()),
                        );
                    }
                }
            }
        }
    }

    println!("\nResetting the trainer to zero gradient / minimum resistance (SAF-2)...");
    client.shutdown().await?;
    println!("Done.");
    Ok(())
}

fn describe_write(target: &ControlTarget, simulation: bool) -> (&'static str, String) {
    match target {
        ControlTarget::Gradient { percent } if simulation => (
            "SetIndoorBikeSimulationParameters (0x11)",
            format!("grade {percent} %"),
        ),
        ControlTarget::Gradient { percent } => (
            "SetTargetInclination (0x03)",
            format!("inclination {percent} %"),
        ),
        ControlTarget::Resistance { level } => (
            "SetTargetResistanceLevel (0x04)",
            format!("level {level}"),
        ),
        ControlTarget::Power { watts } => ("SetTargetPower (0x05)", format!("{watts} W")),
    }
}

fn report_outcome(outcome: &Result<ControlOutcome, FtmsError>, simulation: bool) {
    match outcome {
        Ok(ControlOutcome::Acknowledged { sent }) => {
            println!("  ACCEPTED. Trainer acknowledged with Success.");
            println!("  value actually transmitted (post-clamp): {sent:?}");
            if simulation {
                println!(
                    "\n  >>> A-1 RESOLVED: the D100 ACCEPTS op code 0x11 (sim mode).\n\
                     >>> FR-2.3 may use 0x11 for gradient."
                );
            }
        }
        Ok(ControlOutcome::Superseded) => {
            println!("  superseded before transmission (should not happen for a single write)");
        }
        Err(FtmsError::Rejected { op, result }) => {
            println!("  REJECTED. Trainer answered {op} with: {result}");
            if simulation && *result == ResultCode::OpCodeNotSupported {
                println!(
                    "\n  >>> A-1 RESOLVED: the D100 does NOT support op code 0x11.\n\
                     >>> FR-2.3 must drive gradient via SetTargetInclination (0x03),\n\
                     >>> exactly as the MIT reference implementation does. Low impact —\n\
                     >>> the app owns the physics (FR-7.1)."
                );
            }
            if *result == ResultCode::ControlNotPermitted {
                println!(
                    "  (RequestControl succeeded but the trainer withdrew control — another\n\
                     app may be connected. Only one BLE host may hold the trainer, per A-3.)"
                );
            }
        }
        Err(FtmsError::Unacknowledged { op, timeout_ms }) => {
            println!("  NO ANSWER. {op} was written but no indication arrived in {timeout_ms} ms.");
            println!("  This is the silent-failure mode FR-2.7 exists to catch.");
        }
        Err(FtmsError::Unsupported(e)) => {
            println!("  BLOCKED BEFORE TRANSMISSION: {e}");
        }
        Err(e) => println!("  FAILED: {e}"),
    }
}

// ---------------------------------------------------------------------------
// zwift
// ---------------------------------------------------------------------------

/// TASK-0: connect to whatever advertises Zwift's custom service, try the
/// `RideOn` handshake unencrypted, and log every frame that comes back.
///
/// Points at either end of the open question. Against a **Click v2** it tests
/// A-3 — whether the unencrypted path still yields button events on a v2.
/// Against the **trainer** it asks what the D100 is doing with a Zwift service
/// at all; if it is the virtual-shifting endpoint, the Click may belong to the
/// trainer rather than to us.
///
/// Nothing here interprets a frame as a gear change or drives resistance. It
/// prints bytes. Everything the protocol module claims (§2.3.1) is a hypothesis
/// until the hex on screen agrees with it.
pub async fn zwift_cmd(
    device: &Device,
    duration: Duration,
    no_handshake: bool,
    buttons_only: bool,
    scan_timeout: Duration,
) -> Result<()> {
    let peripheral = connect(device, scan_timeout).await?;

    if let Some(d) = scan::describe(&peripheral).await {
        println!("Connected to {} ({})", d.address, d.label());
        match d.zwift_kind() {
            Some(kind) => println!("Advertised as: {}", kind.describe()),
            None => println!(
                "No Zwift manufacturer data in the advertisement — this is not a controller,\n\
                 or it was already connected when we found it."
            ),
        }
        println!();
    }

    // A Click v2 carries 0xFC82; the trainer carries 00000001-19CA-…. Take
    // whichever is present rather than assuming, because which one a device
    // speaks is itself a finding.
    let service = zwift::SERVICES
        .iter()
        .find_map(|want| peripheral.services().into_iter().find(|s| s.uuid == *want));
    let Some(service) = service else {
        println!("!! This peripheral exposes no known Zwift service. Looked for:");
        for want in zwift::SERVICES {
            println!("     {want}{}", zwift_named(want));
        }
        println!("!! Services it does expose:");
        for s in peripheral.services() {
            println!("     {}{}", s.uuid, named(s.uuid));
        }
        disconnect(&peripheral).await;
        return Ok(());
    };

    println!("=== Zwift service {}{} ===\n", service.uuid, zwift_named(service.uuid));
    for ch in &service.characteristics {
        println!(
            "  char {}{}\n       properties: {}",
            ch.uuid,
            zwift_named(ch.uuid),
            properties(ch.properties)
        );
        if ch.properties.contains(CharPropFlags::READ) {
            match peripheral.read(ch).await {
                Ok(v) => println!("       value: {}  {}", hex(&v), as_text(&v)),
                Err(e) => println!("       value: <unreadable: {e}>"),
            }
        }
    }
    println!();

    // Subscribe to everything that can talk before writing anything, so the
    // handshake reply cannot land before we are listening.
    let mut notifications = peripheral.notifications().await?;
    let listening: Vec<Characteristic> = service
        .characteristics
        .iter()
        .filter(|c| {
            c.properties
                .intersects(CharPropFlags::NOTIFY | CharPropFlags::INDICATE)
        })
        .cloned()
        .collect();

    if listening.is_empty() {
        println!("!! Nothing in this service notifies or indicates — there is nothing to listen to.");
        disconnect(&peripheral).await;
        return Ok(());
    }

    for ch in &listening {
        match peripheral.subscribe(ch).await {
            Ok(()) => println!("Subscribed to {}{}", ch.uuid, zwift_named(ch.uuid)),
            Err(e) => println!("Could not subscribe to {}: {e}", ch.uuid),
        }
    }
    println!();

    let start = Instant::now();
    let mut frames: u64 = 0;

    if no_handshake {
        println!("--no-handshake: writing nothing, just listening.\n");
    } else if let Some(sync_rx) = writable(&service) {
        if sync_rx.uuid != zwift::SYNC_RX {
            println!(
                "Sync RX ({}) is absent; using {} instead, which is the only writable\n\
                 characteristic in this service.\n",
                zwift::SYNC_RX,
                sync_rx.uuid
            );
        }
        frames += handshake(&peripheral, &sync_rx, &mut notifications, start).await?;
    } else {
        println!("Nothing in this service is writable — cannot hand shake. Listening only.\n");
    }

    if buttons_only {
        println!(
            "=== BUTTON MAPPING — GO ===\n\n\
             Press one button at a time, holding each for about 2 s with a gap between.\n\
             Only state changes are printed, so each press is one PRESS and one RELEASE.\n\
             {} s to go; Ctrl-C to stop early.\n",
            duration.saturating_sub(start.elapsed()).as_secs()
        );
    } else {
        println!(
            "Listening for {} s. Press the Click's paddles and D-pad; press Ctrl-C to stop.\n",
            duration.as_secs()
        );
    }

    let deadline = tokio::time::sleep(duration.saturating_sub(start.elapsed()));
    tokio::pin!(deadline);

    // Only meaningful in --buttons mode: the mask as of the previous frame, so
    // the ~10 Hz repeat while a button is held collapses to one line.
    let mut last_mask: Option<u32> = None;
    let mut presses: u64 = 0;

    loop {
        tokio::select! {
            _ = &mut deadline => break,
            _ = tokio::signal::ctrl_c() => {
                println!("\nInterrupted.");
                break;
            }
            n = notifications.next() => {
                let Some(n) = n else {
                    println!("\nNotification stream ended (the device disconnected).");
                    break;
                };
                frames += 1;
                let elapsed = start.elapsed().as_secs_f32();

                if buttons_only {
                    if let Some(mask) = button_mask(&n.value) {
                        if last_mask != Some(mask.raw) {
                            last_mask = Some(mask.raw);
                            if !mask.is_idle() {
                                presses += 1;
                            }
                            print_transition(&mask, elapsed, presses);
                        }
                        continue;
                    }
                }
                print_zwift_frame(n.uuid, &n.value, elapsed, frames);
            }
        }
    }

    println!("\n{frames} frame(s) in {:.1} s.", start.elapsed().as_secs_f32());
    if frames == 0 {
        println!(
            "Nothing arrived. Either the handshake is wrong, or the unlock has expired —\n\
             re-pair in the Zwift app and try again within the day (§2.3)."
        );
    }

    for ch in &listening {
        let _ = peripheral.unsubscribe(ch).await;
    }
    disconnect(&peripheral).await;
    Ok(())
}

/// Write each candidate handshake in turn, waiting briefly for a reply after
/// each. Returns how many frames arrived during the attempts.
///
/// Which two bytes follow `RideOn` is the unverified part of §2.3.1, so this
/// tries them rather than betting on one. It stops at the first `RideOn` reply
/// — that is the answer to TASK-0, and writing further handshakes after a
/// successful one would only confuse the session.
async fn handshake(
    peripheral: &Peripheral,
    sync_rx: &Characteristic,
    notifications: &mut (impl futures::Stream<Item = btleplug::api::ValueNotification> + Unpin),
    start: Instant,
) -> Result<u64> {
    // WriteWithoutResponse when the characteristic allows it: the Zwift
    // references use it, and a device that never sends a write response would
    // otherwise stall us for the full BLE timeout.
    let write_type = if sync_rx
        .properties
        .contains(CharPropFlags::WRITE_WITHOUT_RESPONSE)
    {
        btleplug::api::WriteType::WithoutResponse
    } else {
        btleplug::api::WriteType::WithResponse
    };

    println!("=== Handshake ===\n");
    let mut frames = 0;

    for (label, suffix) in zwift::HANDSHAKE_CANDIDATES {
        let frame = zwift::handshake(suffix);
        println!("-> {} : {}", label, hex(&frame));

        if let Err(e) = peripheral.write(sync_rx, &frame, write_type).await {
            println!("   write failed: {e}");
            continue;
        }

        // Long enough for a device that is going to answer to have answered.
        let window = tokio::time::sleep(Duration::from_millis(1500));
        tokio::pin!(window);
        let mut answered = false;
        loop {
            tokio::select! {
                _ = &mut window => break,
                n = notifications.next() => {
                    let Some(n) = n else { break };
                    frames += 1;
                    print_zwift_frame(n.uuid, &n.value, start.elapsed().as_secs_f32(), frames);
                    if zwift::is_ride_on_reply(&n.value) {
                        answered = true;
                    }
                }
            }
        }

        if answered {
            println!("\n   RideOn acknowledged — this is the handshake the device wants.");
            println!("   TASK-0 answered: the unencrypted path is open.\n");
            return Ok(frames);
        }
        println!("   no RideOn reply.\n");
    }

    println!(
        "None of the candidate handshakes drew a RideOn reply. Either the suffix is\n\
         something else, or this device requires the encrypted handshake (§2.3.1).\n\
         Frames may still arrive unprompted — keep watching.\n"
    );
    Ok(frames)
}

/// Print one Zwift frame: raw hex first, then whatever we think it means.
fn print_zwift_frame(uuid: Uuid, raw: &[u8], elapsed: f32, index: u64) {
    println!(
        "[{elapsed:7.2}s] #{index}  {}{}: {}",
        uuid,
        zwift_named(uuid),
        hex(raw)
    );

    if zwift::is_ride_on_reply(raw) {
        println!("           RideOn reply, {} byte(s) total", raw.len());
        return;
    }

    let Some(frame) = zwift::parse_frame(raw) else {
        println!("           empty frame");
        return;
    };
    println!("           type: {}", frame.kind.describe());

    match frame.kind {
        zwift::MessageType::ButtonBitmask => match zwift::decode_button_bitmask(frame.payload) {
            Ok(m) if m.is_idle() => println!("           mask 0x{:08x}  (idle)", m.raw),
            Ok(m) => {
                let bits: Vec<String> = m.pressed_bits().iter().map(|b| b.to_string()).collect();
                println!(
                    "           mask 0x{:08x}  PRESSED: bit {}",
                    m.raw,
                    bits.join(" + bit ")
                );
            }
            Err(e) => println!("           payload is not a varint ({e})"),
        },
        zwift::MessageType::ClickButtons => match zwift::decode_click_buttons(frame.payload) {
            Ok(b) => println!(
                "           up: {}   down: {}",
                pressed(b.up_pressed),
                pressed(b.down_pressed)
            ),
            Err(e) => println!("           payload is not two varints ({e}) — 0x37 is not what we assume"),
        },
        zwift::MessageType::Battery => match zwift::decode_battery(frame.payload) {
            Ok(Some(pct)) => println!("           battery: {pct}%"),
            Ok(None) => println!("           battery: no field in payload"),
            Err(e) => println!("           could not decode ({e})"),
        },
        _ => {
            // Unknown and controller frames: show the protobuf structure if it
            // has one, since that is the fastest route to naming the fields.
            if let Ok(fields) = zwift::decode_varint_fields(frame.payload) {
                if !fields.is_empty() {
                    let rendered: Vec<String> = fields
                        .iter()
                        .map(|(f, v)| format!("field {f} = {v}"))
                        .collect();
                    println!("           varints: {}", rendered.join(", "));
                }
            }
        }
    }
}

/// The button mask in `raw`, or `None` if this is not a button frame.
fn button_mask(raw: &[u8]) -> Option<zwift::ButtonBitmask> {
    let frame = zwift::parse_frame(raw)?;
    if frame.kind != zwift::MessageType::ButtonBitmask {
        return None;
    }
    zwift::decode_button_bitmask(frame.payload).ok()
}

/// One line per button state change, for `--buttons`.
fn print_transition(mask: &zwift::ButtonBitmask, elapsed: f32, presses: u64) {
    if mask.is_idle() {
        println!("[{elapsed:7.2}s] RELEASE   ---     mask 0x{:08x}", mask.raw);
        return;
    }
    // Name the button where we can, but always show the bit — an unmapped bit
    // is exactly the thing this tool exists to surface.
    let held: Vec<String> = mask
        .pressed_bits()
        .iter()
        .map(|b| match zwift::Button::from_bit(*b) {
            Some(button) => format!("{} (bit {b})", button.label()),
            None => format!("UNMAPPED bit {b}"),
        })
        .collect();
    println!(
        "[{elapsed:7.2}s] PRESS #{presses:<3} {:<24} mask 0x{:08x}",
        held.join(" + "),
        mask.raw
    );
}

/// The characteristic to write the handshake to: the documented sync RX if the
/// device has it, otherwise the first writable one. On a service whose layout
/// we have never seen, "the only thing that accepts a write" is the best
/// available guess.
fn writable(service: &btleplug::api::Service) -> Option<Characteristic> {
    let writable_flags = CharPropFlags::WRITE | CharPropFlags::WRITE_WITHOUT_RESPONSE;
    service
        .characteristics
        .iter()
        .find(|c| c.uuid == zwift::SYNC_RX && c.properties.intersects(writable_flags))
        .or_else(|| {
            service
                .characteristics
                .iter()
                .find(|c| c.properties.intersects(writable_flags))
        })
        .cloned()
}

fn pressed(b: bool) -> &'static str {
    if b {
        "PRESSED"
    } else {
        "released"
    }
}

/// Name a UUID, checking Zwift's custom space as well as the SIG's.
fn zwift_named(uuid: Uuid) -> String {
    zwift::well_known_name(uuid)
        .map(|n| format!("  ({n})"))
        .unwrap_or_else(|| named(uuid))
}

// ---------------------------------------------------------------------------
// Shared plumbing
// ---------------------------------------------------------------------------

async fn connect(device: &Device, scan_timeout: Duration) -> Result<Peripheral> {
    let adapter = scan::default_adapter()
        .await
        .context("no Bluetooth adapter — is the radio on?")?;
    let selector = device.selector();

    println!("Looking for {}...", selector.describe());
    let peripheral = scan::find_peripheral(&adapter, &selector, scan_timeout)
        .await
        .with_context(|| {
            format!(
                "could not find {}. The trainer may be asleep — pedal it and try again (A-4)",
                selector.describe()
            )
        })?;

    if !peripheral.is_connected().await.unwrap_or(false) {
        peripheral.connect().await.context("connect failed")?;
    }
    peripheral
        .discover_services()
        .await
        .context("service discovery failed")?;
    Ok(peripheral)
}

async fn disconnect(peripheral: &Peripheral) {
    if let Err(e) = peripheral.disconnect().await {
        tracing::debug!(error = %e, "disconnect failed");
    }
}

fn find_characteristic(peripheral: &Peripheral, uuid: Uuid) -> Option<Characteristic> {
    peripheral.characteristics().into_iter().find(|c| c.uuid == uuid)
}

async fn read_char(peripheral: &Peripheral, uuid: Uuid) -> Option<Vec<u8>> {
    let ch = find_characteristic(peripheral, uuid)?;
    peripheral.read(&ch).await.ok()
}

/// Render a characteristic's bytes as text when they look like a string —
/// Device Information holds model and firmware numbers this way.
fn as_text(v: &[u8]) -> String {
    if !v.is_empty()
        && v.iter()
            .all(|b| (0x20..0x7f).contains(b) || *b == b'\n' || *b == b'\r')
    {
        format!("\"{}\"", String::from_utf8_lossy(v).trim())
    } else {
        String::new()
    }
}

fn properties(p: CharPropFlags) -> String {
    let mut out = Vec::new();
    for (flag, name) in [
        (CharPropFlags::BROADCAST, "broadcast"),
        (CharPropFlags::READ, "read"),
        (CharPropFlags::WRITE_WITHOUT_RESPONSE, "write-without-response"),
        (CharPropFlags::WRITE, "write"),
        (CharPropFlags::NOTIFY, "notify"),
        (CharPropFlags::INDICATE, "indicate"),
        (
            CharPropFlags::AUTHENTICATED_SIGNED_WRITES,
            "authenticated-signed-writes",
        ),
        (CharPropFlags::EXTENDED_PROPERTIES, "extended-properties"),
    ] {
        if p.contains(flag) {
            out.push(name);
        }
    }
    if out.is_empty() {
        "(none)".to_string()
    } else {
        out.join(", ")
    }
}

// ---------------------------------------------------------------------------
// listen
// ---------------------------------------------------------------------------

/// Raw GATT interrogation, with no idea what any of it means.
///
/// Every other subcommand decodes something. This one deliberately does not:
/// it prints the tree, subscribes to everything that will have it, and reports
/// each notification as a characteristic, a length and some bytes.
///
/// That combination is what a cross-platform disagreement needs. When a device
/// behaves on one backend and not another, the useful questions are *which*
/// characteristic carried the data and *how many bytes arrived* — and a decoder
/// answers neither, because its failure mode is to drop the frame silently and
/// leave you looking at nothing. A length printed next to a truncated payload
/// says more than a parse error ever does.
pub async fn listen(
    device: &Device,
    duration: Duration,
    do_handshake: bool,
    scan_timeout: Duration,
) -> Result<()> {
    let peripheral = connect(device, scan_timeout).await?;

    if let Some(d) = scan::describe(&peripheral).await {
        println!("Connected to {} ({})", d.address, d.label());
        if let Some(kind) = d.zwift_kind() {
            println!("Advertised as: {}", kind.describe());
        }
        println!();
    }

    // The whole tree first, so the capture below can be read months later
    // without the device to hand.
    println!("=== GATT tree ===\n");
    let mut notifying: Vec<Characteristic> = Vec::new();
    for service in peripheral.services() {
        println!(
            "service {}{}{}",
            service.uuid,
            named(service.uuid),
            if service.primary { "  [primary]" } else { "" }
        );
        for ch in &service.characteristics {
            let subscribable = ch
                .properties
                .intersects(CharPropFlags::NOTIFY | CharPropFlags::INDICATE);
            println!(
                "    char {}{}\n         properties: {}{}",
                ch.uuid,
                named(ch.uuid),
                properties(ch.properties),
                if subscribable { "  <- will subscribe" } else { "" }
            );
            // Descriptors are the part `inspect` omits, and the CCCD is exactly
            // where a subscribe goes wrong: its value says whether the platform
            // enabled notification, indication, or nothing at all.
            for d in &ch.descriptors {
                match peripheral.read_descriptor(d).await {
                    Ok(v) => println!(
                        "         descriptor {}{}  = {}",
                        d.uuid,
                        named(d.uuid),
                        hex(&v)
                    ),
                    Err(e) => println!(
                        "         descriptor {}{}  <unreadable: {e}>",
                        d.uuid,
                        named(d.uuid)
                    ),
                }
            }
            if subscribable {
                notifying.push(ch.clone());
            }
        }
        println!();
    }

    if notifying.is_empty() {
        println!("!! Nothing here notifies or indicates. There is nothing to listen to.");
        disconnect(&peripheral).await;
        return Ok(());
    }

    let mut notifications = peripheral.notifications().await?;
    let start = Instant::now();

    println!("=== Subscribing ===\n");
    let mut live = 0usize;
    for ch in &notifying {
        match peripheral.subscribe(ch).await {
            Ok(()) => {
                live += 1;
                println!("  ok      {}{}", ch.uuid, named(ch.uuid));
            }
            // Reported rather than fatal: one characteristic refusing is itself
            // the finding, and the others may still carry what we came for.
            Err(e) => println!("  FAILED  {}{}  — {e}", ch.uuid, named(ch.uuid)),
        }
    }
    println!("\n{live} of {} subscribed.\n", notifying.len());

    let mut frames: u64 = 0;
    if do_handshake {
        let service = zwift::SERVICES
            .iter()
            .find_map(|want| peripheral.services().into_iter().find(|s| s.uuid == *want));
        match service.as_ref().and_then(writable) {
            Some(sync_rx) => {
                frames += handshake(&peripheral, &sync_rx, &mut notifications, start).await?;
            }
            None => println!(
                "No writable Zwift characteristic — greeting skipped. A Click will stay\n\
                 silent; anything that streams unprompted will not care.\n"
            ),
        }
    } else {
        println!("--no-handshake: writing nothing.\n");
    }

    println!(
        "Listening for {} s. Press buttons; Ctrl-C to stop early.\n",
        duration.saturating_sub(start.elapsed()).as_secs()
    );
    println!("    time  #     characteristic                        len  bytes");

    let deadline = tokio::time::sleep(duration.saturating_sub(start.elapsed()));
    tokio::pin!(deadline);

    // Per characteristic, so a summary can show which ones ever spoke — the
    // difference between "the pod is silent" and "we were listening in the
    // wrong place".
    let mut counts: std::collections::BTreeMap<Uuid, (u64, usize)> = Default::default();

    loop {
        tokio::select! {
            _ = &mut deadline => break,
            _ = tokio::signal::ctrl_c() => {
                println!("\nInterrupted.");
                break;
            }
            n = notifications.next() => {
                let Some(n) = n else {
                    println!("\nNotification stream ended (the device disconnected).");
                    break;
                };
                frames += 1;
                let entry = counts.entry(n.uuid).or_insert((0, 0));
                entry.0 += 1;
                entry.1 = entry.1.max(n.value.len());
                println!(
                    "  {:6.2}s #{:<4} {}{:<8} {:>3}  {}  {}",
                    start.elapsed().as_secs_f32(),
                    frames,
                    n.uuid,
                    named(n.uuid),
                    n.value.len(),
                    hex(&n.value),
                    as_text(&n.value),
                );
            }
        }
    }

    println!("\n=== {frames} frames ===\n");
    for (uuid, (count, longest)) in &counts {
        println!("  {uuid}{}  {count} frames, longest {longest} bytes", named(*uuid));
    }
    for ch in &notifying {
        if !counts.contains_key(&ch.uuid) {
            println!("  {}{}  silent", ch.uuid, named(ch.uuid));
        }
    }

    for ch in &notifying {
        let _ = peripheral.unsubscribe(ch).await;
    }
    disconnect(&peripheral).await;
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use bikecontrol_ble::indoor_bike_data::flag;

    #[test]
    fn flag_names_call_out_the_inverted_bit_zero() {
        // Bit 0 clear means speed IS present.
        assert!(flag_names(0x0000).contains("InstantaneousSpeed(bit0 clear)"));
        // Bit 0 set means it is not.
        assert!(flag_names(flag::MORE_DATA).contains("MoreData(bit0 set: no speed)"));
    }

    #[test]
    fn flag_names_list_every_present_field() {
        let names = flag_names(flag::INSTANTANEOUS_CADENCE | flag::INSTANTANEOUS_POWER);
        assert!(names.contains("Cadence"));
        assert!(names.contains("Power"));
        assert!(!names.contains("HeartRate"));
    }

    #[test]
    fn flag_names_flag_reserved_bits() {
        assert!(flag_names(0x8000).contains("<reserved bits set>"));
        assert!(!flag_names(0x0001).contains("<reserved bits set>"));
    }

    #[test]
    fn device_selector_mapping() {
        assert_eq!(
            Device::Address("AA:BB".into()).selector(),
            TrainerSelector::Address("AA:BB".into())
        );
        assert_eq!(
            Device::Name("D100".into()).selector(),
            TrainerSelector::NameContains("D100".into())
        );
    }

    #[test]
    fn describe_write_names_the_op_code() {
        assert_eq!(
            describe_write(&ControlTarget::Gradient { percent: 4.0 }, false).0,
            "SetTargetInclination (0x03)"
        );
        assert_eq!(
            describe_write(&ControlTarget::Gradient { percent: 4.0 }, true).0,
            "SetIndoorBikeSimulationParameters (0x11)"
        );
        assert_eq!(
            describe_write(&ControlTarget::Resistance { level: 10 }, false).0,
            "SetTargetResistanceLevel (0x04)"
        );
        assert_eq!(
            describe_write(&ControlTarget::Power { watts: 100 }, false).0,
            "SetTargetPower (0x05)"
        );
    }

    #[test]
    fn as_text_only_renders_printable_payloads() {
        assert_eq!(as_text(b"D100"), "\"D100\"");
        assert_eq!(as_text(&[0x00, 0x01, 0xff]), "");
        assert_eq!(as_text(&[]), "");
    }

    #[test]
    fn properties_are_listed_in_order() {
        assert_eq!(
            properties(CharPropFlags::READ | CharPropFlags::INDICATE),
            "read, indicate"
        );
        assert_eq!(properties(CharPropFlags::empty()), "(none)");
    }
}
