//! The route, as the ride screen needs to draw it.
//!
//! `crates/core` owns profile *semantics* — `Profile::sample`,
//! `Profile::preview`, `Profile::total_extent`. This module owns the *view
//! model*: the elevation trace, the block breakdown, and the geometry needed to
//! place the current-position marker and answer "how much climbing is left".
//!
//! The route is the hero element of the ride screen, so this is the payload
//! that matters most.

use bikecontrol_core::profile::{Block, Channel, Extent, Position, Profile, Waveform};
use serde::Serialize;

/// Which axis the profile is drawn against.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum XUnit {
    #[default]
    Seconds,
    Metres,
}

#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct BlockSummary {
    pub index: usize,
    /// `constant` | `ramp` | `wave` | `segments` | `terrain`
    pub kind: &'static str,
    pub channel: Channel,
    pub label: String,
    pub start_x: f64,
    pub end_x: f64,
    pub unit: XUnit,
}

/// Everything the UI needs to draw a profile (FR-6.7, FR-9.7).
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ProfileView {
    pub name: String,
    pub description: Option<String>,
    pub looping: bool,
    /// Where it came from: a file path, a sample name, or `"editor"`.
    pub source: String,
    /// The channel the value series plots.
    pub channel: Channel,
    pub x_unit: XUnit,
    pub total_x: f64,
    /// Total ride duration, if the profile is measured in time.
    pub total_seconds: Option<f64>,
    /// Total ride distance, if the profile is measured in distance.
    pub total_metres: Option<f64>,
    /// `[x, value]` along the axis — gradient %, watts or resistance level.
    pub series: Vec<[f64; 2]>,
    /// `[distance_m, elevation_m]`. Real elevation for GPX-derived terrain,
    /// integrated from gradient otherwise. This is the hero chart.
    pub elevation: Option<Vec<[f64; 2]>>,
    pub elevation_min_m: Option<f32>,
    pub elevation_max_m: Option<f32>,
    pub total_ascent_m: Option<f32>,
    pub blocks: Vec<BlockSummary>,
    /// The profile as YAML, for the in-app editor.
    pub yaml: String,
}

/// Precomputed geometry kept Rust-side so per-tick lookups are cheap. Never
/// serialised — the frontend gets answers, not arrays to search.
#[derive(Debug, Clone, Default)]
pub struct ProfileGeometry {
    pub xs: Vec<f64>,
    pub elevation: Vec<f32>,
    /// Cumulative ascent at each sample, so "climbing remaining" is a
    /// subtraction rather than a scan.
    pub cum_ascent: Vec<f32>,
    pub total_x: f64,
    pub x_unit: XUnit,
    pub looping: bool,
    pub total_seconds: Option<f64>,
    pub total_metres: Option<f64>,
}

impl ProfileGeometry {
    /// Elevation at a position on the axis, linearly interpolated.
    pub fn elevation_at(&self, x: f64) -> Option<f32> {
        interp(&self.xs, &self.elevation, x)
    }

    /// Metres of climbing still to come from `x` to the end.
    pub fn ascent_remaining(&self, x: f64) -> Option<f32> {
        let total = *self.cum_ascent.last()?;
        let done = interp(&self.xs, &self.cum_ascent, x)?;
        Some((total - done).max(0.0))
    }

    pub fn total_ascent(&self) -> Option<f32> {
        self.cum_ascent.last().copied()
    }
}

fn interp(xs: &[f64], ys: &[f32], x: f64) -> Option<f32> {
    if xs.is_empty() || xs.len() != ys.len() {
        return None;
    }
    if x <= xs[0] {
        return Some(ys[0]);
    }
    let last = xs.len() - 1;
    if x >= xs[last] {
        return Some(ys[last]);
    }
    let i = xs.partition_point(|v| *v <= x).clamp(1, last);
    let (x0, x1) = (xs[i - 1], xs[i]);
    let (y0, y1) = (ys[i - 1], ys[i]);
    let span = x1 - x0;
    Some(if span.abs() < f64::EPSILON {
        y1
    } else {
        y0 + (y1 - y0) * ((x - x0) / span) as f32
    })
}

const PREVIEW_SAMPLES: usize = 1400;

fn extent_parts(extent: Extent) -> (f64, XUnit) {
    match extent {
        Extent::Seconds(s) => (s.max(0.0), XUnit::Seconds),
        Extent::Metres(m) => (m.max(0.0), XUnit::Metres),
    }
}

