//! Hand-rolled argument parsing.
//!
//! Deliberately dependency-free: the probe is a Phase 0 diagnostic tool that
//! has to build and run on whatever machine is next to the trainer, and it does
//! not need an argument parser to do four subcommands.

use std::time::Duration;

use anyhow::{anyhow, bail, Result};
use bikecontrol_core::types::ControlTarget;

pub const USAGE: &str = "\
probe — Van Rysel D100 / FTMS protocol discovery (REQUIREMENTS.md Phase 0)

USAGE:
    probe <SUBCOMMAND> [OPTIONS]

SUBCOMMANDS:
    scan                       List BLE peripherals: name, address, RSSI, advertised services
    inspect <ADDR>             Connect and dump every service, characteristic and capability
    monitor <ADDR>             Stream Indoor Bike Data as raw hex alongside decoded fields
    set <ADDR> <TARGET>        Take control and apply a target, then reset the trainer to zero
    zwift <ADDR>               Talk to Zwift's custom service: handshake, then log every frame
    listen <ADDR>              Raw GATT: dump services, characteristics and descriptors, then
                               subscribe to everything and print each notification's
                               characteristic, length and bytes, undecoded

TARGET (for `set`):
    gradient=<PCT>             SetTargetInclination      (0x03), e.g. gradient=4.5
    sim=<PCT>                  SetIndoorBikeSimulation   (0x11) — this is what answers A-1
    resistance=<LEVEL>         SetTargetResistanceLevel  (0x04), e.g. resistance=30
    power=<WATTS>              SetTargetPower            (0x05), e.g. power=200

OPTIONS:
    --secs <N>       scan/monitor duration, or how long `set` holds the target (default:
                     scan 6, monitor 30, set 15, zwift 60)
    --all            `scan`: list every peripheral, not just fitness machines
    --name <SUBSTR>  use in place of <ADDR> to match on advertised name
    --no-handshake   `zwift`, `listen`: subscribe and listen without writing RideOn.
                     `listen` greets by default, because a Click that has not been
                     greeted sends nothing and an empty capture proves nothing
    --buttons        `zwift`: collapse the ~10 Hz button stream to one line per
                     press and release, for mapping bits to physical buttons
    -v, --verbose    debug-level logging, including every raw BLE frame (NFR-8)
    -h, --help       this text

ADDR is the address as printed by `scan` (on Linux, AA:BB:CC:DD:EE:FF).

SAFETY: `set` always finishes by zeroing the gradient, dropping resistance to the
trainer's minimum and issuing Reset + Stop (SAF-2), including on Ctrl-C. `zwift` is
read-mostly: the only thing it ever writes is the RideOn handshake.

The Click must be unlocked in the free Zwift app first — pair it there, hold it for
~30 s, then quit Zwift. The unlock lasts about a day (REQUIREMENTS.md §2.3).
";

#[derive(Debug, PartialEq)]
pub enum Command {
    Help,
    Scan {
        duration: Duration,
        all: bool,
    },
    Inspect {
        device: Device,
    },
    Monitor {
        device: Device,
        duration: Duration,
    },
    Set {
        device: Device,
        target: ControlTarget,
        /// True for `sim=`, which forces op code `0x11`.
        simulation: bool,
        hold: Duration,
    },
    /// Raw GATT interrogation: the whole tree including descriptors, then every
    /// notifying characteristic dumped as unparsed bytes.
    ///
    /// Deliberately knows nothing about FTMS or Zwift. When a device works on
    /// one platform and not another, the question is which characteristic
    /// carried what and how long it was — and any decoding layer is exactly
    /// what you cannot trust while asking it.
    Listen {
        device: Device,
        duration: Duration,
        /// Write the Zwift `RideOn` handshake first. A Click streams nothing
        /// until it is greeted, so listening alone hears silence from one.
        handshake: bool,
    },
    /// Phase 3 / TASK-0: exercise Zwift's custom service on whatever advertises
    /// it — a Click, or the trainer itself.
    Zwift {
        device: Device,
        duration: Duration,
        /// True to listen only, writing nothing at all.
        no_handshake: bool,
        /// True to print one line per button state change instead of every
        /// frame — the mode for mapping bits to physical buttons.
        buttons_only: bool,
    },
}

/// How the user identified the trainer.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Device {
    Address(String),
    Name(String),
}

#[derive(Debug, PartialEq)]
pub struct Args {
    pub command: Command,
    pub verbose: bool,
}

