//! Turning a raw log into a FIT activity: aggregation, then assembly.
//!
//! Message order follows what Garmin devices produce, because that is what
//! every uploader has been tested against:
//!
//! ```text
//! file_id, device_info, event(timer/start),
//!   [ record × n, lap ] × laps,
//! event(timer/stop_all), session, activity
//! ```
//!
//! Records carry no `position_lat`/`position_long`: an indoor ride has no GPS,
//! and inventing coordinates is worse than omitting them. Combined with
//! `sub_sport = virtual_activity` this is how Strava is told to treat the file
//! as a Virtual Ride rather than an outdoor ride whose GPS failed.

use crate::encode::{FitEncoder, Message, Value};
use crate::profile::{activity, device_info, enums, event, file_id, lap, mesg, record, session};
use crate::rawlog::{RawLog, Sample};
use crate::{timestamp, FitError};

/// Local message type allocation. FIT allows sixteen; we use seven, so no
/// definition ever has to be evicted and re-emitted.
mod local {
    pub const FILE_ID: u8 = 0;
    pub const DEVICE_INFO: u8 = 1;
    pub const EVENT: u8 = 2;
    pub const RECORD: u8 = 3;
    pub const LAP: u8 = 4;
    pub const SESSION: u8 = 5;
    pub const ACTIVITY: u8 = 6;
}

/// What was written, for logging and for the UI to show after a ride.
#[derive(Debug, Clone, PartialEq)]
pub struct FitSummary {
    /// Size of the encoded file in bytes.
    pub bytes: usize,
    /// Number of `record` messages.
    pub records: usize,
    /// Number of laps.
    pub laps: usize,
    /// Wall-clock duration of the session, seconds.
    pub total_elapsed_s: f64,
    /// Moving/recording time excluding explicit pauses, seconds.
    pub total_timer_s: f64,
    /// Virtual distance covered, metres.
    pub total_distance_m: f64,
    /// Cumulative climbing, metres.
    pub total_ascent_m: u16,
    /// Mean power over samples that reported one. `None` if none did.
    pub avg_power_w: Option<u16>,
    /// Peak power. `None` if no sample reported power.
    pub max_power_w: Option<u16>,
    /// Mean cadence over samples that reported one. `None` if none did.
    pub avg_cadence_rpm: Option<u8>,
    /// Estimated rider energy expenditure in kilocalories, derived from
    /// measured mechanical work — see [`Aggregates::calories`].
    pub total_calories: Option<u16>,
    /// Number of BLE dropouts spanned (FR-8.5).
    pub gaps: usize,
    /// False when the source log had no clean end marker, i.e. this activity
    /// was recovered from a crash.
    pub recovered_from_crash: bool,
    /// Journal lines that could not be parsed.
    pub skipped_log_lines: usize,
}

/// Per-lap and per-session aggregates (FR-8: session/lap totals).
#[derive(Debug, Clone, Default, PartialEq)]
struct Aggregates {
    start_fit: u32,
    end_fit: u32,
    total_elapsed_ms: u64,
    total_timer_ms: u64,
    start_distance_m: f64,
    end_distance_m: f64,
    power_sum: f64,
    power_n: u32,
    max_power: Option<u16>,
    cadence_sum: f64,
    cadence_n: u32,
    max_cadence: Option<u8>,
    speed_sum: f64,
    speed_n: u32,
    max_speed_mps: f64,
    hr_sum: f64,
    hr_n: u32,
    max_hr: Option<u8>,
    ascent_m: f64,
    descent_m: f64,
    grade_sum: f64,
    grade_n: u32,
    /// Mechanical work, joules, integrated from power. The sole basis for the
    /// calorie figure — see [`Aggregates::calories`].
    work_j: f64,
    records: usize,
}

impl Aggregates {
    fn total_distance_m(&self) -> f64 {
        (self.end_distance_m - self.start_distance_m).max(0.0)
    }

    fn avg_power(&self) -> Option<u16> {
        (self.power_n > 0).then(|| clamp_u16(self.power_sum / f64::from(self.power_n)))
    }

    fn avg_cadence(&self) -> Option<u8> {
        (self.cadence_n > 0).then(|| clamp_u8(self.cadence_sum / f64::from(self.cadence_n)))
    }

    fn avg_hr(&self) -> Option<u8> {
        (self.hr_n > 0).then(|| clamp_u8(self.hr_sum / f64::from(self.hr_n)))
    }

    /// Average speed in m/s. Computed from distance over timer time rather than
    /// by averaging the samples, so that it is consistent with the distance and
    /// duration shown alongside it.
    fn avg_speed_mps(&self) -> f64 {
        if self.total_timer_ms == 0 {
            return 0.0;
        }
        self.total_distance_m() / (self.total_timer_ms as f64 / 1000.0)
    }

    /// Calories, from measured work via [`bikecontrol_core::energy`] — the same
    /// model the live readout uses, so the file agrees with what the rider
    /// watched during the ride.
    ///
    /// The trainer's own cumulative energy field is deliberately *not* used,
    /// even when present. FTMS specifies "Total Energy" in kilocalories but
    /// says nothing about whether it means mechanical or metabolic energy, and
    /// implementations disagree by a factor of four: some report kJ/4.184 (the
    /// rider as a perfect engine), others apply an efficiency factor, others
    /// report a figure with no documented basis at all. Measured power is the
    /// one input we can reason about, so it is the only one used.
    fn calories(&self, rider_kg: f32) -> Option<u16> {
        let kcal = clamp_u16(bikecontrol_core::energy::kcal(
            self.work_j,
            rider_kg,
            self.total_timer_ms as f64 / 1000.0,
        ));
        (kcal > 0).then_some(kcal)
    }

    fn avg_grade_pct(&self) -> Option<f64> {
        (self.grade_n > 0).then(|| self.grade_sum / f64::from(self.grade_n))
    }
}

/// A sample with its absolute FIT timestamp and altitude resolved.
struct Resolved {
    sample: Sample,
    fit_time: u32,
    altitude_m: f64,
}

