//! What the trainer says it can do, and the gate that stops us asking it for
//! anything else.
//!
//! Two sources:
//!
//! * **Fitness Machine Feature** (`0x2ACC`) — two little-endian uint32
//!   bitfields: machine features, then target-setting features. The second is
//!   what tells us whether `SetTargetInclination`, `SetTargetResistanceLevel`,
//!   `SetTargetPower` and `SetIndoorBikeSimulationParameters` are supported.
//! * **Supported * Range** characteristics — `0x2AD5` inclination, `0x2AD6`
//!   resistance level, `0x2AD8` power. Each is `min, max, increment`.
//!
//! Together these satisfy FR-2.6: never send an unsupported or out-of-range
//! command. [`gate_target`] is the single choke point; it is a pure function so
//! the whole of SAF-3 is unit-testable without hardware.

use bikecontrol_core::types::{ControlTarget, SafetyLimits};

use crate::control_point::OpCode;

/// Why a [`ControlTarget`] cannot be sent to this trainer.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum UnsupportedTarget {
    #[error("trainer does not advertise support for {0}")]
    OpCodeUnsupported(OpCode),
    #[error(
        "trainer's supported range for {what} is {min}..={max}, which excludes every value \
         permitted by the configured safety limits"
    )]
    EmptyRange {
        what: &'static str,
        min: i32,
        max: i32,
    },
}

/// Decoded Fitness Machine Feature characteristic (`0x2ACC`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct FitnessMachineFeature {
    /// Fitness Machine Features bitfield (what the machine *measures*).
    pub machine: u32,
    /// Target Setting Features bitfield (what the machine can be *told*).
    pub target: u32,
}

/// Bit positions in the Fitness Machine Features field.
pub mod machine_feature {
    pub const AVERAGE_SPEED: u32 = 1 << 0;
    pub const CADENCE: u32 = 1 << 1;
    pub const TOTAL_DISTANCE: u32 = 1 << 2;
    pub const INCLINATION: u32 = 1 << 3;
    pub const ELEVATION_GAIN: u32 = 1 << 4;
    pub const PACE: u32 = 1 << 5;
    pub const STEP_COUNT: u32 = 1 << 6;
    pub const RESISTANCE_LEVEL: u32 = 1 << 7;
    pub const STRIDE_COUNT: u32 = 1 << 8;
    pub const EXPENDED_ENERGY: u32 = 1 << 9;
    pub const HEART_RATE_MEASUREMENT: u32 = 1 << 10;
    pub const METABOLIC_EQUIVALENT: u32 = 1 << 11;
    pub const ELAPSED_TIME: u32 = 1 << 12;
    pub const REMAINING_TIME: u32 = 1 << 13;
    pub const POWER_MEASUREMENT: u32 = 1 << 14;
    pub const FORCE_ON_BELT_AND_POWER_OUTPUT: u32 = 1 << 15;
    pub const USER_DATA_RETENTION: u32 = 1 << 16;
}

/// Bit positions in the Target Setting Features field.
pub mod target_feature {
    pub const SPEED: u32 = 1 << 0;
    pub const INCLINATION: u32 = 1 << 1;
    pub const RESISTANCE: u32 = 1 << 2;
    pub const POWER: u32 = 1 << 3;
    pub const HEART_RATE: u32 = 1 << 4;
    pub const EXPENDED_ENERGY: u32 = 1 << 5;
    pub const STEP_NUMBER: u32 = 1 << 6;
    pub const STRIDE_NUMBER: u32 = 1 << 7;
    pub const DISTANCE: u32 = 1 << 8;
    pub const TRAINING_TIME: u32 = 1 << 9;
    pub const TIME_IN_TWO_HR_ZONES: u32 = 1 << 10;
    pub const TIME_IN_THREE_HR_ZONES: u32 = 1 << 11;
    pub const TIME_IN_FIVE_HR_ZONES: u32 = 1 << 12;
    /// Bit 13 — `SetIndoorBikeSimulationParameters` (`0x11`). Open question A-1.
    pub const INDOOR_BIKE_SIMULATION: u32 = 1 << 13;
    pub const WHEEL_CIRCUMFERENCE: u32 = 1 << 14;
    pub const SPIN_DOWN: u32 = 1 << 15;
    pub const CADENCE: u32 = 1 << 16;
}

