//! Every intent the rider can express, as a Tauri command.
//!
//! Commands are *intents*, not state changes the frontend has already made:
//! they mutate Rust-side state and the resulting truth comes back on the event
//! channel. The UI never assumes a command took effect (§4.3).

use std::path::{Path, PathBuf};

use bikecontrol_core::gpx::{self, SmoothingConfig};
use bikecontrol_core::profile::Profile;
use bikecontrol_core::types::{ControlMode, RiderConfig, SafetyLimits};
use tauri::{AppHandle, State};
use tauri_plugin_fs::{FilePath, FsExt, OpenOptions};

use bikecontrol_ble::PodId;

use crate::controller::{ControllerStatus, Pod};
use crate::devices::DeviceInfo;
use crate::events::{DeviceList, LapSummary, Notice, RideState, RideStatus};
use crate::profile_view::{self, ProfileView};
use crate::recording::{self, Recovered, RideRecordingSetup, RideSummary};
use crate::state::{ack, emit_devices, emit_ride_state, notify, AppState};

type Cmd<T> = Result<T, String>;

/// Ride time now, for journal entries that need a timestamp. Zero before the
/// first tick, which is the correct answer rather than a missing one.
fn elapsed_ms(state: &AppState) -> u64 {
    state.lock().last_snapshot.map_or(0, |s| s.elapsed_ms)
}

// ---------------------------------------------------------------------------
// Ride state
// ---------------------------------------------------------------------------

#[tauri::command]
pub fn ride_state(state: State<'_, AppState>) -> RideState {
    state.lock().ride_state()
}

#[tauri::command]
pub fn start_ride(app: AppHandle, state: State<'_, AppState>) -> Cmd<RideState> {
    begin_ride(&app, &state);
    ack(&app, "start", None);
    emit_ride_state(&app);
    Ok(state.lock().ride_state())
}

/// Put the ride into `Running`, opening a journal if this is a fresh start.
///
/// Every route into a running ride goes through here — the Start button, the
/// space bar via [`toggle_pause`], and Click face button B. Any new path that
/// set `Running` on its own would ride with no recorder attached, and the rider
/// would not find out until the summary said nothing had been saved.
fn begin_ride(app: &AppHandle, state: &AppState) {
    let setup = {
        let mut inner = state.lock();
        // A fresh ride, as opposed to resuming a paused one. Only a fresh ride
        // opens a new journal; resuming must keep writing to the current one.
        let fresh = matches!(inner.inputs.status, RideStatus::Finished | RideStatus::Idle);
        if fresh {
            inner.reset_ride();
        }
        inner.inputs.status = RideStatus::Running;
        fresh.then(|| RideRecordingSetup {
            stamp: recording::stamp_now(),
            rider_kg: inner.inputs.rider.rider_kg,
            has_profile: inner.inputs.profile.is_some(),
        })
    };
    // Started outside the lock: creating the journal touches the disk.
    let Some(setup) = setup else {
        // Resuming, not starting: the journal is already open and only needs
        // its timer restarted.
        state.recorder().resume(elapsed_ms(state));
        return;
    };
    let started = recording::rides_dir(app).and_then(|dir| state.recorder().start(&dir, setup));
    if let Err(e) = started {
        // The ride still starts. Refusing to ride because a file could not be
        // opened would be the wrong trade — but the rider has to be told this
        // one will not be saved.
        tracing::error!(%e, "recording did not start");
        notify(
            app,
            Notice::error(format!("{e} — this ride will not be saved")),
        );
    }
}

#[tauri::command]
pub fn pause_ride(app: AppHandle, state: State<'_, AppState>) -> Cmd<RideState> {
    state.lock().inputs.status = RideStatus::Paused;
    state.recorder().pause(elapsed_ms(&state));
    ack(&app, "pause", None);
    emit_ride_state(&app);
    Ok(state.lock().ride_state())
}

#[tauri::command]
pub fn resume_ride(app: AppHandle, state: State<'_, AppState>) -> Cmd<RideState> {
    // Not a bare status assignment: resuming from `Finished` is a *new* ride and
    // must open a journal rather than run on unrecorded.
    begin_ride(&app, &state);
    ack(&app, "resume", None);
    emit_ride_state(&app);
    Ok(state.lock().ride_state())
}