/// Encode a raw log as a FIT activity file.
///
/// This is the whole encoder: [`crate::Recorder::finish`] and
/// [`crate::build_fit_from_log`] both come through here, so a file rebuilt
/// after a crash is byte-identical to one written by a clean shutdown of the
/// same ride.
pub fn encode_activity(log: &RawLog) -> Result<(Vec<u8>, FitSummary), FitError> {
    let start_fit = timestamp::from_unix_millis(log.start.start_unix_ms)?;
    let resolved = resolve_samples(log, start_fit)?;
    if resolved.is_empty() {
        return Err(FitError::NoSamples);
    }

    let end_ms = resolved.last().map_or(0, |r| r.sample.elapsed_ms);
    let lap_bounds = lap_boundaries(log, end_ms);
    let paused_ms = log.paused_ms(end_ms);

    // Split samples into laps by elapsed time. A lap owns samples in
    // [start, end); the last lap owns everything remaining.
    let mut lap_aggs: Vec<Aggregates> = Vec::with_capacity(lap_bounds.len());
    let mut lap_slices: Vec<(usize, usize)> = Vec::with_capacity(lap_bounds.len());
    let mut cursor = 0usize;
    for (i, &(lap_start_ms, lap_end_ms)) in lap_bounds.iter().enumerate() {
        let is_last = i + 1 == lap_bounds.len();
        let begin = cursor;
        while cursor < resolved.len() {
            let t = resolved[cursor].sample.elapsed_ms;
            if !is_last && t >= lap_end_ms {
                break;
            }
            cursor += 1;
        }
        lap_slices.push((begin, cursor));
        // Pause time attributable to this lap.
        let lap_paused = paused_within(log, lap_start_ms, lap_end_ms, end_ms);
        // The first sample of the *next* lap closes this one's distance and
        // altitude, so that the laps tile the session exactly rather than each
        // dropping the stretch between its last sample and the next boundary.
        let tail = resolved.get(cursor);
        lap_aggs.push(aggregate(
            &resolved[begin..cursor],
            tail,
            start_fit,
            lap_start_ms,
            lap_end_ms,
            lap_paused,
        ));
    }

    let session_agg = aggregate(&resolved, None, start_fit, 0, end_ms, paused_ms);
    let bytes = assemble(log, &resolved, &lap_slices, &lap_aggs, &session_agg, start_fit)?;

    let summary = FitSummary {
        bytes: bytes.len(),
        records: resolved.len(),
        laps: lap_aggs.len(),
        total_elapsed_s: session_agg.total_elapsed_ms as f64 / 1000.0,
        total_timer_s: session_agg.total_timer_ms as f64 / 1000.0,
        total_distance_m: session_agg.total_distance_m(),
        total_ascent_m: clamp_u16(session_agg.ascent_m),
        avg_power_w: session_agg.avg_power(),
        max_power_w: session_agg.max_power,
        avg_cadence_rpm: session_agg.avg_cadence(),
        total_calories: session_agg.calories(log.start.rider_kg),
        gaps: log.gaps(end_ms).len(),
        recovered_from_crash: !log.clean_shutdown,
        skipped_log_lines: log.skipped_lines,
    };
    Ok((bytes, summary))
}

/// Attach absolute timestamps and altitudes to the samples.
///
/// Altitude: if the sample carries one (from a GPX route) it is used verbatim.
/// Otherwise a profile is synthesised by integrating gradient over distance,
/// which is the only altitude an indoor ride has. Without it Strava draws a
/// flat line for a ride up a simulated climb.
fn resolve_samples(log: &RawLog, start_fit: u32) -> Result<Vec<Resolved>, FitError> {
    let mut out: Vec<Resolved> = Vec::new();
    let mut altitude = 0.0f64;
    let mut prev_distance: Option<f64> = None;
    let mut prev_time: Option<u32> = None;

    for sample in log.samples() {
        let fit_time = start_fit
            .checked_add(u32::try_from(sample.elapsed_ms / 1000).unwrap_or(u32::MAX))
            .ok_or(FitError::TimestampOutOfRange {
                unix_secs: i64::from(u32::MAX),
            })?;

        // FIT record timestamps have one-second resolution. Two samples in the
        // same second would produce duplicate timestamps, which some parsers
        // treat as corruption; keep the later one.
        if prev_time == Some(fit_time) {
            out.pop();
        }
        prev_time = Some(fit_time);

        let delta_d = match prev_distance {
            Some(prev) => (sample.distance_m - prev).max(0.0),
            None => 0.0,
        };
        prev_distance = Some(sample.distance_m);

        let altitude_m = match sample.altitude_m {
            Some(a) => {
                altitude = f64::from(a);
                altitude
            }
            None => {
                altitude += delta_d * f64::from(sample.gradient_pct) / 100.0;
                altitude
            }
        };

        out.push(Resolved {
            sample: sample.clone(),
            fit_time,
            altitude_m,
        });
    }
    Ok(out)
}

/// Lap boundaries as `(start_ms, end_ms)` pairs covering the whole ride.
///
/// A lap marker at time `t` ends the lap in progress at `t` and starts the next
/// one there. Markers at or beyond the end of the ride, and duplicates, are
/// ignored — a zero-length lap makes some importers unhappy and carries no
/// information.
fn lap_boundaries(log: &RawLog, end_ms: u64) -> Vec<(u64, u64)> {
    let mut marks: Vec<u64> = log.lap_marks().filter(|&t| t > 0 && t < end_ms).collect();
    marks.sort_unstable();
    marks.dedup();

    let mut bounds = Vec::with_capacity(marks.len() + 1);
    let mut prev = 0u64;
    for m in marks {
        bounds.push((prev, m));
        prev = m;
    }
    bounds.push((prev, end_ms));
    bounds
}

/// Pause time falling inside `[from_ms, to_ms)`.
fn paused_within(log: &RawLog, from_ms: u64, to_ms: u64, fallback_end_ms: u64) -> u64 {
    use crate::rawlog::LogEntry;
    let mut total = 0u64;
    let mut paused_at: Option<u64> = None;
    for entry in &log.entries {
        match entry {
            LogEntry::Pause { at_ms } => {
                if paused_at.is_none() {
                    paused_at = Some(*at_ms);
                }
            }
            LogEntry::Resume { at_ms } => {
                if let Some(start) = paused_at.take() {
                    total += overlap(start, *at_ms, from_ms, to_ms);
                }
            }
            _ => {}
        }
    }
    if let Some(start) = paused_at {
        total += overlap(start, fallback_end_ms, from_ms, to_ms);
    }
    total
}