/// A characteristic (`0x2ACC` etc.) was shorter than its definition requires.
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
#[error("{what} characteristic is {len} bytes; {need} are required")]
pub struct FieldTooShort {
    pub what: &'static str,
    pub len: usize,
    pub need: usize,
}

impl FitnessMachineFeature {
    /// Decode the 8-byte Fitness Machine Feature characteristic.
    pub fn decode(data: &[u8]) -> Result<Self, FieldTooShort> {
        if data.len() < 8 {
            return Err(FieldTooShort {
                what: "Fitness Machine Feature",
                len: data.len(),
                need: 8,
            });
        }
        Ok(Self {
            machine: u32::from_le_bytes([data[0], data[1], data[2], data[3]]),
            target: u32::from_le_bytes([data[4], data[5], data[6], data[7]]),
        })
    }

    pub fn has_machine(self, bit: u32) -> bool {
        self.machine & bit != 0
    }

    pub fn has_target(self, bit: u32) -> bool {
        self.target & bit != 0
    }

    pub fn supports_inclination_target(self) -> bool {
        self.has_target(target_feature::INCLINATION)
    }

    pub fn supports_resistance_target(self) -> bool {
        self.has_target(target_feature::RESISTANCE)
    }

    pub fn supports_power_target(self) -> bool {
        self.has_target(target_feature::POWER)
    }

    /// A-1: whether `0x11` is advertised. Advertised support and *actual*
    /// support are not the same thing — `probe set` writes the op code to find
    /// out for certain.
    pub fn supports_simulation(self) -> bool {
        self.has_target(target_feature::INDOOR_BIKE_SIMULATION)
    }

    /// Human-readable list of set machine-feature bits, for the probe CLI.
    pub fn machine_feature_names(self) -> Vec<&'static str> {
        use machine_feature as m;
        let table: [(u32, &'static str); 17] = [
            (m::AVERAGE_SPEED, "Average Speed"),
            (m::CADENCE, "Cadence"),
            (m::TOTAL_DISTANCE, "Total Distance"),
            (m::INCLINATION, "Inclination"),
            (m::ELEVATION_GAIN, "Elevation Gain"),
            (m::PACE, "Pace"),
            (m::STEP_COUNT, "Step Count"),
            (m::RESISTANCE_LEVEL, "Resistance Level"),
            (m::STRIDE_COUNT, "Stride Count"),
            (m::EXPENDED_ENERGY, "Expended Energy"),
            (m::HEART_RATE_MEASUREMENT, "Heart Rate Measurement"),
            (m::METABOLIC_EQUIVALENT, "Metabolic Equivalent"),
            (m::ELAPSED_TIME, "Elapsed Time"),
            (m::REMAINING_TIME, "Remaining Time"),
            (m::POWER_MEASUREMENT, "Power Measurement"),
            (m::FORCE_ON_BELT_AND_POWER_OUTPUT, "Force on Belt and Power Output"),
            (m::USER_DATA_RETENTION, "User Data Retention"),
        ];
        table
            .iter()
            .filter(|(bit, _)| self.machine & bit != 0)
            .map(|(_, name)| *name)
            .collect()
    }

    /// Human-readable list of set target-setting bits, for the probe CLI.
    pub fn target_feature_names(self) -> Vec<&'static str> {
        use target_feature as t;
        let table: [(u32, &'static str); 17] = [
            (t::SPEED, "Speed Target Setting"),
            (t::INCLINATION, "Inclination Target Setting (0x03)"),
            (t::RESISTANCE, "Resistance Target Setting (0x04)"),
            (t::POWER, "Power Target Setting (0x05)"),
            (t::HEART_RATE, "Heart Rate Target Setting"),
            (t::EXPENDED_ENERGY, "Targeted Expended Energy Configuration"),
            (t::STEP_NUMBER, "Targeted Step Number Configuration"),
            (t::STRIDE_NUMBER, "Targeted Stride Number Configuration"),
            (t::DISTANCE, "Targeted Distance Configuration"),
            (t::TRAINING_TIME, "Targeted Training Time Configuration"),
            (t::TIME_IN_TWO_HR_ZONES, "Targeted Time in Two HR Zones"),
            (t::TIME_IN_THREE_HR_ZONES, "Targeted Time in Three HR Zones"),
            (t::TIME_IN_FIVE_HR_ZONES, "Targeted Time in Five HR Zones"),
            (t::INDOOR_BIKE_SIMULATION, "Indoor Bike Simulation Parameters (0x11)"),
            (t::WHEEL_CIRCUMFERENCE, "Wheel Circumference Configuration"),
            (t::SPIN_DOWN, "Spin Down Control"),
            (t::CADENCE, "Targeted Cadence Configuration"),
        ];
        table
            .iter()
            .filter(|(bit, _)| self.target & bit != 0)
            .map(|(_, name)| *name)
            .collect()
    }
}

