//! Golden test: the exact bytes of a small synthetic activity.
//!
//! The round-trip tests prove an independent decoder agrees with us about what
//! the file *means*. This one pins what the file *is*, byte for byte, so that
//! any change to the encoder — a reordered field, a different scale, an extra
//! message — has to be made deliberately and reviewed as a diff of the golden
//! constant rather than slipping through unnoticed.
//!
//! Regenerate with `PRINT_GOLDEN=1 cargo test -p bikecontrol-fit --test golden`
//! and paste the printed constant back in, *after* checking the change is one
//! you meant to make.

use bikecontrol_fit::encode::{file_header, verify};
use bikecontrol_fit::rawlog::{LogEntry, RawLog, Sample, SessionStart};
use bikecontrol_fit::{crc16, encode_activity, timestamp};

/// 2026-08-05T10:00:00Z.
const START_UNIX_MS: i64 = 1_785_967_200_000;

/// A three-second ride, chosen to be small enough to read in hex and rich
/// enough to exercise every message type.
fn tiny_ride() -> RawLog {
    let samples = [
        // (elapsed_ms, power, cadence, speed_kph, distance_m, grade)
        (0u64, 210i16, 90.0f32, 30.0f32, 0.0f64, 0.0f32),
        (1000, 220, 91.0, 30.6, 8.5, 1.0),
        (2000, 230, 92.0, 31.2, 17.2, 2.0),
    ];
    let mut entries: Vec<LogEntry> = samples
        .iter()
        .map(|&(e, p, c, v, d, g)| {
            LogEntry::Sample(Sample {
                elapsed_ms: e,
                power_w: Some(p),
                cadence_rpm: Some(c),
                speed_kph: v,
                distance_m: d,
                gradient_pct: g,
                ..Default::default()
            })
        })
        .collect();
    entries.push(LogEntry::End { at_ms: 2000 });

    RawLog {
        start: SessionStart {
            start_unix_ms: START_UNIX_MS,
            utc_offset_secs: 0,
            product_name: "BikeControl".to_string(),
            software_version: 100,
            serial_number: 1,
            ..Default::default()
        },
        entries,
        skipped_lines: 0,
        clean_shutdown: true,
        path: None,
    }
}

fn hex(bytes: &[u8]) -> String {
    bytes.iter().map(|b| format!("{b:02x}")).collect()
}

fn unhex(s: &str) -> Vec<u8> {
    s.as_bytes()
        .chunks(2)
        .map(|p| u8::from_str_radix(std::str::from_utf8(p).unwrap(), 16).unwrap())
        .collect()
}

/// The complete encoding of [`tiny_ride`].
const GOLDEN: &str = include_str!("golden/tiny_ride.hex");

#[test]
fn the_encoding_of_a_tiny_ride_is_stable() {
    let (bytes, _) = encode_activity(&tiny_ride()).unwrap();

    if std::env::var("PRINT_GOLDEN").is_ok() {
        println!("{}", hex(&bytes));
    }

    let expected = unhex(GOLDEN.trim());
    assert_eq!(
        hex(&bytes),
        hex(&expected),
        "the encoder's output changed; if that was intended, regenerate the \
         golden with PRINT_GOLDEN=1 after reviewing the diff"
    );
}

#[test]
fn the_golden_file_is_internally_consistent() {
    // The three checks an uploader makes before it looks at anything else.
    let bytes = unhex(GOLDEN.trim());
    assert!(verify(&bytes).is_ok(), "{:?}", verify(&bytes));

    // Header, spelled out rather than delegated.
    assert_eq!(bytes[0], 14, "header size");
    assert_eq!(bytes[1], 0x20, "protocol version 2.0");
    assert_eq!(&bytes[8..12], b".FIT", "signature");

    let declared = u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]) as usize;
    assert_eq!(
        declared,
        bytes.len() - 16,
        "header data size must equal the bytes between the header and the CRC"
    );

    let header_crc = u16::from_le_bytes([bytes[12], bytes[13]]);
    assert_eq!(header_crc, crc16(&bytes[0..12]), "header CRC");

    let file_crc = u16::from_le_bytes([bytes[bytes.len() - 2], bytes[bytes.len() - 1]]);
    assert_eq!(
        file_crc,
        crc16(&bytes[..bytes.len() - 2]),
        "file CRC over header plus data"
    );

    // And the header we produce independently for that data size must match
    // the one embedded in the golden.
    assert_eq!(&bytes[0..14], &file_header(declared as u32));
}

#[test]
fn the_first_message_is_a_file_id_definition_with_the_expected_bytes() {
    let bytes = unhex(GOLDEN.trim());
    let data = &bytes[14..];

    // Definition message for file_id (global 0), local type 0.
    assert_eq!(data[0], 0x40, "normal header, definition bit set, local 0");
    assert_eq!(data[1], 0x00, "reserved");
    assert_eq!(data[2], 0x00, "little-endian architecture");
    assert_eq!(&data[3..5], &[0x00, 0x00], "global message number 0 = file_id");
    let n_fields = data[5] as usize;
    assert_eq!(n_fields, 6, "type, manufacturer, product, serial, time, name");

    // Field 0 (type) is a one-byte enum; the first data message must set it to
    // 4 (activity). This is the single most important byte in the file.
    assert_eq!(&data[6..9], &[0x00, 0x01, 0x00], "field 0: 1 byte, enum");

    let data_msg = 6 + n_fields * 3;
    assert_eq!(data[data_msg], 0x00, "normal header, data, local 0");
    assert_eq!(
        data[data_msg + 1],
        4,
        "file_id.type must be 4 (activity), or nothing will import it"
    );
    // manufacturer 255 (development), little endian.
    assert_eq!(&data[data_msg + 2..data_msg + 4], &[0xFF, 0x00]);
}