fn overlap(a0: u64, a1: u64, b0: u64, b1: u64) -> u64 {
    a1.min(b1).saturating_sub(a0.max(b0))
}

/// Fold a slice of samples into lap or session aggregates.
///
/// `tail` is the first sample *after* this slice, when there is one. It
/// contributes only to the closing distance and altitude, never to averages or
/// maxima — it belongs to the next lap. Without it, lap distances and ascents
/// would not sum to the session's, because each lap would silently drop the
/// stretch between its final sample and the lap boundary.
fn aggregate(
    samples: &[Resolved],
    tail: Option<&Resolved>,
    start_fit: u32,
    from_ms: u64,
    to_ms: u64,
    paused_ms: u64,
) -> Aggregates {
    let mut agg = Aggregates {
        start_fit: start_fit + (from_ms / 1000) as u32,
        end_fit: start_fit + (to_ms / 1000) as u32,
        total_elapsed_ms: to_ms.saturating_sub(from_ms),
        records: samples.len(),
        ..Default::default()
    };
    agg.total_timer_ms = agg.total_elapsed_ms.saturating_sub(paused_ms);

    let mut prev_alt: Option<f64> = None;
    let mut prev_ms: Option<u64> = None;

    for (i, r) in samples.iter().enumerate() {
        let s = &r.sample;
        if i == 0 {
            agg.start_distance_m = s.distance_m;
        }
        agg.end_distance_m = s.distance_m;

        // Sample interval, for work integration. Clamped so that a long BLE
        // dropout does not silently attribute minutes of work to one sample.
        let dt_s = match prev_ms {
            Some(prev) => ((s.elapsed_ms.saturating_sub(prev)) as f64 / 1000.0).clamp(0.0, 10.0),
            None => 0.0,
        };
        prev_ms = Some(s.elapsed_ms);

        if let Some(p) = s.power_w {
            let p = f64::from(p).max(0.0);
            agg.power_sum += p;
            agg.power_n += 1;
            let pw = clamp_u16(p);
            agg.max_power = Some(agg.max_power.map_or(pw, |m| m.max(pw)));
            agg.work_j += p * dt_s;
        }
        if let Some(c) = s.cadence_rpm {
            if c.is_finite() {
                agg.cadence_sum += f64::from(c);
                agg.cadence_n += 1;
                let cu = clamp_u8(f64::from(c));
                agg.max_cadence = Some(agg.max_cadence.map_or(cu, |m| m.max(cu)));
            }
        }
        if s.speed_kph.is_finite() {
            let mps = f64::from(s.speed_kph) / 3.6;
            agg.speed_sum += mps;
            agg.speed_n += 1;
            agg.max_speed_mps = agg.max_speed_mps.max(mps);
        }
        if let Some(h) = s.heart_rate_bpm {
            if h > 0 {
                agg.hr_sum += f64::from(h);
                agg.hr_n += 1;
                agg.max_hr = Some(agg.max_hr.map_or(h, |m| m.max(h)));
            }
        }
        if s.gradient_pct.is_finite() {
            agg.grade_sum += f64::from(s.gradient_pct);
            agg.grade_n += 1;
        }

        if let Some(prev) = prev_alt {
            let d = r.altitude_m - prev;
            if d > 0.0 {
                agg.ascent_m += d;
            } else {
                agg.descent_m -= d;
            }
        }
        prev_alt = Some(r.altitude_m);
    }

    // Close the lap at the boundary rather than at its last sample.
    if let Some(t) = tail {
        agg.end_distance_m = t.sample.distance_m;
        if let Some(prev) = prev_alt {
            let d = t.altitude_m - prev;
            if d > 0.0 {
                agg.ascent_m += d;
            } else {
                agg.descent_m -= d;
            }
        }
    }

    agg
}