/// Supported Resistance Level Range (`0x2AD6`): sint16 min, sint16 max,
/// uint16 increment.
///
/// The spec assigns these a resolution of 0.1, but resistance level is a
/// trainer-specific unit and the D100 reference works in raw integers capped at
/// 100. We therefore keep the **raw** values as authoritative for clamping —
/// they are in the same units as [`ControlTarget::Resistance`] and
/// [`crate::control_point::set_target_resistance`] — and expose the 0.1-scaled
/// interpretation separately for display. **Needs hardware verification**
/// (TASK-1) to know which the D100 actually means.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ResistanceLevelRange {
    pub min: i16,
    pub max: i16,
    pub increment: u16,
}

impl ResistanceLevelRange {
    pub fn decode(data: &[u8]) -> Result<Self, FieldTooShort> {
        let (min, max, increment) = decode_range("Supported Resistance Level Range", data)?;
        Ok(Self {
            min,
            max,
            increment,
        })
    }

    /// The spec-scaled interpretation (0.1 units), for display only.
    pub fn scaled(&self) -> (f32, f32, f32) {
        (
            self.min as f32 * 0.1,
            self.max as f32 * 0.1,
            self.increment as f32 * 0.1,
        )
    }
}

/// Supported Power Range (`0x2AD8`): sint16 min W, sint16 max W, uint16
/// increment W. Resolution 1 W.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PowerRange {
    pub min_w: i16,
    pub max_w: i16,
    pub increment_w: u16,
}

impl PowerRange {
    pub fn decode(data: &[u8]) -> Result<Self, FieldTooShort> {
        let (min_w, max_w, increment_w) = decode_range("Supported Power Range", data)?;
        Ok(Self {
            min_w,
            max_w,
            increment_w,
        })
    }
}

/// Supported Inclination Range (`0x2AD5`): sint16 min, sint16 max, uint16
/// increment, all with 0.1% resolution.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct InclinationRange {
    raw_min: i16,
    raw_max: i16,
    raw_increment: u16,
}

impl InclinationRange {
    pub fn decode(data: &[u8]) -> Result<Self, FieldTooShort> {
        let (raw_min, raw_max, raw_increment) =
            decode_range("Supported Inclination Range", data)?;
        Ok(Self {
            raw_min,
            raw_max,
            raw_increment,
        })
    }

    pub fn min_percent(&self) -> f32 {
        self.raw_min as f32 * 0.1
    }

    pub fn max_percent(&self) -> f32 {
        self.raw_max as f32 * 0.1
    }

    pub fn increment_percent(&self) -> f32 {
        self.raw_increment as f32 * 0.1
    }
}

fn decode_range(what: &'static str, data: &[u8]) -> Result<(i16, i16, u16), FieldTooShort> {
    if data.len() < 6 {
        return Err(FieldTooShort {
            what,
            len: data.len(),
            need: 6,
        });
    }
    Ok((
        i16::from_le_bytes([data[0], data[1]]),
        i16::from_le_bytes([data[2], data[3]]),
        u16::from_le_bytes([data[4], data[5]]),
    ))
}

/// Everything the trainer told us about itself. Any field may be `None` if the
/// corresponding characteristic is absent or unreadable — a trainer is not
/// required to expose the optional range characteristics.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct TrainerCapabilities {
    pub feature: Option<FitnessMachineFeature>,
    pub resistance_range: Option<ResistanceLevelRange>,
    pub power_range: Option<PowerRange>,
    pub inclination_range: Option<InclinationRange>,
}

impl TrainerCapabilities {
    /// Whether the op code used for a given target is advertised.
    ///
    /// If the Fitness Machine Feature characteristic could not be read we
    /// return `true` — refusing to control a trainer that simply did not expose
    /// `0x2ACC` would be worse than trying and reading the error response, and
    /// the response indication (FR-2.7) is the real backstop.
    pub fn supports(&self, target: &ControlTarget, use_simulation: bool) -> bool {
        let Some(f) = self.feature else {
            return true;
        };
        match target {
            ControlTarget::Gradient { .. } => {
                if use_simulation {
                    f.supports_simulation()
                } else {
                    f.supports_inclination_target()
                }
            }
            ControlTarget::Resistance { .. } => f.supports_resistance_target(),
            ControlTarget::Power { .. } => f.supports_power_target(),
        }
    }

