// 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

//! Backend-agnostic channel configuration produced by the input-file
//! parsers: a channel name plus optional attributes (a scope min/max
//! range for now).

/// A validated scope range.  Construction enforces the spec's rules
/// (both bounds finite, `min < max`), so an invalid range cannot exist.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Range {
    min: f64,
    max: f64,
}

impl Range {
    /// `None` unless both bounds are finite and `min < max`.
    pub fn new(min: f64, max: f64) -> Option<Self> {
        (min.is_finite() && max.is_finite() && min < max).then_some(Range { min, max })
    }

    pub fn min(&self) -> f64 {
        self.min
    }

    pub fn max(&self) -> f64 {
        self.max
    }

    /// Is `value` outside `[min, max]`?
    pub fn excludes(&self, value: f64) -> bool {
        value < self.min || value > self.max
    }
}

/// One parsed channel: the name and any optional attributes found in the
/// input file.
#[derive(Debug, Clone, PartialEq)]
pub struct ChannelConfig {
    pub name: String,
    pub range: Option<Range>,
}

impl ChannelConfig {
    pub fn new(name: impl Into<String>, range: Option<Range>) -> Self {
        ChannelConfig {
            name: name.into(),
            range,
        }
    }
}

/// Channel names are alphanumeric plus ':', '-', '_', '.' (the '.' admits
/// record-field references like `X1:FOO.RBV`).
pub fn is_valid_channel_name(name: &str) -> bool {
    !name.is_empty()
        && name
            .chars()
            .all(|c| c.is_ascii_alphanumeric() || matches!(c, ':' | '-' | '_' | '.'))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn range_requires_finite_min_below_max() {
        assert!(Range::new(0.0, 1.0).is_some());
        assert!(Range::new(-50.0, -2.5).is_some());
        assert!(Range::new(1.0, 0.0).is_none(), "inverted");
        assert!(Range::new(5.0, 5.0).is_none(), "min == max");
        assert!(Range::new(f64::NAN, 1.0).is_none());
        assert!(Range::new(0.0, f64::INFINITY).is_none());
        assert!(Range::new(f64::NEG_INFINITY, 0.0).is_none());
    }

    #[test]
    fn excludes_is_outside_the_closed_interval() {
        let range = Range::new(-1.0, 1.0).unwrap();
        assert!(!range.excludes(0.0));
        assert!(!range.excludes(-1.0), "bounds are in range");
        assert!(!range.excludes(1.0));
        assert!(range.excludes(1.5));
        assert!(range.excludes(-1.5));
    }

    #[test]
    fn names_allow_field_references() {
        assert!(is_valid_channel_name("X1:FOO-BAR_2.RBV"));
        assert!(!is_valid_channel_name(""));
        assert!(!is_valid_channel_name("X1:BAD$NAME"));
    }
}