/// Emit the message stream.
fn assemble(
    log: &RawLog,
    resolved: &[Resolved],
    lap_slices: &[(usize, usize)],
    lap_aggs: &[Aggregates],
    session_agg: &Aggregates,
    start_fit: u32,
) -> Result<Vec<u8>, FitError> {
    let mut enc = FitEncoder::new();
    let end_fit = session_agg.end_fit;

    // --- file_id -----------------------------------------------------------
    let mut m = Message::new();
    m.set(file_id::TYPE, Value::Enum(enums::FILE_ACTIVITY));
    m.set(
        file_id::MANUFACTURER,
        Value::Uint16(enums::MANUFACTURER_DEVELOPMENT),
    );
    m.set(file_id::PRODUCT, Value::Uint16(1));
    m.set(
        file_id::SERIAL_NUMBER,
        Value::Uint32z(log.start.serial_number),
    );
    m.set(file_id::TIME_CREATED, Value::Uint32(start_fit));
    m.set(
        file_id::PRODUCT_NAME,
        Value::String(log.start.product_name.clone()),
    );
    enc.write_message(local::FILE_ID, mesg::FILE_ID, &m);

    // --- device_info -------------------------------------------------------
    let mut m = Message::new();
    m.set(device_info::TIMESTAMP, Value::Uint32(start_fit));
    m.set(
        device_info::DEVICE_INDEX,
        Value::Uint8(enums::DEVICE_INDEX_CREATOR),
    );
    m.set(
        device_info::MANUFACTURER,
        Value::Uint16(enums::MANUFACTURER_DEVELOPMENT),
    );
    m.set(device_info::PRODUCT, Value::Uint16(1));
    m.set(
        device_info::SOFTWARE_VERSION,
        Value::Uint16(log.start.software_version),
    );
    m.set(
        device_info::SOURCE_TYPE,
        Value::Enum(enums::SOURCE_TYPE_LOCAL),
    );
    m.set(
        device_info::PRODUCT_NAME,
        Value::String(log.start.product_name.clone()),
    );
    enc.write_message(local::DEVICE_INFO, mesg::DEVICE_INFO, &m);

    // --- timer start -------------------------------------------------------
    write_timer_event(&mut enc, start_fit, enums::EVENT_TYPE_START);

    // --- records, laps -----------------------------------------------------
    let field_mask = FieldMask::of(resolved);
    for (lap_index, (&(begin, end), agg)) in lap_slices.iter().zip(lap_aggs).enumerate() {
        for r in &resolved[begin..end] {
            enc.write_message(local::RECORD, mesg::RECORD, &record_message(r, &field_mask));
        }
        let is_last = lap_index + 1 == lap_aggs.len();
        enc.write_message(
            local::LAP,
            mesg::LAP,
            &lap_message(lap_index as u16, agg, log.start.sub_sport, log.start.rider_kg, is_last),
        );
    }

    // --- timer stop, session, activity -------------------------------------
    write_timer_event(&mut enc, end_fit, enums::EVENT_TYPE_STOP_ALL);
    enc.write_message(
        local::SESSION,
        mesg::SESSION,
        &session_message(
            session_agg,
            log.start.sub_sport,
            log.start.rider_kg,
            lap_aggs.len() as u16,
        ),
    );

    let mut m = Message::new();
    m.set(activity::TIMESTAMP, Value::Uint32(end_fit));
    m.set(
        activity::TOTAL_TIMER_TIME,
        Value::Uint32(scale_ms_to_millis_u32(session_agg.total_timer_ms)),
    );
    m.set(activity::NUM_SESSIONS, Value::Uint16(1));
    m.set(activity::TYPE, Value::Enum(enums::ACTIVITY_MANUAL));
    m.set(activity::EVENT, Value::Enum(enums::EVENT_ACTIVITY));
    m.set(activity::EVENT_TYPE, Value::Enum(enums::EVENT_TYPE_STOP));
    m.set(
        activity::LOCAL_TIMESTAMP,
        Value::Uint32(timestamp::to_local(end_fit, log.start.utc_offset_secs)),
    );
    enc.write_message(local::ACTIVITY, mesg::ACTIVITY, &m);

    Ok(enc.finish())
}

fn write_timer_event(enc: &mut FitEncoder, at: u32, event_type: u8) {
    let mut m = Message::new();
    m.set(event::TIMESTAMP, Value::Uint32(at));
    m.set(event::EVENT, Value::Enum(enums::EVENT_TIMER));
    m.set(event::EVENT_TYPE, Value::Enum(event_type));
    m.set(event::EVENT_GROUP, Value::Uint8(0));
    enc.write_message(local::EVENT, mesg::EVENT, &m);
}

/// Which optional record fields any sample in the ride actually carries.
///
/// A definition message is shared by every record, so a field must be either
/// present throughout or absent throughout. Deciding once, up front, means a
/// ride without a heart-rate strap carries no heart-rate field at all rather
/// than an hour of "invalid" bytes that some importers render as a flat zero
/// trace.
struct FieldMask {
    power: bool,
    cadence: bool,
    heart_rate: bool,
    resistance: bool,
}

impl FieldMask {
    fn of(resolved: &[Resolved]) -> Self {
        Self {
            power: resolved.iter().any(|r| r.sample.power_w.is_some()),
            cadence: resolved.iter().any(|r| r.sample.cadence_rpm.is_some()),
            heart_rate: resolved
                .iter()
                .any(|r| r.sample.heart_rate_bpm.is_some_and(|h| h > 0)),
            resistance: resolved.iter().any(|r| r.sample.resistance.is_some()),
        }
    }
}

/// Build one `record` message (FR-8.1 / FR-8.3).
///
/// Fields the mask includes are always written; a sample missing one gets the
/// base type's invalid value, which is how FIT represents a momentary sensor
/// dropout within an otherwise-present stream.
fn record_message(r: &Resolved, mask: &FieldMask) -> Message {
    let s = &r.sample;
    let mut m = Message::new();
    m.set(record::TIMESTAMP, Value::Uint32(r.fit_time));

    // altitude: (metres + 500) * 5, uint16.
    m.set(
        record::ALTITUDE,
        Value::Uint16(clamp_u16((r.altitude_m + 500.0) * 5.0)),
    );
    // distance: centimetres, uint32.
    m.set(
        record::DISTANCE,
        Value::Uint32(clamp_u32(s.distance_m * 100.0)),
    );
    // speed: mm/s, uint16.
    m.set(
        record::SPEED,
        Value::Uint16(clamp_u16(f64::from(s.speed_kph) / 3.6 * 1000.0)),
    );
    // grade: percent * 100, sint16.
    m.set(
        record::GRADE,
        Value::Sint16(clamp_i16(f64::from(s.gradient_pct) * 100.0)),
    );

    if mask.power {
        m.set(
            record::POWER,
            Value::Uint16(match s.power_w {
                Some(p) => clamp_u16(f64::from(p).max(0.0)),
                None => INVALID_U16,
            }),
        );
    }
    if mask.cadence {
        m.set(
            record::CADENCE,
            Value::Uint8(match s.cadence_rpm {
                Some(c) if c.is_finite() => clamp_u8(f64::from(c)),
                _ => INVALID_U8,
            }),
        );
    }
    if mask.heart_rate {
        m.set(
            record::HEART_RATE,
            Value::Uint8(match s.heart_rate_bpm {
                Some(h) if h > 0 => h,
                _ => INVALID_U8,
            }),
        );
    }
    if mask.resistance {
        m.set(
            record::RESISTANCE,
            Value::Uint8(match s.resistance {
                Some(v) => clamp_u8(f64::from(v)),
                None => INVALID_U8,
            }),
        );
    }
    m
}