/// Pause or resume, whichever is the opposite of now. This is the one bound to
/// the space bar and to Click face button B, and it is also how a ride is
/// *started* from the launch screen — hence the trip through [`begin_ride`]
/// rather than a status assignment.
#[tauri::command]
pub fn toggle_pause(app: AppHandle, state: State<'_, AppState>) -> Cmd<RideState> {
    let running = state.lock().inputs.status == RideStatus::Running;
    if running {
        state.lock().inputs.status = RideStatus::Paused;
        state.recorder().pause(elapsed_ms(&state));
    } else {
        begin_ride(&app, &state);
    }
    let status = state.lock().inputs.status;
    ack(&app, "toggle-pause", Some(format!("{status:?}")));
    emit_ride_state(&app);
    Ok(state.lock().ride_state())
}

/// End the ride. SAF-2: the trainer is returned to 0% / minimum resistance
/// before the session closes.
///
/// The activity is written automatically, before anything is shown and before
/// the rider is asked anything (FR-8.2). Saving to a location they choose is a
/// copy made afterwards (FR-9.14, [`save_fit`]) — a rider who cancels that
/// dialog, or closes the window, still has their ride.
#[tauri::command]
pub fn stop_ride(app: AppHandle, state: State<'_, AppState>) -> Cmd<RideState> {
    state.lock().inputs.status = RideStatus::Finished;
    // The trainer comes first. Whatever happens to the file, the rider must not
    // be left on a loaded trainer while we talk to the disk.
    crate::state::release_trainer(&app);
    ack(&app, "stop", None);
    notify(&app, Notice::info("Ride ended — trainer released to 0%"));

    match state.recorder().finish() {
        Ok(Some((summary, fit_path))) => {
            let summary = RideSummary::new(&summary, &fit_path);
            state.lock().last_summary = Some(summary.clone());
            let _ = tauri::Emitter::emit(&app, crate::events::RIDE_SUMMARY, summary);
            recording::prune(&app, KEEP_RECORDINGS);
        }
        // Nothing was recording — a ride that never started, or a recorder that
        // failed to open at the start and already said so.
        Ok(None) => {}
        Err(e) => {
            tracing::error!(%e, "could not finalise the activity");
            notify(&app, Notice::error(e));
        }
    }

    emit_ride_state(&app);
    Ok(state.lock().ride_state())
}

/// How many finished rides stay in the app's data directory.
///
/// §5.8 puts ride *history* out of scope for v1: the FIT the rider saved is the
/// artifact, and this directory is the safety net behind it. Unbounded it would
/// grow forever somewhere nobody looks.
pub const KEEP_RECORDINGS: usize = 20;

/// Rides rebuilt from an interrupted session at startup (FR-8.4).
///
/// Draining rather than reading: this is reported to the rider once, and a
/// webview reload should not re-announce a recovery they have already seen.
#[tauri::command]
pub fn recovered_rides(state: State<'_, AppState>) -> Vec<Recovered> {
    std::mem::take(&mut state.lock().recovered)
}

/// The most recently finished ride, if the summary screen is reloaded.
#[tauri::command]
pub fn ride_summary(state: State<'_, AppState>) -> Option<RideSummary> {
    state.lock().last_summary.clone()
}

/// Save the finished activity where the rider asked (FR-9.14).
///
/// Returns the path actually written, so the UI can confirm it rather than
/// claiming success against a path it merely proposed.
#[tauri::command]
pub fn save_fit(app: AppHandle, state: State<'_, AppState>, path: FilePath) -> Cmd<String> {
    let source = {
        let inner = state.lock();
        let summary = inner
            .last_summary
            .as_ref()
            .ok_or("There is no finished ride to save")?;
        PathBuf::from(&summary.fit_path)
    };

    match &path {
        FilePath::Path(dest) => recording::save_copy(&source, dest)?,
        // Android: the save dialog returns a `content://` URI for a document
        // the provider has already created. There is no directory to make and
        // no path to copy to — the bytes go down a descriptor the resolver
        // opens, which is the same reason `read_picked_file` exists.
        FilePath::Url(_) => write_through_resolver(&app, &path, &source)?,
    }
    let written = path.to_string();
    if let Some(summary) = state.lock().last_summary.as_mut() {
        summary.saved_path = Some(written.clone());
    }
    ack(&app, "save-fit", Some(written.clone()));
    notify(&app, Notice::info(format!("Ride saved to {written}")));
    Ok(written)
}

