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

use crate::channel::{ChannelConfig, Range, Scale, is_valid_channel_name};
use std::collections::HashSet;

/// Parse a plain-text channel-list file into an ordered channel list.
///
/// * anything after a '#' is a comment
/// * blank lines are ignored
/// * the first whitespace-separated word of a line is the channel name
///   (alphanumeric plus ':', '-', '_', '.'); invalid names are skipped
/// * the remaining words are attribute candidates: the first valid
///   `min:max` range wins, the word `log` (matched case-insensitively)
///   sets a logarithmic y-axis scale, unknown or malformed words are
///   ignored
/// * a line naming an already-seen channel is a duplicate and is skipped
///   entirely — its attributes are never applied
pub fn parse_channel_list(input: &str) -> Vec<ChannelConfig> {
    let mut seen = HashSet::new();
    let mut channels = Vec::new();
    for line in input.lines() {
        let line = line.split('#').next().unwrap_or("");
        let mut words = line.split_whitespace();
        let Some(name) = words.next() else {
            continue;
        };
        if !is_valid_channel_name(name) || !seen.insert(name.to_string()) {
            continue;
        }
        let mut range = None;
        let mut scale = Scale::Linear;
        for word in words {
            if word.eq_ignore_ascii_case("log") {
                scale = Scale::Log;
            } else if range.is_none() {
                range = parse_range(word);
            }
        }
        channels.push(ChannelConfig::new(name, range).with_scale(scale));
    }
    channels
}

/// A `min:max` attribute word: both sides parse as `f64` and satisfy the
/// [`Range`] rules (finite, min < max).  Anything else is `None`.
fn parse_range(word: &str) -> Option<Range> {
    let (min, max) = word.split_once(':')?;
    Range::new(min.parse().ok()?, max.parse().ok()?)
}

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

    fn names(channels: &[ChannelConfig]) -> Vec<&str> {
        channels.iter().map(|c| c.name.as_str()).collect()
    }

    #[test]
    fn sample_input_from_spec() {
        let input = "# a comment\n  \
                     # comments don't have to start on the first character\n \
                     X1:SINE_1  -50:-2.5 # this is the range -50 to -2.5, trailing comments are comments to\n\
                     X1:SINE_2 100.2:50  # ignore the other fields, min > max\n\
                     X1:SINE_1 0:10  # duplicate channel, the whole line is skipped, the range is not applied\n\
                     X1:SINE_4\n\
                     X1:SINE_4 we skipped SINE_3 20:30\n\
                     X1:SINE_5 nonsense 100:50 50:80\n";
        let channels = parse_channel_list(input);
        assert_eq!(
            channels,
            vec![
                ChannelConfig::new("X1:SINE_1", Range::new(-50.0, -2.5)),
                ChannelConfig::new("X1:SINE_2", None),
                ChannelConfig::new("X1:SINE_4", None),
                ChannelConfig::new("X1:SINE_5", Range::new(50.0, 80.0)),
            ]
        );
    }

    #[test]
    fn empty_and_blank_input() {
        assert!(parse_channel_list("").is_empty());
        assert!(parse_channel_list("\n\n   \n\t\n").is_empty());
        assert!(parse_channel_list("# only a comment\n").is_empty());
    }

    #[test]
    fn whitespace_is_trimmed() {
        assert_eq!(names(&parse_channel_list("\t  X1:A  \n")), vec!["X1:A"]);
    }

    #[test]
    fn invalid_names_are_skipped() {
        let input = "X1:GOOD\nX1:BAD$NAME\nX1:ALSO/BAD\nX1:GOOD-2_a.RBV\n";
        assert_eq!(
            names(&parse_channel_list(input)),
            vec!["X1:GOOD", "X1:GOOD-2_a.RBV"]
        );
    }

    #[test]
    fn duplicates_keep_first_occurrence_order() {
        let input = "X1:B\nX1:A\nX1:B\nX1:A\n";
        assert_eq!(names(&parse_channel_list(input)), vec!["X1:B", "X1:A"]);
    }

    #[test]
    fn duplicate_lines_never_contribute_attributes() {
        let input = "X1:A 0:10\nX1:A 20:30\n";
        assert_eq!(
            parse_channel_list(input),
            vec![ChannelConfig::new("X1:A", Range::new(0.0, 10.0))]
        );
    }

    #[test]
    fn comment_immediately_after_name() {
        assert_eq!(names(&parse_channel_list("X1:A# comment\n")), vec!["X1:A"]);
    }

    #[test]
    fn first_valid_range_wins() {
        let channels = parse_channel_list("X1:A 1:2 3:4\n");
        assert_eq!(channels[0].range, Range::new(1.0, 2.0));
    }

    #[test]
    fn range_words_accept_any_f64_form() {
        let channels = parse_channel_list("X1:A -1.5e2:2e3\n");
        assert_eq!(channels[0].range, Range::new(-150.0, 2000.0));
    }

    #[test]
    fn malformed_range_words_are_ignored() {
        for word in [
            "10:5",     // inverted
            "5:5",      // min == max
            "5",        // a range takes both a min and a max
            "5:",       // missing max
            ":5",       // missing min
            "1:2:3",    // too many parts
            "nan:5",    // not finite
            "-inf:5",   // not finite
            "a:5",      // not a number
            "junkword", // not a range at all
        ] {
            let channels = parse_channel_list(&format!("X1:A {word}\n"));
            assert_eq!(channels, vec![ChannelConfig::new("X1:A", None)], "{word}");
        }
    }

    #[test]
    fn valid_range_after_junk_and_malformed_words() {
        // The spec's X1:SINE_5 case: skip words individually, take the
        // first range that is actually valid.
        let channels = parse_channel_list("X1:A nonsense 100:50 50:80\n");
        assert_eq!(channels[0].range, Range::new(50.0, 80.0));
    }

    #[test]
    fn log_word_sets_the_scale() {
        let channels = parse_channel_list("X1:A log\nX1:B\n");
        assert_eq!(channels[0].scale, Scale::Log);
        assert_eq!(channels[1].scale, Scale::Linear);
    }

    #[test]
    fn log_combines_with_a_range_in_either_order() {
        for line in ["X1:A log 1:100", "X1:A 1:100 log"] {
            let channels = parse_channel_list(line);
            assert_eq!(channels[0].range, Range::new(1.0, 100.0), "{line}");
            assert_eq!(channels[0].scale, Scale::Log, "{line}");
        }
    }

    #[test]
    fn log_on_a_duplicate_line_is_not_applied() {
        let channels = parse_channel_list("X1:A\nX1:A log\n");
        assert_eq!(
            channels,
            vec![ChannelConfig::new("X1:A", None)] // stays linear
        );
    }

    #[test]
    fn log_word_is_case_insensitive() {
        for line in ["X1:A LOG", "X1:A Log", "X1:A lOg"] {
            let channels = parse_channel_list(line);
            assert_eq!(channels[0].scale, Scale::Log, "{line}");
        }
    }

    #[test]
    fn log_among_junk_words_still_applies() {
        let channels = parse_channel_list("X1:A nonsense log 100:50\n");
        assert_eq!(channels[0].scale, Scale::Log);
        assert_eq!(channels[0].range, None);
    }
}