/// Build the view model and the geometry that goes with it.
pub fn build(profile: &Profile, source: impl Into<String>) -> (ProfileView, ProfileGeometry) {
    let extent = profile.total_extent();
    let x_unit = match (extent.metres, extent.seconds) {
        (Some(m), Some(s)) => {
            if m >= s {
                XUnit::Metres
            } else {
                XUnit::Seconds
            }
        }
        (Some(_), None) => XUnit::Metres,
        _ => XUnit::Seconds,
    };

    let preview = profile.preview(PREVIEW_SAMPLES);
    let series: Vec<[f64; 2]> = preview.iter().map(|(x, v)| [*x, *v as f64]).collect();
    let total_x = series.last().map(|p| p[0]).unwrap_or(0.0);

    let channel = profile
        .blocks
        .first()
        .map(|b| b.channel())
        .unwrap_or(Channel::Gradient);

    // Elevation. Prefer the real thing: a GPX import lands as a `Terrain`
    // block that already carries surveyed elevation. Otherwise integrate the
    // gradient, which is what a hand-authored segment profile implies anyway.
    let mut geom = ProfileGeometry {
        total_x,
        x_unit,
        looping: profile.looping,
        total_seconds: extent.seconds,
        total_metres: extent.metres,
        ..Default::default()
    };

    let elevation: Option<Vec<[f64; 2]>> = if channel == Channel::Gradient {
        let surveyed = surveyed_elevation(profile);
        let pairs = match surveyed {
            Some(points) => points,
            None if x_unit == XUnit::Metres => integrate_gradient(&series),
            None => Vec::new(),
        };
        if pairs.len() < 2 {
            None
        } else {
            geom.xs = pairs.iter().map(|p| p[0]).collect();
            geom.elevation = pairs.iter().map(|p| p[1] as f32).collect();
            let mut cum = Vec::with_capacity(geom.elevation.len());
            let mut acc = 0.0f32;
            let mut prev = geom.elevation[0];
            for e in &geom.elevation {
                acc += (e - prev).max(0.0);
                prev = *e;
                cum.push(acc);
            }
            geom.cum_ascent = cum;
            Some(pairs)
        }
    } else {
        None
    };

    let (elevation_min_m, elevation_max_m) = match &geom.elevation {
        e if e.is_empty() => (None, None),
        e => (
            Some(e.iter().copied().fold(f32::INFINITY, f32::min)),
            Some(e.iter().copied().fold(f32::NEG_INFINITY, f32::max)),
        ),
    };

    let mut blocks = Vec::with_capacity(profile.blocks.len());
    let mut cursor = 0.0f64;
    for (index, block) in profile.blocks.iter().enumerate() {
        let (span, unit) = extent_parts(block.extent());
        blocks.push(BlockSummary {
            index,
            kind: block_kind(block),
            channel: block.channel(),
            label: block_label(block),
            start_x: cursor,
            end_x: cursor + span,
            unit,
        });
        cursor += span;
    }

    let view = ProfileView {
        name: profile.name.clone(),
        description: profile.description.clone(),
        looping: profile.looping,
        source: source.into(),
        channel,
        x_unit,
        total_x,
        total_seconds: extent.seconds,
        total_metres: extent.metres,
        series,
        elevation,
        elevation_min_m,
        elevation_max_m,
        total_ascent_m: geom.total_ascent(),
        blocks,
        yaml: serde_yaml_ng::to_string(profile).unwrap_or_default(),
    };
    (view, geom)
}

/// Elevation straight out of `Terrain` blocks, offset so consecutive blocks
/// join up rather than each restarting at zero distance.
fn surveyed_elevation(profile: &Profile) -> Option<Vec<[f64; 2]>> {
    let mut out: Vec<[f64; 2]> = Vec::new();
    let mut offset = 0.0f64;
    let mut any = false;
    for block in &profile.blocks {
        let (span, _) = extent_parts(block.extent());
        if let Block::Terrain { points } = block {
            any = true;
            let base = points.first().map(|p| p.distance_m).unwrap_or(0.0);
            for p in points {
                out.push([offset + (p.distance_m - base), p.elevation_m as f64]);
            }
        }
        offset += span;
    }
    any.then_some(out)
}

/// Integrate gradient over distance to get a relative elevation trace.
fn integrate_gradient(series: &[[f64; 2]]) -> Vec<[f64; 2]> {
    let mut elev = 0.0f64;
    let mut prev_x = series.first().map(|p| p[0]).unwrap_or(0.0);
    series
        .iter()
        .map(|[x, grade]| {
            elev += (x - prev_x).max(0.0) * (grade / 100.0);
            prev_x = *x;
            [*x, elev]
        })
        .collect()
}

/// Where the rider is on the preview axis right now.
pub fn position_x(geom: &ProfileGeometry, elapsed_s: f64, distance_m: f64) -> f64 {
    let raw = match geom.x_unit {
        XUnit::Seconds => elapsed_s,
        XUnit::Metres => distance_m,
    };
    if geom.looping && geom.total_x > 0.0 {
        raw.rem_euclid(geom.total_x)
    } else {
        raw.clamp(0.0, geom.total_x.max(0.0))
    }
}

/// Convenience wrapper so callers do not have to build a `Position`.
pub fn position(elapsed_s: f64, distance_m: f64) -> Position {
    Position {
        elapsed_s,
        distance_m,
    }
}

fn block_kind(block: &Block) -> &'static str {
    match block {
        Block::Constant { .. } => "constant",
        Block::Ramp { .. } => "ramp",
        Block::Wave { .. } => "wave",
        Block::Segments { .. } => "segments",
        Block::Terrain { .. } => "terrain",
    }
}

fn unit_suffix(channel: Channel) -> &'static str {
    match channel {
        Channel::Gradient => "%",
        Channel::Resistance => "",
        Channel::Power => " W",
    }
}

fn block_label(block: &Block) -> String {
    let u = unit_suffix(block.channel());
    match block {
        Block::Constant { value, .. } => format!("hold {value:.0}{u}"),
        Block::Ramp { from, to, .. } => format!("ramp {from:.0}{u} → {to:.0}{u}"),
        Block::Wave {
            shape,
            midpoint,
            amplitude,
            repeats,
            ..
        } => format!(
            "{} {:.0}{u} ±{:.0}{u} ×{:.0}",
            match shape {
                Waveform::Sine => "sine",
                Waveform::Square => "square",
                Waveform::Triangle => "triangle",
                Waveform::Sawtooth => "sawtooth",
            },
            midpoint,
            amplitude,
            repeats
        ),
        Block::Segments { segments } => {
            let d: f64 = segments.iter().map(|s| s.distance_m).sum();
            format!("{} segments · {:.1} km", segments.len(), d / 1000.0)
        }
        Block::Terrain { points } => {
            let d = points.last().map(|p| p.distance_m).unwrap_or(0.0);
            format!("terrain · {:.1} km", d / 1000.0)
        }
    }
}