/// Copy the activity into a document the rider chose from an Android picker.
///
/// The `std::fs` path in `recording::save_copy` cannot do this: there is no
/// filesystem path on the other end, only a URI the content resolver can turn
/// into a writable descriptor. Still a copy, never a move, for the reason
/// `save_copy` documents — the automatic file in the rides directory has to
/// survive a failed export.
fn write_through_resolver(app: &AppHandle, dest: &FilePath, source: &Path) -> Result<(), String> {
    let mut from = std::fs::File::open(source)
        .map_err(|e| format!("{} is gone — nothing to save: {e}", source.display()))?;
    let mut to = app
        .fs()
        .open(
            dest.clone(),
            OpenOptions::new().write(true).truncate(true).clone(),
        )
        .map_err(|e| format!("could not save to {dest}: {e}"))?;
    std::io::copy(&mut from, &mut to)
        .map(|_| ())
        .map_err(|e| format!("could not save to {dest}: {e}"))
}

#[tauri::command]
pub fn reset_ride(app: AppHandle, state: State<'_, AppState>) -> Cmd<RideState> {
    state.lock().reset_ride();
    emit_ride_state(&app);
    Ok(state.lock().ride_state())
}

// ---------------------------------------------------------------------------
// Control modes and targets (§5.4)
// ---------------------------------------------------------------------------

/// Mode cycle order, matching the on-screen control and Click face button A.
const MODE_CYCLE: [ControlMode; 4] = [
    ControlMode::ManualGrade,
    ControlMode::Profile,
    ControlMode::Resistance,
    ControlMode::Erg,
];

#[tauri::command]
pub fn set_control_mode(
    app: AppHandle,
    state: State<'_, AppState>,
    mode: ControlMode,
) -> Cmd<RideState> {
    {
        let mut inner = state.lock();
        if mode == ControlMode::Profile && inner.inputs.profile.is_none() {
            return Err("No profile loaded — load a GPX or YAML profile first".into());
        }
        inner.inputs.mode = mode;
    }
    ack(&app, "mode", Some(format!("{mode:?}")));
    emit_ride_state(&app);
    Ok(state.lock().ride_state())
}

#[tauri::command]
pub fn cycle_control_mode(app: AppHandle, state: State<'_, AppState>) -> Cmd<RideState> {
    let mode = {
        let mut inner = state.lock();
        let has_profile = inner.inputs.profile.is_some();
        let current = inner.inputs.mode;
        let start = MODE_CYCLE.iter().position(|m| *m == current).unwrap_or(0);
        let mut chosen = current;
        for step in 1..=MODE_CYCLE.len() {
            let candidate = MODE_CYCLE[(start + step) % MODE_CYCLE.len()];
            if candidate == ControlMode::Profile && !has_profile {
                continue;
            }
            chosen = candidate;
            break;
        }
        inner.inputs.mode = chosen;
        chosen
    };
    ack(&app, "mode", Some(format!("{mode:?}")));
    emit_ride_state(&app);
    Ok(state.lock().ride_state())
}

/// Shift the virtual gear by `delta` (FR-4.1).
///
/// Clamps at both ends rather than wrapping: going from top gear straight to
/// bottom mid-climb would be violent, and a rider holding the paddle down
/// expects to arrive at the end of the cassette and stay there.
///
/// This is the one place a shift happens. The controller loop and the keyboard
/// both route here, so the pod and the keys cannot drift apart, and neither can
/// also nudge the gradient on the way past — a shift changes how hard the
/// pedals are, not what the road is doing.
#[tauri::command]
pub fn shift_gear(app: AppHandle, state: State<'_, AppState>, delta: i32) -> Cmd<RideState> {
    let (gear, count) = {
        let mut inner = state.lock();
        inner.inputs.shift_gear(delta);
        (inner.inputs.gear, inner.inputs.gear_count())
    };
    ack(&app, "gear", Some(format!("{gear}/{count}")));
    emit_ride_state(&app);
    Ok(state.lock().ride_state())
}