pub fn parse<I: IntoIterator<Item = String>>(argv: I) -> Result<Args> {
    let mut args: Vec<String> = argv.into_iter().collect();

    let mut verbose = false;
    let mut secs: Option<u64> = None;
    let mut all = false;
    let mut no_handshake = false;
    let mut buttons_only = false;
    let mut name: Option<String> = None;
    let mut help = false;
    let mut positional: Vec<String> = Vec::new();

    let mut i = 0;
    while i < args.len() {
        let arg = std::mem::take(&mut args[i]);
        match arg.as_str() {
            "-h" | "--help" | "help" => help = true,
            "-v" | "--verbose" => verbose = true,
            "--all" => all = true,
            "--no-handshake" => no_handshake = true,
            "--buttons" => buttons_only = true,
            "--secs" | "--seconds" => {
                i += 1;
                let v = args
                    .get(i)
                    .ok_or_else(|| anyhow!("--secs needs a value"))?
                    .clone();
                secs = Some(
                    v.parse()
                        .map_err(|_| anyhow!("--secs expects a whole number of seconds, got {v:?}"))?,
                );
            }
            "--name" => {
                i += 1;
                name = Some(
                    args.get(i)
                        .ok_or_else(|| anyhow!("--name needs a value"))?
                        .clone(),
                );
            }
            other if other.starts_with('-') => bail!("unknown option {other:?}"),
            other => positional.push(other.to_string()),
        }
        i += 1;
    }

    if help || positional.is_empty() {
        return Ok(Args {
            command: Command::Help,
            verbose,
        });
    }

    let device = |positional: &[String], index: usize| -> Result<Device> {
        if let Some(n) = &name {
            return Ok(Device::Name(n.clone()));
        }
        positional
            .get(index)
            .map(|a| Device::Address(a.clone()))
            .ok_or_else(|| anyhow!("this subcommand needs an address (or --name <SUBSTR>)"))
    };

    let command = match positional[0].as_str() {
        "scan" => Command::Scan {
            duration: Duration::from_secs(secs.unwrap_or(6)),
            all,
        },
        "inspect" => Command::Inspect {
            device: device(&positional, 1)?,
        },
        "monitor" => Command::Monitor {
            device: device(&positional, 1)?,
            duration: Duration::from_secs(secs.unwrap_or(30)),
        },
        "set" => {
            // With --name the address slot is absent, so the target may be at
            // index 1 or 2.
            let target_arg = if name.is_some() && positional.len() == 2 {
                positional[1].clone()
            } else {
                positional
                    .get(2)
                    .cloned()
                    .ok_or_else(|| anyhow!("`set` needs a target, e.g. gradient=4.5"))?
            };
            let (target, simulation) = parse_target(&target_arg)?;
            Command::Set {
                device: device(&positional, 1)?,
                target,
                simulation,
                hold: Duration::from_secs(secs.unwrap_or(15)),
            }
        }
        "listen" => Command::Listen {
            device: device(&positional, 1)?,
            duration: Duration::from_secs(secs.unwrap_or(60)),
            handshake: !no_handshake,
        },
        "zwift" => Command::Zwift {
            device: device(&positional, 1)?,
            duration: Duration::from_secs(secs.unwrap_or(60)),
            no_handshake,
            buttons_only,
        },
        other => bail!("unknown subcommand {other:?} — run `probe --help`"),
    };

    Ok(Args { command, verbose })
}

