// Copyright 2026 California Institute of Technology
//
// You should have received a copy of the licensing terms for this
// software included in the file “LICENSE” located in the top-level
// directory of this package. If you did not, you can view a copy at
// http://dcc.ligo.org/M1500244/LICENSE.txt

//! App-side signal history: a fixed-capacity ring buffer per channel,
//! sampled from the shared store every [`SAMPLE_PERIOD`], plus the pure
//! chart helpers (gap splitting, peak-preserving downsampling).  The
//! backend is not involved; simple sampling only — true min/mean/max
//! would need every CA update (see plan.md, Future).

use crate::signal::ChannelEntry;
use std::collections::{HashMap, VecDeque};
use std::time::Duration;

/// How often one sample is pushed into every channel's history.
pub const SAMPLE_PERIOD: Duration = Duration::from_millis(250);
/// [`SAMPLE_PERIOD`] in seconds, for chart x coordinates.
pub const SAMPLE_PERIOD_SECS: f64 = 0.25;
/// Samples per minute of lookback at [`SAMPLE_PERIOD`].
pub const SAMPLES_PER_MINUTE: usize = 240;
/// The history lookback limits and its `<`/`>` adjustment step, in minutes.
/// `--lookback` defaults to "auto", the 24 h maximum.
pub const MIN_HISTORY_MINUTES: usize = 5;
pub const MAX_HISTORY_MINUTES: usize = 24 * 60;
pub const HISTORY_STEP_MINUTES: usize = 5;

/// One history sample: the channel's numeric value at that tick, or `None`
/// while disconnected or non-numeric.  Named so a future upgrade to a
/// min/mean/max triple stays contained in this module.
pub type Sample = Option<f64>;

/// Fixed-capacity ring buffer of samples, oldest first.
struct History {
    buf: VecDeque<Sample>,
    capacity: usize,
}

impl History {
    fn new(capacity: usize) -> Self {
        History {
            // Deliberately not `with_capacity`: at the 24 h default that
            // would commit ~5.5 MB per channel up front.  Growing lazily,
            // a run only pays for the history it has actually collected.
            buf: VecDeque::new(),
            capacity,
        }
    }

    fn push(&mut self, sample: Sample) {
        if self.buf.len() == self.capacity {
            self.buf.pop_front();
        }
        self.buf.push_back(sample);
    }

    fn samples(&self) -> Vec<Sample> {
        self.buf.iter().copied().collect()
    }

    /// Change the capacity: shrinking drops the oldest samples, expanding
    /// keeps everything (the deque grows lazily as samples arrive).
    fn set_capacity(&mut self, capacity: usize) {
        if self.buf.len() > capacity {
            self.buf.drain(..self.buf.len() - capacity);
        }
        self.capacity = capacity;
    }
}

/// The master per-channel histories.  Always fed, even while the display
/// is paused.  The lookback is resizable at runtime within
/// [`MIN_HISTORY_MINUTES`]..=[`MAX_HISTORY_MINUTES`].
pub struct HistoryStore {
    histories: HashMap<String, History>,
    minutes: usize,
}

impl HistoryStore {
    pub fn new<'a>(channels: impl IntoIterator<Item = &'a str>, minutes: usize) -> Self {
        let minutes = minutes.clamp(MIN_HISTORY_MINUTES, MAX_HISTORY_MINUTES);
        HistoryStore {
            histories: channels
                .into_iter()
                .map(|name| (name.to_string(), History::new(minutes * SAMPLES_PER_MINUTE)))
                .collect(),
            minutes,
        }
    }

    /// The current lookback in minutes.
    pub fn minutes(&self) -> usize {
        self.minutes
    }

    /// Resize the lookback, clamped to the limits; returns the minutes
    /// actually applied.  Shrinking truncates each buffer's oldest samples;
    /// expanding invalidates nothing.
    pub fn resize(&mut self, minutes: usize) -> usize {
        self.minutes = minutes.clamp(MIN_HISTORY_MINUTES, MAX_HISTORY_MINUTES);
        for history in self.histories.values_mut() {
            history.set_capacity(self.minutes * SAMPLES_PER_MINUTE);
        }
        self.minutes
    }

    /// Push one sample per channel from a snapshot of the shared store.
    pub fn sample(&mut self, store: &HashMap<String, ChannelEntry>) {
        for (name, history) in &mut self.histories {
            history.push(
                store
                    .get(name)
                    .and_then(|entry| entry.value.data.as_ref())
                    .and_then(|data| data.as_f64()),
            );
        }
    }

    /// The samples for one channel, oldest first (empty for unknown names).
    pub fn samples_for(&self, channel: &str) -> Vec<Sample> {
        self.histories
            .get(channel)
            .map(History::samples)
            .unwrap_or_default()
    }
}