/// Select a gear directly, one-based (FR-4.1). Out-of-range values clamp.
#[tauri::command]
pub fn set_gear(app: AppHandle, state: State<'_, AppState>, gear: usize) -> Cmd<RideState> {
    let (gear, count) = {
        let mut inner = state.lock();
        inner.inputs.set_gear(gear);
        (inner.inputs.gear, inner.inputs.gear_count())
    };
    ack(&app, "gear", Some(format!("{gear}/{count}")));
    emit_ride_state(&app);
    Ok(state.lock().ride_state())
}

/// FR-4.2 / SAF-5 — one configured increment per event, never more.
#[tauri::command]
pub fn nudge_gradient(
    app: AppHandle,
    state: State<'_, AppState>,
    delta_pct: f32,
) -> Cmd<RideState> {
    let step = delta_pct.clamp(-2.0, 2.0);
    {
        let mut inner = state.lock();
        match inner.inputs.mode {
            ControlMode::ManualGrade => inner.inputs.manual_gradient_pct += step,
            // In profile mode the nudge trims on top of the profile's gradient.
            _ => inner.inputs.gradient_offset_pct += step,
        }
        let limits = inner.inputs.limits;
        inner.inputs.manual_gradient_pct = inner
            .inputs
            .manual_gradient_pct
            .clamp(limits.min_gradient_pct, limits.max_gradient_pct);
        inner.inputs.gradient_offset_pct = inner.inputs.gradient_offset_pct.clamp(-10.0, 10.0);
    }
    ack(&app, "gradient", Some(format!("{step:+.1}%")));
    emit_ride_state(&app);
    Ok(state.lock().ride_state())
}

#[tauri::command]
pub fn set_gradient(app: AppHandle, state: State<'_, AppState>, percent: f32) -> Cmd<RideState> {
    {
        let mut inner = state.lock();
        let limits = inner.inputs.limits;
        inner.inputs.manual_gradient_pct =
            percent.clamp(limits.min_gradient_pct, limits.max_gradient_pct);
    }
    ack(&app, "gradient", Some(format!("{percent:.1}%")));
    emit_ride_state(&app);
    Ok(state.lock().ride_state())
}

#[tauri::command]
pub fn reset_gradient(app: AppHandle, state: State<'_, AppState>) -> Cmd<RideState> {
    {
        let mut inner = state.lock();
        inner.inputs.gradient_offset_pct = 0.0;
        inner.inputs.manual_gradient_pct = 0.0;
    }
    ack(&app, "gradient-reset", None);
    emit_ride_state(&app);
    Ok(state.lock().ride_state())
}

#[tauri::command]
pub fn set_target_resistance(
    app: AppHandle,
    state: State<'_, AppState>,
    level: i16,
) -> Cmd<RideState> {
    {
        let mut inner = state.lock();
        let limits = inner.inputs.limits;
        inner.inputs.resistance_level = level.clamp(limits.min_resistance, limits.max_resistance);
    }
    ack(&app, "resistance", Some(format!("{level}")));
    emit_ride_state(&app);
    Ok(state.lock().ride_state())
}

#[tauri::command]
pub fn set_target_power(app: AppHandle, state: State<'_, AppState>, watts: u16) -> Cmd<RideState> {
    {
        let mut inner = state.lock();
        let limits = inner.inputs.limits;
        inner.inputs.power_target_w = watts.clamp(limits.min_power_w, limits.max_power_w);
    }
    ack(&app, "power", Some(format!("{watts} W")));
    emit_ride_state(&app);
    Ok(state.lock().ride_state())
}

#[tauri::command]
pub fn mark_lap(app: AppHandle, state: State<'_, AppState>) -> Cmd<LapSummary> {
    let lap = state.lock().mark_lap();
    // The journal takes the ride time the lap closed at, not the lap's own
    // duration — the two differ from the second lap onwards.
    state.recorder().mark_lap(elapsed_ms(&state), false);
    let _ = tauri::Emitter::emit(&app, crate::events::RIDE_LAP, lap);
    ack(&app, "lap", Some(format!("Lap {}", lap.index)));
    emit_ride_state(&app);
    Ok(lap)
}

// ---------------------------------------------------------------------------
// Rider and safety configuration
// ---------------------------------------------------------------------------

#[tauri::command]
pub fn rider_config(state: State<'_, AppState>) -> RiderConfig {
    state.lock().inputs.rider
}

