//! Source request metadata shared by graph construction and source plugins.

use std::collections::BTreeMap;

use crate::{ErrorCategory, ErrorCode, PixelFlowError, Rational, Result};

/// Script-provided scalar option for a source request.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum SourceOptionValue {
    /// String option.
    String(String),
    /// Boolean option.
    Bool(bool),
    /// Integer option.
    Int(i64),
    /// Rational option.
    Rational(Rational),
}

/// Lazy source request captured during graph construction.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SourceRequest {
    path: String,
    options: BTreeMap<String, SourceOptionValue>,
}

impl SourceRequest {
    /// Creates source request for user-provided media path.
    #[must_use]
    pub fn new(path: impl Into<String>) -> Self {
        Self {
            path: path.into(),
            options: BTreeMap::new(),
        }
    }

    /// Returns user-provided media path.
    #[must_use]
    pub fn path(&self) -> &str {
        &self.path
    }

    /// Returns source options sorted by option name.
    #[must_use]
    pub const fn options(&self) -> &BTreeMap<String, SourceOptionValue> {
        &self.options
    }

    /// Adds validated source option and returns updated request.
    pub fn try_with_option(
        mut self,
        name: impl Into<String>,
        value: SourceOptionValue,
    ) -> Result<Self> {
        let name = name.into();
        validate_option_name(&name)?;
        self.options.insert(name, value);
        Ok(self)
    }

    /// Adds option for tests and internal construction where name is known valid.
    #[must_use]
    pub fn with_option(mut self, name: impl Into<String>, value: SourceOptionValue) -> Self {
        let name = name.into();
        debug_assert!(is_option_name(&name));
        self.options.insert(name, value);
        self
    }
}

fn validate_option_name(name: &str) -> Result<()> {
    if is_option_name(name) {
        return Ok(());
    }

    Err(PixelFlowError::new(
        ErrorCategory::Source,
        ErrorCode::new("source.invalid_option"),
        format!("invalid source option name '{name}'"),
    ))
}

fn is_option_name(name: &str) -> bool {
    let mut bytes = name.bytes();

    matches!(bytes.next(), Some(first) if first.is_ascii_alphabetic() || first == b'_')
        && bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
}

#[cfg(test)]
mod tests {
    use crate::{ErrorCategory, ErrorCode, Rational};

    use super::{SourceOptionValue, SourceRequest};

    #[test]
    fn source_request_rejects_invalid_option_name() {
        let error = SourceRequest::new("input.mkv")
            .try_with_option(
                "bad-name",
                SourceOptionValue::Rational(Rational {
                    numerator: 30_000,
                    denominator: 1_001,
                }),
            )
            .expect_err("invalid option name should fail");

        assert_eq!(error.category(), ErrorCategory::Source);
        assert_eq!(error.code(), ErrorCode::new("source.invalid_option"));
    }
}