/// Estimated history memory for `channels` channels at `minutes` of
/// lookback.  Exact for the buffers themselves (strings never enter the
/// history), and honest by construction if [`Sample`] ever grows.
pub fn memory_estimate(channels: usize, minutes: usize) -> usize {
    channels * std::mem::size_of::<Sample>() * SAMPLES_PER_MINUTE * minutes
}

/// Chart data for a sample buffer: contiguous connected runs of `(x, y)`
/// points, where x is seconds relative to the newest sample (always <= 0)
/// and `None` samples become gaps between runs.  Downsampled to roughly
/// `max_points` in total, peak-preserving: each bucket of samples keeps its
/// min and its max so spikes survive.
pub fn chart_segments(samples: &[Sample], max_points: usize) -> Vec<Vec<(f64, f64)>> {
    let n = samples.len();
    if n == 0 || max_points == 0 {
        return Vec::new();
    }
    // Each bucket contributes at most 2 points.
    let buckets = (max_points / 2).max(1);
    let bucket_len = n.div_ceil(buckets);
    let x_of = |i: usize| -((n - 1 - i) as f64) * SAMPLE_PERIOD_SECS;

    let mut segments = Vec::new();
    let mut run: Vec<(usize, f64)> = Vec::new();
    for (i, sample) in samples.iter().enumerate() {
        match sample {
            Some(v) => run.push((i, *v)),
            None => {
                if !run.is_empty() {
                    segments.push(downsample_run(&run, bucket_len, x_of));
                    run.clear();
                }
            }
        }
    }
    if !run.is_empty() {
        segments.push(downsample_run(&run, bucket_len, x_of));
    }
    segments
}