#[tauri::command]
pub fn set_rider_config(
    app: AppHandle,
    state: State<'_, AppState>,
    config: RiderConfig,
) -> Cmd<RiderConfig> {
    if config.rider_kg <= 20.0 || config.bike_kg <= 0.0 {
        return Err("Rider and bike mass must be positive and realistic".into());
    }
    state.lock().inputs.rider = config;
    emit_ride_state(&app);
    Ok(config)
}

#[tauri::command]
pub fn safety_limits(state: State<'_, AppState>) -> SafetyLimits {
    state.lock().inputs.limits
}

#[tauri::command]
pub fn set_safety_limits(
    app: AppHandle,
    state: State<'_, AppState>,
    limits: SafetyLimits,
) -> Cmd<SafetyLimits> {
    if limits.min_gradient_pct >= limits.max_gradient_pct {
        return Err("Gradient limits are inverted".into());
    }
    state.lock().inputs.limits = limits;
    emit_ride_state(&app);
    Ok(limits)
}

// ---------------------------------------------------------------------------
// Profiles (§5.5, §5.6)
// ---------------------------------------------------------------------------

fn parse_profile(text: &str, name: &str, is_gpx: bool) -> Result<Profile, String> {
    if is_gpx {
        // FR-5.2/5.3: core smooths the elevation before differentiating and
        // clamps the result. The defaults are the spec's defaults.
        gpx::import(text, name, &SmoothingConfig::default()).map_err(|e| e.to_string())
    } else {
        Profile::from_yaml(text).map_err(|e| e.to_string())
    }
}

/// Read a file the rider picked, wherever it actually lives.
///
/// `std::fs` is not enough, and Android is why. The dialog plugin's picker
/// fires `ACTION_GET_CONTENT`, which hands back a `content://` URI rather than
/// a path; `std::fs::read_to_string` on one of those fails with "no such file
/// or directory" — an error the rider gets for a file they are looking at in
/// the picker. And for a provider backed by a server rather than storage
/// (Nextcloud, Drive) there may be no local file at all until the resolver
/// opens the stream, so no amount of path-guessing could have found one.
///
/// `tauri_plugin_fs` is the piece that knows the difference: a plain path is
/// opened directly, a URI goes through the Android content resolver for a file
/// descriptor. On desktop it is `std::fs` with extra steps.
fn read_picked_file(app: &AppHandle, path: &FilePath) -> Result<String, String> {
    app.fs()
        .read_to_string(path.clone())
        .map_err(|e| format!("{path}: {e}"))
}

/// Whether a picked file is GPX, decided by content rather than by name.
///
/// The extension is not always there to read: a `content://` URI carries a
/// document id, which for some providers contains no filename at all. XML is
/// unmistakable next to the YAML profile format — no YAML document begins with
/// `<` — so the first non-space character is the reliable test and the
/// extension is only a fast path.
fn looks_like_gpx(path: &FilePath, text: &str) -> bool {
    path.to_string().to_ascii_lowercase().ends_with(".gpx") || text.trim_start().starts_with('<')
}

/// The filename behind a picked file, if there is one to be had.
///
/// A `FilePath::Path` always has a stem. A URI might: providers over real
/// storage encode the path in the last segment, percent-escaped but with the
/// extension intact (`primary%3ADownload%2Fventoux.gpx`). Others use opaque row
/// ids, which would make a terrible route name — so "does it end in an
/// extension we know" is the test, and anything else gets `None` and falls back
/// to the name inside the GPX.
fn picked_file_stem(path: &FilePath) -> Option<String> {
    match path {
        FilePath::Path(p) => p.file_stem().map(|s| s.to_string_lossy().to_string()),
        FilePath::Url(url) => {
            let (stem, ext) = url.path_segments()?.next_back()?.rsplit_once('.')?;
            if !matches!(ext.to_ascii_lowercase().as_str(), "gpx" | "yaml" | "yml") {
                return None;
            }
            // The escaped separators are all that stands between the document
            // id and the name inside it.
            let decoded = stem
                .replace("%2F", "/")
                .replace("%2f", "/")
                .replace("%3A", ":")
                .replace("%3a", ":");
            let name = decoded.rsplit(['/', ':']).next().unwrap_or(&decoded);
            (!name.is_empty()).then(|| name.to_string())
        }
    }
}