    /// The op code that would carry this target.
    pub fn op_code_for(target: &ControlTarget, use_simulation: bool) -> OpCode {
        match target {
            ControlTarget::Gradient { .. } if use_simulation => {
                OpCode::SetIndoorBikeSimulationParameters
            }
            ControlTarget::Gradient { .. } => OpCode::SetTargetInclination,
            ControlTarget::Resistance { .. } => OpCode::SetTargetResistanceLevel,
            ControlTarget::Power { .. } => OpCode::SetTargetPower,
        }
    }
}

/// Clamp `target` first to the configured [`SafetyLimits`] (SAF-3) and then to
/// the range the trainer itself reported (FR-2.6, SAF-3), and reject it
/// outright if the op code is not advertised.
///
/// This is the **only** function the client uses to prepare a target for
/// transmission. Everything else — profiles, waveforms, the D-pad, the UI —
/// funnels through here, which is what makes "clamp at the point of
/// transmission" true rather than aspirational.
pub fn gate_target(
    limits: &SafetyLimits,
    caps: &TrainerCapabilities,
    target: ControlTarget,
    use_simulation: bool,
) -> Result<ControlTarget, UnsupportedTarget> {
    if !caps.supports(&target, use_simulation) {
        return Err(UnsupportedTarget::OpCodeUnsupported(
            TrainerCapabilities::op_code_for(&target, use_simulation),
        ));
    }

    // 1. Configured safety limits.
    let target = limits.clamp(target);

    // 2. The trainer's own reported range, where it gave us one.
    let target = match target {
        ControlTarget::Gradient { percent } => {
            if let Some(r) = caps.inclination_range {
                let (lo, hi) = (r.min_percent(), r.max_percent());
                if lo > hi {
                    return Err(UnsupportedTarget::EmptyRange {
                        what: "inclination",
                        min: r.raw_min as i32,
                        max: r.raw_max as i32,
                    });
                }
                ControlTarget::Gradient {
                    percent: percent.clamp(lo, hi),
                }
            } else {
                ControlTarget::Gradient { percent }
            }
        }
        ControlTarget::Resistance { level } => {
            if let Some(r) = caps.resistance_range {
                if r.min > r.max {
                    return Err(UnsupportedTarget::EmptyRange {
                        what: "resistance level",
                        min: r.min as i32,
                        max: r.max as i32,
                    });
                }
                ControlTarget::Resistance {
                    level: level.clamp(r.min, r.max),
                }
            } else {
                ControlTarget::Resistance { level }
            }
        }
        ControlTarget::Power { watts } => {
            if let Some(r) = caps.power_range {
                if r.min_w > r.max_w {
                    return Err(UnsupportedTarget::EmptyRange {
                        what: "power",
                        min: r.min_w as i32,
                        max: r.max_w as i32,
                    });
                }
                // ControlTarget::Power is u16; the range is sint16. Negative
                // minima are meaningless for a trainer, so floor at zero.
                let lo = r.min_w.max(0) as u16;
                let hi = r.max_w.max(0) as u16;
                ControlTarget::Power {
                    watts: watts.clamp(lo, hi),
                }
            } else {
                ControlTarget::Power { watts }
            }
        }
    };

    Ok(target)
}