/// Downsample one gap-free run of `(index, value)`: per bucket keep the min
/// and the max sample, emitted in index order.
fn downsample_run(
    run: &[(usize, f64)],
    bucket_len: usize,
    x_of: impl Fn(usize) -> f64,
) -> Vec<(f64, f64)> {
    let mut points = Vec::new();
    for bucket in run.chunks(bucket_len) {
        let (mut lo, mut hi) = (bucket[0], bucket[0]);
        for &(i, v) in bucket {
            if v < lo.1 {
                lo = (i, v);
            }
            if v > hi.1 {
                hi = (i, v);
            }
        }
        let (first, second) = if lo.0 <= hi.0 { (lo, hi) } else { (hi, lo) };
        points.push((x_of(first.0), first.1));
        if second.0 != first.0 {
            points.push((x_of(second.0), second.1));
        }
    }
    points
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::signal::{SignalData, SignalValue};
    use std::time::SystemTime;

    #[test]
    fn ring_buffer_wraps_at_capacity() {
        let mut history = History::new(3);
        for i in 0..5 {
            history.push(Some(f64::from(i)));
        }
        assert_eq!(
            history.samples(),
            vec![Some(2.0), Some(3.0), Some(4.0)] // oldest two dropped
        );
    }

    fn entry(data: SignalData) -> ChannelEntry {
        ChannelEntry {
            value: SignalValue {
                data: Some(data),
                time: SystemTime::now(),
            },
            info: None,
        }
    }

    #[test]
    fn shrinking_capacity_drops_only_the_oldest() {
        let mut history = History::new(5);
        for i in 0..4 {
            history.push(Some(f64::from(i)));
        }
        history.set_capacity(2);
        assert_eq!(history.samples(), vec![Some(2.0), Some(3.0)]);
        // Shrinking a buffer whose data already fits is lossless.
        history.set_capacity(1);
        assert_eq!(history.samples(), vec![Some(3.0)]);
    }

    #[test]
    fn expanding_capacity_keeps_data_and_raises_the_cap() {
        let mut history = History::new(2);
        history.push(Some(1.0));
        history.push(Some(2.0));
        history.set_capacity(4);
        assert_eq!(history.samples(), vec![Some(1.0), Some(2.0)]);
        history.push(Some(3.0));
        history.push(Some(4.0));
        history.push(Some(5.0));
        // Old cap of 2 no longer applies; the new cap of 4 does.
        assert_eq!(
            history.samples(),
            vec![Some(2.0), Some(3.0), Some(4.0), Some(5.0)]
        );
    }

    #[test]
    fn store_resize_clamps_to_the_limits() {
        let mut store = HistoryStore::new(["A"], 15);
        assert_eq!(store.minutes(), 15);
        assert_eq!(store.resize(0), MIN_HISTORY_MINUTES);
        assert_eq!(store.resize(100_000), MAX_HISTORY_MINUTES);
        assert_eq!(store.resize(20), 20);
        // The constructor clamps too.
        assert_eq!(HistoryStore::new(["A"], 1).minutes(), MIN_HISTORY_MINUTES);
    }

    #[test]
    fn store_resize_truncates_the_buffers() {
        let mut store = HistoryStore::new(["A"], 15);
        let snapshot = HashMap::from([("A".to_string(), entry(SignalData::Float64(1.0)))]);
        for _ in 0..6 * SAMPLES_PER_MINUTE {
            store.sample(&snapshot);
        }
        store.resize(5);
        assert_eq!(store.samples_for("A").len(), 5 * SAMPLES_PER_MINUTE);
        // A partially filled buffer is untouched by a shrink it fits in.
        store.resize(10);
        assert_eq!(store.samples_for("A").len(), 5 * SAMPLES_PER_MINUTE);
    }

    #[test]
    fn memory_estimate_multiplies_out() {
        assert_eq!(
            memory_estimate(50, 15),
            50 * std::mem::size_of::<Sample>() * 240 * 15
        );
        assert_eq!(memory_estimate(0, 15), 0);
    }

    #[test]
    fn store_samples_every_channel() {
        let mut store = HistoryStore::new(["A", "B", "C"], 15);
        let mut snapshot = HashMap::new();
        snapshot.insert("A".to_string(), entry(SignalData::Float64(1.5)));
        snapshot.insert(
            "B".to_string(),
            entry(SignalData::String("text".to_string())),
        );
        // C is absent: never connected.
        store.sample(&snapshot);
        assert_eq!(store.samples_for("A"), vec![Some(1.5)]);
        assert_eq!(store.samples_for("B"), vec![None]); // non-numeric
        assert_eq!(store.samples_for("C"), vec![None]);
        assert_eq!(store.samples_for("UNKNOWN"), Vec::<Sample>::new());
    }

    #[test]
    fn chart_x_ends_at_zero_for_the_newest_sample() {
        let samples = vec![Some(1.0), Some(2.0), Some(3.0)];
        let segments = chart_segments(&samples, 100);
        assert_eq!(segments, vec![vec![(-0.5, 1.0), (-0.25, 2.0), (0.0, 3.0)]]);
    }

    #[test]
    fn gaps_split_segments() {
        let samples = vec![Some(1.0), None, None, Some(2.0), Some(3.0)];
        let segments = chart_segments(&samples, 100);
        assert_eq!(segments.len(), 2);
        assert_eq!(segments[0], vec![(-1.0, 1.0)]);
        assert_eq!(segments[1], vec![(-0.25, 2.0), (0.0, 3.0)]);
    }

    #[test]
    fn downsampling_preserves_spikes() {
        // 100 flat samples with one spike; downsample to at most ~4 points.
        let mut samples: Vec<Sample> = vec![Some(1.0); 100];
        samples[37] = Some(50.0);
        let segments = chart_segments(&samples, 4);
        let points = &segments[0];
        assert!(points.len() <= 4);
        assert!(points.iter().any(|&(_, y)| y == 50.0), "spike lost");
        assert!(points.iter().any(|&(_, y)| y == 1.0));
    }

    #[test]
    fn all_disconnected_yields_no_segments() {
        assert!(chart_segments(&[None, None], 100).is_empty());
        assert!(chart_segments(&[], 100).is_empty());
    }
}