fn lap_message(
    index: u16,
    agg: &Aggregates,
    sub_sport: u8,
    rider_kg: f32,
    is_last: bool,
) -> Message {
    let mut m = Message::new();
    m.set(lap::MESSAGE_INDEX, Value::Uint16(index));
    m.set(lap::TIMESTAMP, Value::Uint32(agg.end_fit));
    m.set(lap::EVENT, Value::Enum(enums::EVENT_LAP));
    m.set(lap::EVENT_TYPE, Value::Enum(enums::EVENT_TYPE_STOP));
    m.set(lap::START_TIME, Value::Uint32(agg.start_fit));
    m.set(
        lap::TOTAL_ELAPSED_TIME,
        Value::Uint32(scale_ms_to_millis_u32(agg.total_elapsed_ms)),
    );
    m.set(
        lap::TOTAL_TIMER_TIME,
        Value::Uint32(scale_ms_to_millis_u32(agg.total_timer_ms)),
    );
    m.set(
        lap::TOTAL_DISTANCE,
        Value::Uint32(clamp_u32(agg.total_distance_m() * 100.0)),
    );
    m.set_opt(lap::TOTAL_CALORIES, agg.calories(rider_kg).map(Value::Uint16));
    m.set(
        lap::AVG_SPEED,
        Value::Uint16(clamp_u16(agg.avg_speed_mps() * 1000.0)),
    );
    m.set(
        lap::MAX_SPEED,
        Value::Uint16(clamp_u16(agg.max_speed_mps * 1000.0)),
    );
    m.set_opt(lap::AVG_HEART_RATE, agg.avg_hr().map(Value::Uint8));
    m.set_opt(lap::MAX_HEART_RATE, agg.max_hr.map(Value::Uint8));
    m.set_opt(lap::AVG_CADENCE, agg.avg_cadence().map(Value::Uint8));
    m.set_opt(lap::MAX_CADENCE, agg.max_cadence.map(Value::Uint8));
    m.set_opt(lap::AVG_POWER, agg.avg_power().map(Value::Uint16));
    m.set_opt(lap::MAX_POWER, agg.max_power.map(Value::Uint16));
    m.set(lap::TOTAL_ASCENT, Value::Uint16(clamp_u16(agg.ascent_m)));
    m.set(lap::TOTAL_DESCENT, Value::Uint16(clamp_u16(agg.descent_m)));
    m.set(lap::INTENSITY, Value::Enum(enums::INTENSITY_ACTIVE));
    m.set(
        lap::LAP_TRIGGER,
        Value::Enum(if is_last {
            enums::LAP_TRIGGER_SESSION_END
        } else {
            enums::LAP_TRIGGER_MANUAL
        }),
    );
    m.set(lap::SPORT, Value::Enum(enums::SPORT_CYCLING));
    m.set(lap::SUB_SPORT, Value::Enum(sub_sport));
    m.set(lap::TOTAL_WORK, Value::Uint32(clamp_u32(agg.work_j)));
    m.set_opt(
        lap::AVG_GRADE,
        agg.avg_grade_pct()
            .map(|g| Value::Sint16(clamp_i16(g * 100.0))),
    );
    m
}

fn session_message(agg: &Aggregates, sub_sport: u8, rider_kg: f32, num_laps: u16) -> Message {
    let mut m = Message::new();
    m.set(session::MESSAGE_INDEX, Value::Uint16(0));
    m.set(session::TIMESTAMP, Value::Uint32(agg.end_fit));
    m.set(session::EVENT, Value::Enum(enums::EVENT_SESSION));
    m.set(session::EVENT_TYPE, Value::Enum(enums::EVENT_TYPE_STOP));
    m.set(session::START_TIME, Value::Uint32(agg.start_fit));
    m.set(session::SPORT, Value::Enum(enums::SPORT_CYCLING));
    m.set(session::SUB_SPORT, Value::Enum(sub_sport));
    m.set(
        session::TOTAL_ELAPSED_TIME,
        Value::Uint32(scale_ms_to_millis_u32(agg.total_elapsed_ms)),
    );
    m.set(
        session::TOTAL_TIMER_TIME,
        Value::Uint32(scale_ms_to_millis_u32(agg.total_timer_ms)),
    );
    m.set(
        session::TOTAL_DISTANCE,
        Value::Uint32(clamp_u32(agg.total_distance_m() * 100.0)),
    );
    m.set_opt(session::TOTAL_CALORIES, agg.calories(rider_kg).map(Value::Uint16));
    m.set(
        session::AVG_SPEED,
        Value::Uint16(clamp_u16(agg.avg_speed_mps() * 1000.0)),
    );
    m.set(
        session::MAX_SPEED,
        Value::Uint16(clamp_u16(agg.max_speed_mps * 1000.0)),
    );
    m.set_opt(session::AVG_HEART_RATE, agg.avg_hr().map(Value::Uint8));
    m.set_opt(session::MAX_HEART_RATE, agg.max_hr.map(Value::Uint8));
    m.set_opt(session::AVG_CADENCE, agg.avg_cadence().map(Value::Uint8));
    m.set_opt(session::MAX_CADENCE, agg.max_cadence.map(Value::Uint8));
    m.set_opt(session::AVG_POWER, agg.avg_power().map(Value::Uint16));
    m.set_opt(session::MAX_POWER, agg.max_power.map(Value::Uint16));
    m.set(session::TOTAL_ASCENT, Value::Uint16(clamp_u16(agg.ascent_m)));
    m.set(
        session::TOTAL_DESCENT,
        Value::Uint16(clamp_u16(agg.descent_m)),
    );
    m.set(session::FIRST_LAP_INDEX, Value::Uint16(0));
    m.set(session::NUM_LAPS, Value::Uint16(num_laps.max(1)));
    m.set(
        session::TRIGGER,
        Value::Enum(enums::SESSION_TRIGGER_ACTIVITY_END),
    );
    m.set(session::TOTAL_WORK, Value::Uint32(clamp_u32(agg.work_j)));
    m
}

// --- numeric helpers -------------------------------------------------------

/// The `uint16` invalid value.
const INVALID_U16: u16 = 0xFFFF;
/// The `uint8` invalid value.
const INVALID_U8: u8 = 0xFF;

/// Milliseconds as a FIT `uint32` scaled by 1000 (i.e. milliseconds), saturating.
fn scale_ms_to_millis_u32(ms: u64) -> u32 {
    u32::try_from(ms).unwrap_or(u32::MAX - 1)
}

