//! FIT activity file encoder (REQUIREMENTS.md §5.8, FR-8).
//!
//! Records a ride to a crash-safe journal and turns it into a FIT activity file
//! that Strava and Garmin Connect will accept.
//!
//! # Why this is hand-rolled
//!
//! RISK-3 in the requirements is accurate: the Rust ecosystem reads FIT far
//! better than it writes it. There *is* a capable encoder on crates.io
//! (`rustyfit`), but it was not the right dependency here:
//!
//! * The value it adds is the generated Garmin profile — several megabytes of
//!   message definitions — of which an activity file needs seven messages. The
//!   binary container underneath is about two hundred lines and is the part
//!   that decides whether an upload is accepted.
//! * We have no Strava to test against, so correctness has to come from tests.
//!   Encoding with someone's crate and round-tripping through the same crate's
//!   decoder proves only self-consistency. Encoding with our own writer and
//!   decoding with an *independent* parser — `fitparser`, a dev-dependency —
//!   is a genuinely independent check, and it is the check this crate rests on.
//! * When an upload is rejected, the fix is at the byte level. Owning those
//!   bytes is worth more here than saving a few hundred lines.
//!
//! The field numbers and enum values in [`profile`] were transcribed from the
//! Garmin FIT SDK profile and cross-checked against `rustyfit`'s generated
//! tables, so the SDK's knowledge is used — just not its code.
//!
//! # Shape of the crate
//!
//! ```text
//! RideSnapshot --> Recorder --> raw journal (JSON Lines, flushed per sample)
//!                                    |
//!                                    v
//!                          build_fit_from_log --> .fit
//! ```
//!
//! The FIT file cannot be written incrementally: its header carries a data size
//! and its last two bytes are a CRC over everything before them, so a
//! half-written FIT is a broken FIT. Crash safety therefore lives one level
//! down, in the journal — see [`rawlog`]. A ride that ends in a crash is
//! recovered by pointing [`build_fit_from_log`] at the journal, and the
//! resulting file is byte-identical to the one a clean shutdown would have
//! produced.
//!
//! # Example
//!
//! ```no_run
//! use bikecontrol_fit::{Recorder, RecorderOptions};
//! # use bikecontrol_core::RideSnapshot;
//! # fn demo(snapshots: &[RideSnapshot]) -> Result<(), bikecontrol_fit::FitError> {
//! let mut rec = Recorder::create("rides/2026-08-05.jsonl", RecorderOptions::default())?;
//! for snap in snapshots {
//!     rec.record(snap)?;
//! }
//! let summary = rec.finish("rides/2026-08-05.fit")?;
//! # Ok(())
//! # }
//! ```

#![warn(missing_docs)]

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

pub mod builder;
pub mod crc;
pub mod encode;
pub mod profile;
pub mod rawlog;
pub mod recorder;
pub mod timestamp;

pub use builder::{encode_activity, FitSummary};
pub use crc::crc16;
pub use encode::{verify, VerifyError};
pub use rawlog::{parse_log, read_log, LogEntry, RawLog, Sample, SessionStart};
pub use recorder::{Recorder, RecorderOptions};
pub use timestamp::FIT_EPOCH_UNIX_SECS;

/// Anything that can go wrong recording or encoding a ride.
#[derive(Debug, thiserror::Error)]
pub enum FitError {
    /// Reading or writing a file failed.
    #[error("i/o error on {path}: {source}")]
    Io {
        /// The file involved.
        path: PathBuf,
        /// The underlying error.
        #[source]
        source: std::io::Error,
    },

    /// A journal line could not be serialised or deserialised.
    #[error("journal encoding error: {0}")]
    Json(#[from] serde_json::Error),

    /// The journal has no `start` header, so elapsed times cannot be anchored
    /// to the wall clock.
    #[error("raw log has no session start entry")]
    MissingSessionStart,

    /// The journal contains no telemetry. An activity with no records is
    /// rejected by every uploader, so it is refused here instead.
    #[error("raw log contains no samples; nothing to encode")]
    NoSamples,

    /// A timestamp lies outside the FIT `date_time` range — before
    /// 1989-12-31 UTC, or beyond 2158.
    #[error("timestamp {unix_secs} is outside the FIT date_time range (1989-12-31 onwards)")]
    TimestampOutOfRange {
        /// The offending Unix timestamp, in seconds.
        unix_secs: i64,
    },
}

/// Build a FIT activity from a raw journal and write it to `fit_path`.
///
/// This is the crash-recovery entry point (FR-8.4): point it at a journal left
/// behind by a ride that ended badly and it produces the activity that ride
/// should have exported. It is also what [`Recorder::finish`] calls, so the two
/// paths cannot drift apart.
///
/// The encoded file is verified — header, declared data size, both CRCs —
/// before it is written, so a file that reaches disk is structurally sound.
pub fn build_fit_from_log(
    log_path: impl AsRef<Path>,
    fit_path: impl AsRef<Path>,
) -> Result<FitSummary, FitError> {
    let log = read_log(log_path.as_ref())?;
    let (bytes, summary) = encode_activity(&log)?;

    debug_assert!(
        verify(&bytes).is_ok(),
        "encoder produced a structurally invalid FIT file: {:?}",
        verify(&bytes)
    );

    let fit_path = fit_path.as_ref();
    if let Some(parent) = fit_path.parent() {
        if !parent.as_os_str().is_empty() {
            std::fs::create_dir_all(parent).map_err(|source| FitError::Io {
                path: parent.to_path_buf(),
                source,
            })?;
        }
    }
    std::fs::write(fit_path, &bytes).map_err(|source| FitError::Io {
        path: fit_path.to_path_buf(),
        source,
    })?;
    Ok(summary)
}

/// Build a FIT activity from a raw journal and return the bytes without
/// touching the filesystem.
pub fn fit_bytes_from_log(log_path: impl AsRef<Path>) -> Result<(Vec<u8>, FitSummary), FitError> {
    let log = read_log(log_path.as_ref())?;
    encode_activity(&log)
}