/// The route's own name: `<metadata><name>`, else the first `<trk><name>`.
///
/// The fallback when the picker gave us no filename to use. Matches on local
/// names so a namespaced document (`<g:trk>`) is not silently skipped, for the
/// same reason `core::gpx` does.
fn gpx_name(xml: &str) -> Option<String> {
    let doc = roxmltree::Document::parse(xml).ok()?;
    let named = |parent: &str| {
        doc.descendants()
            .find(|n| n.is_element() && n.tag_name().name() == parent)?
            .children()
            .find(|c| c.is_element() && c.tag_name().name() == "name")?
            .text()
            .map(str::trim)
            .filter(|s| !s.is_empty())
            .map(str::to_string)
    };
    named("metadata").or_else(|| named("trk"))
}

/// Load a profile the rider picked: a path on desktop, a `content://` URI on
/// Android. GPX is detected by content, everything else is treated as the YAML
/// profile format.
#[tauri::command]
pub fn load_profile_from_path(
    app: AppHandle,
    state: State<'_, AppState>,
    path: FilePath,
) -> Cmd<ProfileView> {
    let text = read_picked_file(&app, &path)?;
    let is_gpx = looks_like_gpx(&path, &text);
    let name = picked_file_stem(&path)
        .or_else(|| gpx_name(&text))
        .unwrap_or_else(|| "Profile".into());
    let profile = parse_profile(&text, &name, is_gpx)?;
    let (view, geom) = profile_view::build(&profile, path.to_string());
    state.lock().set_profile(profile, view.clone(), geom);
    emit_ride_state(&app);
    notify(
        &app,
        Notice::info(format!("Loaded profile “{}”", view.name)),
    );
    Ok(view)
}

/// Load from text the frontend already has — used by the drop target, the
/// built-in samples and the profile editor.
#[tauri::command]
pub fn load_profile_from_text(
    app: AppHandle,
    state: State<'_, AppState>,
    name: String,
    text: String,
    is_gpx: bool,
) -> Cmd<ProfileView> {
    let profile = parse_profile(&text, &name, is_gpx)?;
    let (view, geom) = profile_view::build(&profile, name);
    state.lock().set_profile(profile, view.clone(), geom);
    emit_ride_state(&app);
    notify(
        &app,
        Notice::info(format!("Loaded profile “{}”", view.name)),
    );
    Ok(view)
}

/// Parse and preview without loading — the editor calls this on every keystroke
/// so errors surface as you type rather than when you press Ride.
#[tauri::command]
pub fn preview_profile_yaml(yaml: String) -> Cmd<ProfileView> {
    let profile = Profile::from_yaml(&yaml).map_err(|e| e.to_string())?;
    Ok(profile_view::build(&profile, "editor").0)
}

#[tauri::command]
pub fn clear_profile(app: AppHandle, state: State<'_, AppState>) -> Cmd<RideState> {
    state.lock().clear_profile();
    emit_ride_state(&app);
    Ok(state.lock().ride_state())
}

#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SampleProfile {
    pub name: String,
    pub summary: String,
    /// YAML profile source, or GPX XML when `is_gpx`.
    pub text: String,
    pub is_gpx: bool,
}

/// Profiles shipped with the app, so there is always something to ride.
#[tauri::command]
pub fn sample_profiles() -> Vec<SampleProfile> {
    crate::samples::all()
}

// ---------------------------------------------------------------------------
// Devices (FR-1, FR-9.1–9.3)
// ---------------------------------------------------------------------------

#[tauri::command]
pub fn device_list(state: State<'_, AppState>) -> DeviceList {
    let inner = state.lock();
    DeviceList {
        scanning: inner.devices.scanning,
        devices: inner.devices.list(),
    }
}

#[tauri::command]
pub fn start_scan(app: AppHandle, state: State<'_, AppState>) -> Cmd<()> {
    state.lock().devices.start_scan();
    emit_devices(&app);
    Ok(())
}

#[tauri::command]
pub fn stop_scan(app: AppHandle, state: State<'_, AppState>) -> Cmd<()> {
    state.lock().devices.stop_scan();
    emit_devices(&app);
    Ok(())
}

#[tauri::command]
pub fn connect_device(
    app: AppHandle,
    state: State<'_, AppState>,
    device_id: String,
) -> Cmd<DeviceInfo> {
    let info = state.lock().devices.connect(&device_id)?;
    emit_devices(&app);
    Ok(info)
}