#[test]
fn the_golden_timestamp_is_the_fit_epoch_not_the_unix_epoch() {
    let bytes = unhex(GOLDEN.trim());
    let expected = timestamp::from_unix_millis(START_UNIX_MS).unwrap();

    // file_id.time_created sits at a known offset: 14 (header) + 6 (definition
    // header) + 18 (six field definitions) + 1 (data header) + 1 (type)
    // + 2 (manufacturer) + 2 (product) + 4 (serial) = 48.
    let at = 14 + 6 + 6 * 3 + 1 + 1 + 2 + 2 + 4;
    let encoded = u32::from_le_bytes([bytes[at], bytes[at + 1], bytes[at + 2], bytes[at + 3]]);
    assert_eq!(encoded, expected);

    // Spelled out: the value must be the Unix time minus the FIT epoch...
    assert_eq!(
        i64::from(encoded),
        START_UNIX_MS / 1000 - bikecontrol_fit::FIT_EPOCH_UNIX_SECS
    );
    // ...and emphatically not the Unix time itself.
    assert_ne!(i64::from(encoded), START_UNIX_MS / 1000);
    // A 1989 date would decode below this bound.
    assert!(encoded > 1_100_000_000, "would render as the 1990s");
}

#[test]
fn the_golden_still_decodes_with_an_independent_parser() {
    // Ties the two test strategies together: the pinned bytes are not just
    // stable, they are still a valid FIT file.
    let bytes = unhex(GOLDEN.trim());
    let records = fitparser::from_bytes(&bytes).expect("golden bytes must still parse");
    let kinds: Vec<String> = records.iter().map(|r| format!("{:?}", r.kind())).collect();
    assert_eq!(kinds, vec![
        "FileId",
        "DeviceInfo",
        "Event",
        "Record",
        "Record",
        "Record",
        "Lap",
        "Event",
        "Session",
        "Activity",
    ]);
}

#[test]
fn definitions_are_emitted_once_each() {
    // Seven message types, seven definition messages, no matter how many
    // records. If this number grows, the definition cache has broken and every
    // record is carrying its own schema.
    let bytes = unhex(GOLDEN.trim());
    let definitions = count_definitions(&bytes);
    assert_eq!(definitions, 7, "file_id, device_info, event, record, lap, session, activity");

    // The same holds for a much longer ride.
    let mut log = tiny_ride();
    log.entries.clear();
    for i in 0..600u64 {
        log.entries.push(LogEntry::Sample(Sample {
            elapsed_ms: i * 1000,
            power_w: Some(200),
            cadence_rpm: Some(90.0),
            speed_kph: 30.0,
            distance_m: (i as f64) * 8.33,
            ..Default::default()
        }));
    }
    log.entries.push(LogEntry::End { at_ms: 599_000 });
    let (long_bytes, _) = encode_activity(&log).unwrap();
    assert_eq!(count_definitions(&long_bytes), 7);
}

/// Walk the data-records section and count definition messages.
///
/// A deliberately independent re-implementation of the record framing: if the
/// encoder and this walker disagree about message sizes, the walk desynchronises
/// and the count comes out wrong.
fn count_definitions(bytes: &[u8]) -> usize {
    let data = &bytes[14..bytes.len() - 2];
    // local message type -> total data-message body size
    let mut sizes = [0usize; 16];
    let mut i = 0;
    let mut definitions = 0;

    while i < data.len() {
        let header = data[i];
        assert_eq!(header & 0x80, 0, "we never emit compressed timestamp headers");
        let local = (header & 0x0F) as usize;
        if header & 0x40 != 0 {
            definitions += 1;
            let n = data[i + 5] as usize;
            let mut total = 0;
            for f in 0..n {
                total += data[i + 6 + f * 3 + 1] as usize;
            }
            sizes[local] = total;
            i += 6 + n * 3;
        } else {
            assert!(sizes[local] > 0, "data message before its definition");
            i += 1 + sizes[local];
        }
    }
    assert_eq!(i, data.len(), "message framing did not land exactly on the end");
    definitions
}

#[test]
fn a_record_costs_a_fixed_and_small_number_of_bytes() {
    // A tripwire on file size. The fixed cost is the seven definitions plus the
    // summary messages; the marginal cost is one record. If the marginal cost
    // jumps, a definition is being re-emitted per sample and an hour-long ride
    // will produce a file uploaders throttle or reject.
    let baseline = unhex(GOLDEN.trim()).len();

    let mut log = tiny_ride();
    let extra = 100u64;
    log.entries.clear();
    for i in 0..(3 + extra) {
        log.entries.push(LogEntry::Sample(Sample {
            elapsed_ms: i * 1000,
            power_w: Some(210),
            cadence_rpm: Some(90.0),
            speed_kph: 30.0,
            distance_m: (i as f64) * 8.5,
            ..Default::default()
        }));
    }
    log.entries.push(LogEntry::End {
        at_ms: (2 + extra) * 1000,
    });
    let (bigger, _) = encode_activity(&log).unwrap();

    let per_record = (bigger.len() - baseline) as f64 / extra as f64;
    assert!(
        (17.0..=19.0).contains(&per_record),
        "expected ~18 bytes per record (1 header + timestamp, altitude, \
         distance, speed, grade, power, cadence), got {per_record:.1}"
    );

    // An hour at 1 Hz must stay comfortably within what an uploader accepts.
    let hour = baseline as f64 + 3600.0 * per_record;
    assert!(hour < 100_000.0, "an hour would be {hour:.0} bytes");
}