/// Round and clamp into `u16`, keeping clear of the invalid sentinel so a real
/// measurement is never mistaken for missing data.
fn clamp_u16(v: f64) -> u16 {
    if !v.is_finite() || v <= 0.0 {
        return 0;
    }
    v.round().min(f64::from(INVALID_U16 - 1)) as u16
}

fn clamp_u32(v: f64) -> u32 {
    if !v.is_finite() || v <= 0.0 {
        return 0;
    }
    v.round().min(f64::from(u32::MAX - 1)) as u32
}

fn clamp_u8(v: f64) -> u8 {
    if !v.is_finite() || v <= 0.0 {
        return 0;
    }
    v.round().min(f64::from(INVALID_U8 - 1)) as u8
}

fn clamp_i16(v: f64) -> i16 {
    if !v.is_finite() {
        return 0;
    }
    v.round().clamp(f64::from(i16::MIN + 1), f64::from(i16::MAX - 1)) as i16
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::rawlog::{LogEntry, SessionStart};

    fn log_with(entries: Vec<LogEntry>) -> RawLog {
        RawLog {
            start: SessionStart {
                start_unix_ms: 1_785_000_000_000,
                ..Default::default()
            },
            entries,
            skipped_lines: 0,
            clean_shutdown: true,
            path: None,
        }
    }

    fn ride(seconds: u64) -> RawLog {
        let mut entries = Vec::new();
        for i in 0..seconds {
            entries.push(LogEntry::Sample(Sample {
                elapsed_ms: i * 1000,
                power_w: Some(200),
                cadence_rpm: Some(90.0),
                speed_kph: 36.0, // 10 m/s
                distance_m: (i * 10) as f64,
                gradient_pct: 0.0,
                ..Default::default()
            }));
        }
        entries.push(LogEntry::End {
            at_ms: (seconds - 1) * 1000,
        });
        log_with(entries)
    }

    #[test]
    fn clamps_behave_at_the_edges() {
        assert_eq!(clamp_u16(-5.0), 0);
        assert_eq!(clamp_u16(f64::NAN), 0);
        assert_eq!(clamp_u16(1e30), 0xFFFE, "never reaches the invalid value");
        assert_eq!(clamp_u16(2.5), 3);
        assert_eq!(clamp_u8(1e9), 0xFE);
        assert_eq!(clamp_u32(1e30), u32::MAX - 1);
        assert_eq!(clamp_i16(-1e9), i16::MIN + 1);
        assert_eq!(clamp_i16(1e9), i16::MAX - 1);
        assert_eq!(clamp_i16(f64::NAN), 0);
        assert_eq!(clamp_i16(-250.0), -250);
    }

    #[test]
    fn a_log_with_no_samples_is_rejected_rather_than_written_empty() {
        let log = log_with(vec![LogEntry::End { at_ms: 0 }]);
        assert!(matches!(encode_activity(&log), Err(FitError::NoSamples)));
    }

    #[test]
    fn one_lap_by_default_covering_the_whole_ride() {
        let log = ride(10);
        assert_eq!(lap_boundaries(&log, 9000), vec![(0, 9000)]);
    }

    #[test]
    fn lap_markers_split_the_ride() {
        let mut log = ride(100);
        log.entries.push(LogEntry::Lap {
            at_ms: 30_000,
            from_controller: true,
        });
        log.entries.push(LogEntry::Lap {
            at_ms: 60_000,
            from_controller: false,
        });
        assert_eq!(lap_boundaries(&log, 99_000), vec![
            (0, 30_000),
            (30_000, 60_000),
            (60_000, 99_000)
        ]);
    }

    #[test]
    fn degenerate_lap_markers_are_ignored() {
        let mut log = ride(50);
        // At the very start, past the end, and a duplicate.
        log.entries.push(LogEntry::Lap {
            at_ms: 0,
            from_controller: false,
        });
        log.entries.push(LogEntry::Lap {
            at_ms: 999_999,
            from_controller: false,
        });
        log.entries.push(LogEntry::Lap {
            at_ms: 20_000,
            from_controller: false,
        });
        log.entries.push(LogEntry::Lap {
            at_ms: 20_000,
            from_controller: false,
        });
        assert_eq!(lap_boundaries(&log, 49_000), vec![(0, 20_000), (20_000, 49_000)]);
    }

    #[test]
    fn aggregates_match_hand_computed_values() {
        let log = ride(11); // samples at 0..10 s, 10 m/s, 200 W, 90 rpm
        let (_, summary) = encode_activity(&log).unwrap();
        assert_eq!(summary.records, 11);
        assert_eq!(summary.laps, 1);
        assert_eq!(summary.total_elapsed_s, 10.0);
        assert_eq!(summary.total_timer_s, 10.0);
        assert_eq!(summary.total_distance_m, 100.0);
        assert_eq!(summary.avg_power_w, Some(200));
        assert_eq!(summary.max_power_w, Some(200));
        // 200 W for ten one-second intervals = 2000 J = 2 kJ ~ 2 kcal. The
        // fixture log carries no rider mass, so there is no resting term.
        assert_eq!(summary.total_calories, Some(2));
    }

    #[test]
    fn pauses_reduce_timer_time_but_not_elapsed_time() {
        let mut log = ride(101);
        log.entries.push(LogEntry::Pause { at_ms: 20_000 });
        log.entries.push(LogEntry::Resume { at_ms: 50_000 });
        let (_, summary) = encode_activity(&log).unwrap();
        assert_eq!(summary.total_elapsed_s, 100.0);
        assert_eq!(summary.total_timer_s, 70.0);
    }

    #[test]
    fn a_ble_dropout_is_a_hole_in_the_records_not_a_failure() {
        // Samples 0..5 s, nothing for 30 s, then 35..40 s.
        let mut entries = Vec::new();
        for i in 0..6u64 {
            entries.push(LogEntry::Sample(Sample {
                elapsed_ms: i * 1000,
                power_w: Some(200),
                speed_kph: 36.0,
                distance_m: (i * 10) as f64,
                ..Default::default()
            }));
        }
        entries.push(LogEntry::Gap {
            at_ms: 5_000,
            until_ms: Some(35_000),
            reason: "peripheral disconnected".into(),
        });
        for i in 35..41u64 {
            entries.push(LogEntry::Sample(Sample {
                elapsed_ms: i * 1000,
                power_w: Some(200),
                speed_kph: 36.0,
                distance_m: (i * 10) as f64,
                ..Default::default()
            }));
        }
        entries.push(LogEntry::End { at_ms: 40_000 });
        let log = log_with(entries);

        let (bytes, summary) = encode_activity(&log).unwrap();
        assert!(crate::encode::verify(&bytes).is_ok());
        assert_eq!(summary.records, 12, "only the samples we actually have");
        assert_eq!(summary.gaps, 1);
        // The timer keeps running across a dropout: the rider was still riding.
        assert_eq!(summary.total_elapsed_s, 40.0);
        assert_eq!(summary.total_timer_s, 40.0);
        // Work is not inflated by attributing the whole 30 s gap to one sample.
        assert!(
            summary.total_calories.unwrap() < 8,
            "gap must not be integrated as full-power work, got {:?}",
            summary.total_calories
        );
    }

    #[test]
    fn altitude_is_integrated_from_gradient_and_distance() {
        // 100 m at 10% should climb 10 m.
        let mut entries = Vec::new();
        for i in 0..11u64 {
            entries.push(LogEntry::Sample(Sample {
                elapsed_ms: i * 1000,
                power_w: Some(250),
                speed_kph: 36.0,
                distance_m: (i * 10) as f64,
                gradient_pct: 10.0,
                ..Default::default()
            }));
        }
        entries.push(LogEntry::End { at_ms: 10_000 });
        let log = log_with(entries);
        let resolved = resolve_samples(&log, 0).unwrap();
        assert!((resolved.last().unwrap().altitude_m - 10.0).abs() < 1e-9);

        let (_, summary) = encode_activity(&log).unwrap();
        assert_eq!(summary.total_ascent_m, 10);
    }

    #[test]
    fn an_explicit_altitude_overrides_the_integrated_profile() {
        let entries = vec![
            LogEntry::Sample(Sample {
                elapsed_ms: 0,
                distance_m: 0.0,
                altitude_m: Some(1200.0),
                ..Default::default()
            }),
            LogEntry::Sample(Sample {
                elapsed_ms: 1000,
                distance_m: 10.0,
                gradient_pct: 50.0,
                altitude_m: Some(1205.0),
                ..Default::default()
            }),
            LogEntry::End { at_ms: 1000 },
        ];
        let log = log_with(entries);
        let resolved = resolve_samples(&log, 0).unwrap();
        assert_eq!(resolved[0].altitude_m, 1200.0);
        assert_eq!(resolved[1].altitude_m, 1205.0);
    }

    #[test]
    fn duplicate_second_samples_are_collapsed() {
        // The engine ticks faster than 1 Hz; two samples landing in the same
        // second must not produce two records with the same timestamp.
        let entries = vec![
            LogEntry::Sample(Sample {
                elapsed_ms: 0,
                power_w: Some(100),
                ..Default::default()
            }),
            LogEntry::Sample(Sample {
                elapsed_ms: 400,
                power_w: Some(150),
                ..Default::default()
            }),
            LogEntry::Sample(Sample {
                elapsed_ms: 1000,
                power_w: Some(200),
                ..Default::default()
            }),
            LogEntry::End { at_ms: 1000 },
        ];
        let log = log_with(entries);
        let resolved = resolve_samples(&log, 100).unwrap();
        assert_eq!(resolved.len(), 2);
        assert_eq!(resolved[0].fit_time, 100);
        assert_eq!(resolved[0].sample.power_w, Some(150), "later sample wins");
        assert_eq!(resolved[1].fit_time, 101);
    }

    #[test]
    fn the_record_definition_omits_sensors_the_ride_never_had() {
        let log = ride(5); // power and cadence, no heart rate
        let resolved = resolve_samples(&log, 0).unwrap();
        let mask = FieldMask::of(&resolved);
        assert!(mask.power);
        assert!(mask.cadence);
        assert!(!mask.heart_rate);
        assert!(!mask.resistance);

        let msg = record_message(&resolved[0], &mask);
        let fields: Vec<u8> = msg.fields().iter().map(|(n, _)| *n).collect();
        assert!(fields.contains(&record::POWER));
        assert!(!fields.contains(&record::HEART_RATE));
    }

    #[test]
    fn record_scaling_is_the_profile_scaling() {
        let entries = vec![
            LogEntry::Sample(Sample {
                elapsed_ms: 0,
                power_w: Some(250),
                cadence_rpm: Some(92.4),
                speed_kph: 36.0,
                distance_m: 1234.56,
                gradient_pct: -3.25,
                heart_rate_bpm: Some(151),
                altitude_m: Some(100.0),
                ..Default::default()
            }),
            LogEntry::End { at_ms: 0 },
        ];
        let log = log_with(entries);
        let resolved = resolve_samples(&log, 0).unwrap();
        let msg = record_message(&resolved[0], &FieldMask::of(&resolved));
        let get = |n: u8| msg.fields().iter().find(|(f, _)| *f == n).map(|(_, v)| v.clone());

        assert_eq!(get(record::POWER), Some(Value::Uint16(250)), "watts, unscaled");
        assert_eq!(get(record::CADENCE), Some(Value::Uint8(92)), "rpm, rounded");
        assert_eq!(get(record::SPEED), Some(Value::Uint16(10_000)), "mm/s");
        assert_eq!(get(record::DISTANCE), Some(Value::Uint32(123_456)), "cm");
        assert_eq!(get(record::GRADE), Some(Value::Sint16(-325)), "percent x100");
        assert_eq!(get(record::HEART_RATE), Some(Value::Uint8(151)), "bpm");
        // (100 m + 500) * 5
        assert_eq!(get(record::ALTITUDE), Some(Value::Uint16(3000)));
    }

    #[test]
    fn lap_and_session_do_not_share_field_numbers() {
        // Guards the single most dangerous transcription error in this crate.
        assert_ne!(lap::AVG_POWER, session::AVG_POWER);
        assert_eq!(lap::AVG_POWER, 19);
        assert_eq!(session::AVG_POWER, 20);
        assert_eq!(lap::AVG_SPEED, 13);
        assert_eq!(session::AVG_SPEED, 14);
        assert_eq!(lap::TOTAL_ASCENT, 21);
        assert_eq!(session::TOTAL_ASCENT, 22);
    }

    #[test]
    fn lap_aggregates_sum_to_the_session() {
        let mut log = ride(61);
        log.entries.push(LogEntry::Lap {
            at_ms: 20_000,
            from_controller: true,
        });
        log.entries.push(LogEntry::Lap {
            at_ms: 40_000,
            from_controller: true,
        });
        let (bytes, summary) = encode_activity(&log).unwrap();
        assert!(crate::encode::verify(&bytes).is_ok());
        assert_eq!(summary.laps, 3);
        assert_eq!(summary.records, 61);
        assert_eq!(summary.total_elapsed_s, 60.0);
        assert_eq!(summary.total_distance_m, 600.0);
    }

    #[test]
    fn lap_distance_and_ascent_tile_the_session_exactly() {
        // Each lap must be closed at the boundary, not at its last sample, or
        // the laps quietly lose one sample interval of distance apiece.
        let mut entries = Vec::new();
        for i in 0..61u64 {
            entries.push(LogEntry::Sample(Sample {
                elapsed_ms: i * 1000,
                power_w: Some(200),
                speed_kph: 36.0,
                distance_m: (i * 10) as f64,
                gradient_pct: 5.0,
                ..Default::default()
            }));
        }
        entries.push(LogEntry::Lap {
            at_ms: 20_000,
            from_controller: true,
        });
        entries.push(LogEntry::Lap {
            at_ms: 40_000,
            from_controller: true,
        });
        entries.push(LogEntry::End { at_ms: 60_000 });
        let log = log_with(entries);

        let start_fit = crate::timestamp::from_unix_millis(log.start.start_unix_ms).unwrap();
        let resolved = resolve_samples(&log, start_fit).unwrap();
        let bounds = lap_boundaries(&log, 60_000);
        let mut cursor = 0usize;
        let mut lap_distance = 0.0;
        let mut lap_ascent = 0.0;
        for (i, &(from, to)) in bounds.iter().enumerate() {
            let begin = cursor;
            let is_last = i + 1 == bounds.len();
            while cursor < resolved.len() && (is_last || resolved[cursor].sample.elapsed_ms < to) {
                cursor += 1;
            }
            let agg = aggregate(
                &resolved[begin..cursor],
                resolved.get(cursor),
                start_fit,
                from,
                to,
                0,
            );
            lap_distance += agg.total_distance_m();
            lap_ascent += agg.ascent_m;
        }
        let session = aggregate(&resolved, None, start_fit, 0, 60_000, 0);
        assert!(
            (lap_distance - session.total_distance_m()).abs() < 1e-9,
            "laps sum to {lap_distance} m, session is {} m",
            session.total_distance_m()
        );
        assert!(
            (lap_ascent - session.ascent_m).abs() < 1e-9,
            "laps climb {lap_ascent} m, session climbs {} m",
            session.ascent_m
        );
        assert_eq!(session.total_distance_m(), 600.0);
    }

    /// A trainer's own energy field is ignored, however confidently it is
    /// reported: implementations disagree by a factor of four about what it
    /// means, and measured power does not. 200 W for a minute is 12 kJ of
    /// work — about 12 kcal — not the 200 the trainer claims.
    #[test]
    fn trainer_reported_energy_is_ignored_in_favour_of_measured_work() {
        let mut entries: Vec<LogEntry> = (0..=60)
            .map(|i| {
                LogEntry::Sample(Sample {
                    elapsed_ms: i * 1000,
                    power_w: Some(200),
                    // A trainer insisting the rider burned 200 kcal in a minute.
                    energy_kcal: Some(10 + (i * 200 / 60) as u16),
                    ..Default::default()
                })
            })
            .collect();
        entries.push(LogEntry::End { at_ms: 60_000 });
        let (_, summary) = encode_activity(&log_with(entries)).unwrap();
        assert_eq!(summary.total_calories, Some(11));
    }

    /// With a rider mass recorded, the hour spent riding costs something even
    /// beyond the pedalling: 1 MET of resting metabolism on top of the work.
    #[test]
    fn a_recorded_rider_mass_adds_the_resting_burn() {
        let mut entries: Vec<LogEntry> = (0..=60)
            .map(|i| {
                LogEntry::Sample(Sample {
                    elapsed_ms: i * 1000,
                    power_w: Some(200),
                    ..Default::default()
                })
            })
            .collect();
        entries.push(LogEntry::End { at_ms: 60_000 });
        let mut log = log_with(entries);
        log.start.rider_kg = 75.0;
        let (_, summary) = encode_activity(&log).unwrap();
        // ~11.5 kcal of work plus 75 kcal/h for one minute.
        assert_eq!(summary.total_calories, Some(13));
    }

    #[test]
    fn a_crashed_log_still_encodes_and_says_so() {
        let mut log = ride(30);
        log.entries.retain(|e| !matches!(e, LogEntry::End { .. }));
        log.clean_shutdown = false;
        log.skipped_lines = 1;
        let (bytes, summary) = encode_activity(&log).unwrap();
        assert!(crate::encode::verify(&bytes).is_ok());
        assert!(summary.recovered_from_crash);
        assert_eq!(summary.skipped_log_lines, 1);
        assert_eq!(summary.records, 30);
    }

    #[test]
    fn a_pre_epoch_start_time_is_rejected() {
        let mut log = ride(5);
        log.start.start_unix_ms = 0; // 1970
        assert!(matches!(
            encode_activity(&log),
            Err(FitError::TimestampOutOfRange { .. })
        ));
    }

    #[test]
    fn encoding_is_deterministic() {
        // A file rebuilt from the same log must be byte-identical, which is
        // what makes crash recovery trustworthy.
        let log = ride(20);
        let (a, _) = encode_activity(&log).unwrap();
        let (b, _) = encode_activity(&log).unwrap();
        assert_eq!(a, b);
    }
}