/// Encode a gated target into control point bytes.
///
/// `use_simulation` selects `0x11` over `0x03` for gradient; `sim_template`
/// supplies the rolling/aero coefficients that accompany the grade.
pub fn encode_target(
    target: ControlTarget,
    use_simulation: bool,
    sim_template: crate::control_point::SimulationParameters,
) -> (OpCode, Vec<u8>) {
    use crate::control_point as cp;
    match target {
        ControlTarget::Gradient { percent } if use_simulation => (
            OpCode::SetIndoorBikeSimulationParameters,
            cp::set_simulation_parameters(cp::SimulationParameters {
                grade_percent: percent,
                ..sim_template
            }),
        ),
        ControlTarget::Gradient { percent } => (
            OpCode::SetTargetInclination,
            cp::set_target_inclination(percent),
        ),
        ControlTarget::Resistance { level } => (
            OpCode::SetTargetResistanceLevel,
            cp::set_target_resistance(level),
        ),
        ControlTarget::Power { watts } => (
            OpCode::SetTargetPower,
            // watts is u16 but the wire format is sint16; the gate has already
            // clamped it to the trainer's range, and `min(i16::MAX)` keeps a
            // pathological value from wrapping negative.
            cp::set_target_power(watts.min(i16::MAX as u16) as i16),
        ),
    }
}

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

    fn feature(machine: u32, target: u32) -> FitnessMachineFeature {
        FitnessMachineFeature { machine, target }
    }

    #[test]
    fn feature_decodes_two_little_endian_u32s() {
        // machine = 0x00000086, target = 0x0000200C
        let bytes = [0x86, 0x00, 0x00, 0x00, 0x0c, 0x20, 0x00, 0x00];
        let f = FitnessMachineFeature::decode(&bytes).unwrap();
        assert_eq!(f.machine, 0x0000_0086);
        assert_eq!(f.target, 0x0000_200C);
        assert!(f.has_machine(machine_feature::CADENCE));
        assert!(f.has_machine(machine_feature::RESISTANCE_LEVEL));
        assert!(!f.has_machine(machine_feature::AVERAGE_SPEED));
        assert!(f.supports_resistance_target());
        assert!(f.supports_power_target());
        assert!(f.supports_simulation());
        assert!(!f.supports_inclination_target());
    }

    #[test]
    fn feature_decode_rejects_short_data() {
        assert_eq!(
            FitnessMachineFeature::decode(&[0u8; 7]).unwrap_err(),
            FieldTooShort {
                what: "Fitness Machine Feature",
                len: 7,
                need: 8
            }
        );
    }

    #[test]
    fn feature_extra_trailing_bytes_are_tolerated() {
        let f = FitnessMachineFeature::decode(&[0x02, 0, 0, 0, 0x04, 0, 0, 0, 0xff]).unwrap();
        assert_eq!(f.machine, 2);
        assert_eq!(f.target, 4);
    }

    #[test]
    fn feature_name_lists() {
        let f = feature(
            machine_feature::CADENCE | machine_feature::POWER_MEASUREMENT,
            target_feature::RESISTANCE | target_feature::INDOOR_BIKE_SIMULATION,
        );
        assert_eq!(
            f.machine_feature_names(),
            vec!["Cadence", "Power Measurement"]
        );
        assert_eq!(
            f.target_feature_names(),
            vec![
                "Resistance Target Setting (0x04)",
                "Indoor Bike Simulation Parameters (0x11)"
            ]
        );
    }

    #[test]
    fn resistance_range_decodes() {
        // min 0, max 100, increment 1
        let r = ResistanceLevelRange::decode(&[0x00, 0x00, 0x64, 0x00, 0x01, 0x00]).unwrap();
        assert_eq!(
            r,
            ResistanceLevelRange {
                min: 0,
                max: 100,
                increment: 1
            }
        );
        let (lo, hi, inc) = r.scaled();
        assert_eq!((lo, hi, inc), (0.0, 10.0, 0.1));
    }

    #[test]
    fn resistance_range_handles_negative_minimum() {
        let r = ResistanceLevelRange::decode(&[0xf6, 0xff, 0x64, 0x00, 0x02, 0x00]).unwrap();
        assert_eq!(r.min, -10);
        assert_eq!(r.max, 100);
        assert_eq!(r.increment, 2);
    }

    #[test]
    fn power_and_inclination_ranges_decode() {
        let p = PowerRange::decode(&[0x32, 0x00, 0x58, 0x02, 0x01, 0x00]).unwrap();
        assert_eq!(p.min_w, 50);
        assert_eq!(p.max_w, 600);
        assert_eq!(p.increment_w, 1);

        // -10.0% .. +20.0%, 0.5% increment
        let i = InclinationRange::decode(&[0x9c, 0xff, 0xc8, 0x00, 0x05, 0x00]).unwrap();
        assert_eq!(i.min_percent(), -10.0);
        assert_eq!(i.max_percent(), 20.0);
        assert_eq!(i.increment_percent(), 0.5);
    }

    #[test]
    fn range_decode_rejects_short_data() {
        assert!(ResistanceLevelRange::decode(&[0, 0, 0, 0, 0]).is_err());
        assert!(PowerRange::decode(&[]).is_err());
        assert!(InclinationRange::decode(&[1, 2, 3]).is_err());
    }

    // -- gate_target -------------------------------------------------------

    fn caps_all() -> TrainerCapabilities {
        TrainerCapabilities {
            feature: Some(feature(
                0,
                target_feature::INCLINATION
                    | target_feature::RESISTANCE
                    | target_feature::POWER
                    | target_feature::INDOOR_BIKE_SIMULATION,
            )),
            resistance_range: Some(ResistanceLevelRange {
                min: 0,
                max: 100,
                increment: 1,
            }),
            power_range: Some(PowerRange {
                min_w: 50,
                max_w: 600,
                increment_w: 1,
            }),
            inclination_range: Some(InclinationRange {
                raw_min: -100,
                raw_max: 200,
                raw_increment: 5,
            }),
        }
    }

    #[test]
    fn gate_passes_an_in_range_target_unchanged() {
        let l = SafetyLimits::default();
        let c = caps_all();
        assert_eq!(
            gate_target(&l, &c, ControlTarget::Power { watts: 200 }, false).unwrap(),
            ControlTarget::Power { watts: 200 }
        );
        assert_eq!(
            gate_target(&l, &c, ControlTarget::Gradient { percent: 3.5 }, false).unwrap(),
            ControlTarget::Gradient { percent: 3.5 }
        );
    }

    #[test]
    fn gate_clamps_to_safety_limits() {
        let l = SafetyLimits::default(); // -10..15 %, 0..100 res, 50..600 W
        let c = TrainerCapabilities::default(); // trainer told us nothing
        assert_eq!(
            gate_target(&l, &c, ControlTarget::Gradient { percent: 99.0 }, false).unwrap(),
            ControlTarget::Gradient { percent: 15.0 }
        );
        assert_eq!(
            gate_target(&l, &c, ControlTarget::Gradient { percent: -99.0 }, false).unwrap(),
            ControlTarget::Gradient { percent: -10.0 }
        );
        assert_eq!(
            gate_target(&l, &c, ControlTarget::Power { watts: 5000 }, false).unwrap(),
            ControlTarget::Power { watts: 600 }
        );
        assert_eq!(
            gate_target(&l, &c, ControlTarget::Resistance { level: 500 }, false).unwrap(),
            ControlTarget::Resistance { level: 100 }
        );
        assert_eq!(
            gate_target(&l, &c, ControlTarget::Resistance { level: -500 }, false).unwrap(),
            ControlTarget::Resistance { level: 0 }
        );
    }

    /// The trainer's reported range is *narrower* than the safety limits, so it
    /// must win. This is FR-2.6.
    #[test]
    fn gate_clamps_to_the_trainers_narrower_range() {
        let l = SafetyLimits {
            min_gradient_pct: -25.0,
            max_gradient_pct: 25.0,
            min_resistance: -200,
            max_resistance: 200,
            min_power_w: 0,
            max_power_w: 2000,
        };
        let c = caps_all(); // incl -10..20 %, res 0..100, power 50..600 W

        assert_eq!(
            gate_target(&l, &c, ControlTarget::Gradient { percent: 24.0 }, false).unwrap(),
            ControlTarget::Gradient { percent: 20.0 }
        );
        assert_eq!(
            gate_target(&l, &c, ControlTarget::Resistance { level: 150 }, false).unwrap(),
            ControlTarget::Resistance { level: 100 }
        );
        assert_eq!(
            gate_target(&l, &c, ControlTarget::Power { watts: 1500 }, false).unwrap(),
            ControlTarget::Power { watts: 600 }
        );
        assert_eq!(
            gate_target(&l, &c, ControlTarget::Power { watts: 10 }, false).unwrap(),
            ControlTarget::Power { watts: 50 }
        );
    }

    #[test]
    fn gate_rejects_unadvertised_op_codes() {
        let l = SafetyLimits::default();
        let c = TrainerCapabilities {
            feature: Some(feature(0, target_feature::RESISTANCE)),
            ..Default::default()
        };
        assert_eq!(
            gate_target(&l, &c, ControlTarget::Power { watts: 200 }, false).unwrap_err(),
            UnsupportedTarget::OpCodeUnsupported(OpCode::SetTargetPower)
        );
        assert_eq!(
            gate_target(&l, &c, ControlTarget::Gradient { percent: 1.0 }, false).unwrap_err(),
            UnsupportedTarget::OpCodeUnsupported(OpCode::SetTargetInclination)
        );
        assert!(gate_target(&l, &c, ControlTarget::Resistance { level: 5 }, false).is_ok());
    }

    /// A-1 in gate form: if the trainer does not advertise bit 13, asking for
    /// gradient in simulation mode is refused before anything is transmitted.
    #[test]
    fn gate_rejects_simulation_mode_when_unadvertised() {
        let l = SafetyLimits::default();
        let c = TrainerCapabilities {
            feature: Some(feature(0, target_feature::INCLINATION)),
            ..Default::default()
        };
        assert_eq!(
            gate_target(&l, &c, ControlTarget::Gradient { percent: 2.0 }, true).unwrap_err(),
            UnsupportedTarget::OpCodeUnsupported(OpCode::SetIndoorBikeSimulationParameters)
        );
        // ...but plain inclination is fine.
        assert!(gate_target(&l, &c, ControlTarget::Gradient { percent: 2.0 }, false).is_ok());
    }

    #[test]
    fn gate_allows_everything_when_the_feature_characteristic_is_missing() {
        let l = SafetyLimits::default();
        let c = TrainerCapabilities::default();
        assert!(gate_target(&l, &c, ControlTarget::Power { watts: 100 }, false).is_ok());
        assert!(gate_target(&l, &c, ControlTarget::Gradient { percent: 1.0 }, true).is_ok());
    }

    #[test]
    fn gate_rejects_an_inverted_range() {
        let l = SafetyLimits::default();
        let c = TrainerCapabilities {
            resistance_range: Some(ResistanceLevelRange {
                min: 50,
                max: 10,
                increment: 1,
            }),
            ..Default::default()
        };
        assert_eq!(
            gate_target(&l, &c, ControlTarget::Resistance { level: 20 }, false).unwrap_err(),
            UnsupportedTarget::EmptyRange {
                what: "resistance level",
                min: 50,
                max: 10
            }
        );
    }

    #[test]
    fn gate_handles_a_negative_power_minimum() {
        let l = SafetyLimits {
            min_power_w: 0,
            ..SafetyLimits::default()
        };
        let c = TrainerCapabilities {
            power_range: Some(PowerRange {
                min_w: -100,
                max_w: 400,
                increment_w: 1,
            }),
            ..Default::default()
        };
        assert_eq!(
            gate_target(&l, &c, ControlTarget::Power { watts: 0 }, false).unwrap(),
            ControlTarget::Power { watts: 0 }
        );
    }

    // -- encode_target -----------------------------------------------------

    #[test]
    fn encode_target_picks_the_right_op_code_and_bytes() {
        let sim = crate::control_point::SimulationParameters::default();
        assert_eq!(
            encode_target(ControlTarget::Gradient { percent: 5.0 }, false, sim),
            (OpCode::SetTargetInclination, vec![0x03, 0x32, 0x00])
        );
        assert_eq!(
            encode_target(ControlTarget::Resistance { level: 40 }, false, sim),
            (OpCode::SetTargetResistanceLevel, vec![0x04, 0x28, 0x00])
        );
        assert_eq!(
            encode_target(ControlTarget::Power { watts: 250 }, false, sim),
            (OpCode::SetTargetPower, vec![0x05, 0xfa, 0x00])
        );
    }

    #[test]
    fn encode_target_in_simulation_mode_carries_the_grade() {
        let sim = crate::control_point::SimulationParameters {
            wind_speed_mps: 0.0,
            grade_percent: 999.0, // must be overridden by the target
            crr: 0.004,
            wind_resistance_coefficient: 0.51,
        };
        let (op, bytes) = encode_target(ControlTarget::Gradient { percent: 4.5 }, true, sim);
        assert_eq!(op, OpCode::SetIndoorBikeSimulationParameters);
        assert_eq!(bytes, vec![0x11, 0x00, 0x00, 0xc2, 0x01, 0x28, 0x33]);
    }

    #[test]
    fn encode_target_power_never_wraps_negative() {
        let sim = crate::control_point::SimulationParameters::default();
        let (_, bytes) = encode_target(ControlTarget::Power { watts: 60000 }, false, sim);
        let raw = i16::from_le_bytes([bytes[1], bytes[2]]);
        assert!(raw > 0, "power must not wrap to a negative sint16");
        assert_eq!(raw, i16::MAX);
    }
}