#[tauri::command]
pub fn disconnect_device(
    app: AppHandle,
    state: State<'_, AppState>,
    device_id: String,
) -> Cmd<DeviceInfo> {
    let info = state.lock().devices.disconnect(&device_id)?;
    emit_devices(&app);
    notify(&app, Notice::info(format!("Disconnected {}", info.name)));
    Ok(info)
}

#[tauri::command]
pub fn forget_device(app: AppHandle, state: State<'_, AppState>, device_id: String) -> Cmd<()> {
    state.lock().devices.forget(&device_id)?;
    emit_devices(&app);
    Ok(())
}

/// True once a trainer has FTMS control. This is what gates the ride screen:
/// connected is not controllable, and a ride nothing is driving is not a ride
/// (FR-9.3).
#[tauri::command]
pub fn trainer_controllable(state: State<'_, AppState>) -> bool {
    state.lock().devices.trainer_controllable()
}

/// Ask the platform to switch the Bluetooth radio on.
///
/// Only Android can answer this: there, a disabled radio is a normal state the
/// rider reaches by accident and can fix from inside the app. On desktop the
/// remedy is the system's business, so this is a no-op and the connection screen
/// keeps showing the adapter error.
///
/// Returns nothing on purpose. The rider may decline, and some OEM dialogs claim
/// success before the radio is up, so the only trustworthy answer is the one
/// that arrives on the device list a moment later (§4.3).
#[tauri::command]
pub fn request_bluetooth_enable() {
    #[cfg(target_os = "android")]
    crate::android::request_bluetooth_enable();
}

// ---------------------------------------------------------------------------
// Controller (Zwift Click)
// ---------------------------------------------------------------------------

#[tauri::command]
pub fn controller_status(state: State<'_, AppState>) -> ControllerStatus {
    state.controller().status()
}

/// Connect a Click pod, or both when `pod` is omitted (FR-1.4).
///
/// `device_id` is an address, for a specific pod the scanner has already
/// listed. Without one the supervisor looks the pod up by the type byte in its
/// advertisement — never by name, because both pods of a pair advertise the
/// same one and the app used to get whichever answered first.
///
/// Fire-and-forget: the supervisor owns the radio and the result arrives on
/// `controller://status`. A Click sleeps within seconds and only advertises
/// after a button press (A-4), so this routinely takes a few attempts — which
/// is why it must not block the UI thread waiting for one.
#[tauri::command]
pub fn connect_controller(
    state: State<'_, AppState>,
    pod: Option<Pod>,
    device_id: Option<String>,
) -> Cmd<()> {
    let controller = state.controller();
    let address = device_id.filter(|id| !id.trim().is_empty());
    match pod {
        Some(pod) => controller.connect(pod.into(), address),
        None => {
            if address.is_some() {
                return Err("An address names one pod, so say which pod it is".into());
            }
            // Both, each on its own schedule: a pod that is awake connects now
            // rather than queueing behind its sleeping twin.
            let known = state.lock().devices.click_pod_addresses();
            for id in PodId::BOTH {
                controller.connect(id, known.get(&id).cloned());
            }
        }
    }
    Ok(())
}

/// Disconnect one pod, or both when `pod` is omitted.
#[tauri::command]
pub fn disconnect_controller(
    app: AppHandle,
    state: State<'_, AppState>,
    pod: Option<Pod>,
) -> Cmd<()> {
    state.controller().disconnect(pod.map(PodId::from));
    notify(
        &app,
        Notice::info(match pod {
            Some(Pod::Plus) => "+ pod disconnected",
            Some(Pod::Minus) => "− pod disconnected",
            None => "Both Click pods disconnected",
        }),
    );
    Ok(())
}

/// Exchange the two pods, for when they answer to the other name.
///
/// §2.3.1 confirms one manufacturer-data type byte per pod but not which byte
/// belongs to which, so the app starts from a documented guess. Pressing a
/// paddle shows the rider whether the guess was right; this is how they fix it
/// if it was not.
#[tauri::command]
pub fn swap_controller_pods(app: AppHandle, state: State<'_, AppState>) -> Cmd<()> {
    state.controller().swap();
    notify(&app, Notice::info("Swapped the + and − pods"));
    Ok(())
}