/// Parse `gradient=4.5`, `resistance=30`, `power=200` or `sim=4.5`.
///
/// Returns the target and whether simulation mode (`0x11`) was requested.
pub fn parse_target(s: &str) -> Result<(ControlTarget, bool)> {
    let (key, value) = s
        .split_once('=')
        .or_else(|| s.split_once(':'))
        .ok_or_else(|| anyhow!("target must look like `gradient=4.5`, got {s:?}"))?;

    let key = key.trim().to_lowercase();
    let value = value.trim();

    match key.as_str() {
        "gradient" | "grade" | "incline" | "inclination" => {
            let pct: f32 = value
                .parse()
                .map_err(|_| anyhow!("gradient must be a number of percent, got {value:?}"))?;
            Ok((ControlTarget::Gradient { percent: pct }, false))
        }
        "sim" | "simulation" | "simgrade" => {
            let pct: f32 = value
                .parse()
                .map_err(|_| anyhow!("sim grade must be a number of percent, got {value:?}"))?;
            Ok((ControlTarget::Gradient { percent: pct }, true))
        }
        "resistance" | "res" | "level" => {
            let level: i16 = value
                .parse()
                .map_err(|_| anyhow!("resistance must be a whole number, got {value:?}"))?;
            Ok((ControlTarget::Resistance { level }, false))
        }
        "power" | "watts" | "erg" => {
            let watts: u16 = value
                .parse()
                .map_err(|_| anyhow!("power must be a whole number of watts, got {value:?}"))?;
            Ok((ControlTarget::Power { watts }, false))
        }
        other => bail!("unknown target channel {other:?} — use gradient, sim, resistance or power"),
    }
}

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

    fn args(v: &[&str]) -> Result<Args> {
        parse(v.iter().map(|s| s.to_string()))
    }

    #[test]
    fn no_arguments_prints_help() {
        assert_eq!(args(&[]).unwrap().command, Command::Help);
        assert_eq!(args(&["--help"]).unwrap().command, Command::Help);
        assert_eq!(args(&["scan", "-h"]).unwrap().command, Command::Help);
    }

    #[test]
    fn scan_defaults_and_flags() {
        assert_eq!(
            args(&["scan"]).unwrap().command,
            Command::Scan {
                duration: Duration::from_secs(6),
                all: false
            }
        );
        assert_eq!(
            args(&["scan", "--all", "--secs", "12"]).unwrap().command,
            Command::Scan {
                duration: Duration::from_secs(12),
                all: true
            }
        );
    }

    #[test]
    fn verbose_is_recognised_anywhere() {
        assert!(args(&["-v", "scan"]).unwrap().verbose);
        assert!(args(&["scan", "--verbose"]).unwrap().verbose);
        assert!(!args(&["scan"]).unwrap().verbose);
    }

    #[test]
    fn inspect_and_monitor_take_an_address() {
        assert_eq!(
            args(&["inspect", "AA:BB:CC:DD:EE:FF"]).unwrap().command,
            Command::Inspect {
                device: Device::Address("AA:BB:CC:DD:EE:FF".into())
            }
        );
        assert_eq!(
            args(&["monitor", "AA:BB:CC:DD:EE:FF", "--secs", "5"])
                .unwrap()
                .command,
            Command::Monitor {
                device: Device::Address("AA:BB:CC:DD:EE:FF".into()),
                duration: Duration::from_secs(5)
            }
        );
    }

    #[test]
    fn name_substitutes_for_an_address() {
        assert_eq!(
            args(&["inspect", "--name", "D100"]).unwrap().command,
            Command::Inspect {
                device: Device::Name("D100".into())
            }
        );
        assert_eq!(
            args(&["set", "--name", "D100", "power=150"]).unwrap().command,
            Command::Set {
                device: Device::Name("D100".into()),
                target: ControlTarget::Power { watts: 150 },
                simulation: false,
                hold: Duration::from_secs(15),
            }
        );
    }

    #[test]
    fn set_parses_every_channel() {
        let cmd = args(&["set", "aa:bb", "gradient=4.5"]).unwrap().command;
        assert_eq!(
            cmd,
            Command::Set {
                device: Device::Address("aa:bb".into()),
                target: ControlTarget::Gradient { percent: 4.5 },
                simulation: false,
                hold: Duration::from_secs(15),
            }
        );

        let cmd = args(&["set", "aa:bb", "sim=-3.0", "--secs", "4"])
            .unwrap()
            .command;
        assert_eq!(
            cmd,
            Command::Set {
                device: Device::Address("aa:bb".into()),
                target: ControlTarget::Gradient { percent: -3.0 },
                simulation: true,
                hold: Duration::from_secs(4),
            }
        );
    }

    #[test]
    fn target_parsing_covers_aliases_and_signs() {
        assert_eq!(
            parse_target("grade=-7.5").unwrap(),
            (ControlTarget::Gradient { percent: -7.5 }, false)
        );
        assert_eq!(
            parse_target("res=30").unwrap(),
            (ControlTarget::Resistance { level: 30 }, false)
        );
        assert_eq!(
            parse_target("watts=250").unwrap(),
            (ControlTarget::Power { watts: 250 }, false)
        );
        assert_eq!(
            parse_target("SIM=6").unwrap(),
            (ControlTarget::Gradient { percent: 6.0 }, true)
        );
        // Colon works too, for shells that dislike `=`.
        assert_eq!(
            parse_target("power:100").unwrap(),
            (ControlTarget::Power { watts: 100 }, false)
        );
    }

    #[test]
    fn target_parsing_rejects_nonsense() {
        assert!(parse_target("gradient").is_err());
        assert!(parse_target("gradient=uphill").is_err());
        assert!(parse_target("torque=5").is_err());
        assert!(parse_target("power=-50").is_err(), "power is unsigned");
        assert!(parse_target("resistance=1.5").is_err(), "resistance is integral");
    }

    #[test]
    fn missing_and_unknown_arguments_are_errors() {
        assert!(args(&["inspect"]).is_err());
        assert!(args(&["set", "aa:bb"]).is_err());
        assert!(args(&["scan", "--secs"]).is_err());
        assert!(args(&["scan", "--secs", "soon"]).is_err());
        assert!(args(&["frobnicate"]).is_err());
        assert!(args(&["scan", "--wat"]).is_err());
    }
}
