//! `CanonicalId` — the string identity of a plugin.
//!
//! Two accepted forms:
//!
//! - URL-style — at least one `.` followed by a `/`, e.g.
//!   `github.com/williamweatherholtz/maat-myplugin`.
//! - Reverse-DNS — at least two `.`-separated lowercase labels with no
//!   slashes, e.g. `com.example.plugin`.
//!
//! Reserved prefixes `local:` and `system:` are accepted for dev/test
//! contexts only; the runtime treats them as "not for distribution".
//!
//! Path-traversal characters (`..`, embedded `\`, `:` outside the
//! reserved-prefix slot, leading/trailing whitespace) are always
//! rejected.

use core::fmt;

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CanonicalIdError {
    Empty,
    /// ASCII whitespace only: ` `, `\t`, `\n`, `\r`. Non-ASCII
    /// whitespace (NBSP U+00A0, LINE SEPARATOR U+2028, etc.) falls
    /// through to the char-set check and surfaces as
    /// [`Self::InvalidChar`]. The narrowing aligns runtime
    /// `validate()` with the compile-time `const_validate` used by
    /// `CanonicalId::from_static` — see 0570.
    Whitespace,
    PathTraversal,
    InvalidChar(char),
    BadShape(&'static str),
}

impl fmt::Display for CanonicalIdError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            CanonicalIdError::Empty => f.write_str("canonical_id is empty"),
            CanonicalIdError::Whitespace => f.write_str("canonical_id contains ASCII whitespace"),
            CanonicalIdError::PathTraversal => {
                f.write_str("canonical_id contains path-traversal sequence")
            }
            CanonicalIdError::InvalidChar(c) => write!(f, "canonical_id contains invalid char {c:?}"),
            CanonicalIdError::BadShape(why) => write!(f, "canonical_id has invalid shape: {why}"),
        }
    }
}

impl std::error::Error for CanonicalIdError {}

#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct CanonicalId(std::borrow::Cow<'static, str>);

impl CanonicalId {
    pub fn new(s: impl Into<String>) -> Result<Self, CanonicalIdError> {
        let s = s.into();
        validate(&s)?;
        Ok(CanonicalId(std::borrow::Cow::Owned(s)))
    }

    /// CF2-style const constructor with compile-time validation.
    ///
    /// All `validate()` rules (non-empty, no whitespace, no path-traversal,
    /// reserved prefix handling, char-set, reverse-DNS shape) are enforced
    /// at compile time via const-eval `assert!()`. A malformed literal
    /// produces `error[E0080]: evaluation of constant value failed` at the
    /// call site.
    ///
    /// ```ignore
    /// // ✅ compiles
    /// const ID: CanonicalId = CanonicalId::from_static("github.com/org/maat-game");
    /// // ❌ const-eval panic at the call site
    /// const BAD: CanonicalId = CanonicalId::from_static("missing slash");
    /// ```
    pub const fn from_static(s: &'static str) -> Self {
        const_validate(s.as_bytes());
        CanonicalId(std::borrow::Cow::Borrowed(s))
    }

    pub fn as_str(&self) -> &str {
        &self.0
    }

    /// Returns true if this id starts with a reserved dev/test prefix
    /// (`local:` or `system:`).
    pub fn is_reserved(&self) -> bool {
        self.0.starts_with("local:") || self.0.starts_with("system:")
    }
}

impl fmt::Display for CanonicalId {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(&self.0)
    }
}

#[cfg(feature = "serde")]
impl serde::Serialize for CanonicalId {
    fn serialize<S: serde::Serializer>(&self, ser: S) -> Result<S::Ok, S::Error> {
        ser.serialize_str(&self.0)
    }
}

#[cfg(feature = "serde")]
impl<'de> serde::Deserialize<'de> for CanonicalId {
    fn deserialize<D: serde::Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
        let s = String::deserialize(de)?;
        CanonicalId::new(s).map_err(serde::de::Error::custom)
    }
}

fn validate(raw: &str) -> Result<(), CanonicalIdError> {
    if raw.is_empty() {
        return Err(CanonicalIdError::Empty);
    }
    // 0570: mirror const_validate Rule 2 — ASCII whitespace only.
    // Non-ASCII whitespace (NBSP, U+2028, ...) is rejected as InvalidChar
    // below by the char-set check.
    if raw.chars().any(|c| matches!(c, ' ' | '\t' | '\n' | '\r')) {
        return Err(CanonicalIdError::Whitespace);
    }

    // Path-traversal: any `..` token, or embedded backslash.
    if raw.contains("..") {
        return Err(CanonicalIdError::PathTraversal);
    }
    if raw.contains('\\') {
        return Err(CanonicalIdError::PathTraversal);
    }

    // Reserved prefixes are dev-only escape hatches; we accept any
    // non-empty body of allowed chars after them (no shape requirement).
    let (reserved, body) = if let Some(rest) = raw.strip_prefix("local:") {
        (true, rest)
    } else if let Some(rest) = raw.strip_prefix("system:") {
        (true, rest)
    } else {
        (false, raw)
    };
    if reserved && body.is_empty() {
        return Err(CanonicalIdError::BadShape("reserved prefix without body"));
    }

    // Allowed character set: ASCII alphanumeric, `.`, `/`, `-`, `_`.
    // Non-reserved-prefix bodies must NOT contain `:` (avoids confusion
    // with future schemes).
    for ch in body.chars() {
        let ok = ch.is_ascii_alphanumeric() || matches!(ch, '.' | '/' | '-' | '_');
        if !ok {
            return Err(CanonicalIdError::InvalidChar(ch));
        }
    }

    if reserved {
        // Reserved-prefix ids are accepted with any non-empty body of
        // allowed chars; they're dev/test escapes by design.
        return Ok(());
    }

    // Shape: URL-style (has `/`, has at least one `.` before the first
    // `/`) OR reverse-DNS (no `/`, at least one `.`).
    let has_slash = body.contains('/');
    let has_dot = body.contains('.');
    if !has_dot {
        return Err(CanonicalIdError::BadShape("missing `.`"));
    }
    // 0570: mirror const_validate Rule 8 — non-reserved must have `/`.
    if !has_slash {
        return Err(CanonicalIdError::BadShape(
            "canonical_id missing `/` (reverse-DNS shape)",
        ));
    }
    let prefix = body.split('/').next().unwrap_or("");
    if !prefix.contains('.') {
        return Err(CanonicalIdError::BadShape(
            "URL-style id must have `.` before first `/`",
        ));
    }

    Ok(())
}

/// PG1-FU: compile-time validation for `CanonicalId::from_static`.
///
/// Mirrors `validate(&str)` rule-for-rule, but works on `&[u8]` so it can
/// run inside a `const fn`. Each violated rule produces a const-eval panic
/// (surfaced by rustc as `error[E0080]: evaluation of constant value failed`).
///
/// The `clippy::indexing_slicing` allow is required for const-fn byte
/// iteration: `const fn` cannot use iterators or `for` loops as of
/// rustc 1.84.
#[allow(
    clippy::indexing_slicing,
    reason = "const fn byte iteration requires direct indexing"
)]
const fn const_validate(b: &[u8]) {
    // Rule 1: non-empty.
    assert!(!b.is_empty(), "canonical_id is empty");

    // Rule 2: no whitespace bytes.
    let mut i = 0;
    while i < b.len() {
        let c = b[i];
        assert!(
            c != b' ' && c != b'\t' && c != b'\n' && c != b'\r',
            "canonical_id contains whitespace"
        );
        i += 1;
    }

    // Rule 3: no `\\` byte, no `..` substring.
    let mut i = 0;
    while i < b.len() {
        assert!(b[i] != b'\\', "canonical_id contains backslash");
        if i + 1 < b.len() {
            assert!(
                !(b[i] == b'.' && b[i + 1] == b'.'),
                "canonical_id contains `..` (path traversal)"
            );
        }
        i += 1;
    }

    // Rule 4-5: optional `local:` / `system:` reserved prefix.
    let body_start = if const_starts_with(b, b"local:") {
        6
    } else if const_starts_with(b, b"system:") {
        7
    } else {
        0
    };

    let reserved = body_start > 0;
    if reserved {
        assert!(
            body_start < b.len(),
            "canonical_id has reserved prefix without body"
        );
    }

    // Rule 6: char-set check (alphanumeric, `.`, `/`, `-`, `_`) after prefix.
    let mut i = body_start;
    while i < b.len() {
        let c = b[i];
        let ok = (c >= b'a' && c <= b'z')
            || (c >= b'A' && c <= b'Z')
            || (c >= b'0' && c <= b'9')
            || c == b'.'
            || c == b'/'
            || c == b'-'
            || c == b'_';
        assert!(ok, "canonical_id contains invalid char");
        i += 1;
    }

    // Rule 7: reserved → done.
    if reserved {
        return;
    }

    // Rule 8: non-reserved must contain `/` and `.`.
    let mut has_slash = false;
    let mut has_dot = false;
    let mut first_slash = b.len();
    let mut i = 0;
    while i < b.len() {
        if b[i] == b'/' {
            has_slash = true;
            if first_slash == b.len() {
                first_slash = i;
            }
        }
        if b[i] == b'.' {
            has_dot = true;
        }
        i += 1;
    }
    assert!(has_dot, "canonical_id missing `.`");
    assert!(has_slash, "canonical_id missing `/` (reverse-DNS shape)");

    // Rule 9: prefix before first `/` must contain `.` (>= 2 labels).
    let mut has_dot_in_prefix = false;
    let mut i = 0;
    while i < first_slash {
        if b[i] == b'.' {
            has_dot_in_prefix = true;
        }
        i += 1;
    }
    assert!(
        has_dot_in_prefix,
        "canonical_id reverse-DNS prefix needs `.` (e.g. `github.com/...`)"
    );
}

#[allow(
    clippy::indexing_slicing,
    reason = "const fn byte iteration requires direct indexing"
)]
const fn const_starts_with(haystack: &[u8], needle: &[u8]) -> bool {
    if haystack.len() < needle.len() {
        return false;
    }
    let mut i = 0;
    while i < needle.len() {
        if haystack[i] != needle[i] {
            return false;
        }
        i += 1;
    }
    true
}

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

    #[test]
    fn canonical_id_validates_url_form() {
        // 0570: URL-form (slash-bearing) is the canonical shape.
        CanonicalId::new("github.com/example/maat-myplugin").unwrap();
        CanonicalId::new("gitlab.example.org/group/sub/proj").unwrap();
    }

    #[test]
    fn validate_rejects_no_slash_reverse_dns() {
        // 0570: validate() now mirrors const_validate — non-reserved
        // canonical_ids must contain `/`. Reverse-DNS without slash is
        // rejected (matches `CanonicalId::from_static` const-eval panic).
        assert!(matches!(
            CanonicalId::new("com.example.plugin"),
            Err(CanonicalIdError::BadShape(_))
        ));
        assert!(matches!(
            CanonicalId::new("org.foundation.bar"),
            Err(CanonicalIdError::BadShape(_))
        ));
    }

    #[test]
    fn validate_rejects_nbsp() {
        // 0570: NBSP (U+00A0) is not ASCII whitespace; const_validate
        // rejects it as InvalidChar (Rule 6 char-set). validate() now
        // matches — char-set check fires after the ASCII-whitespace
        // check passes.
        assert!(matches!(
            CanonicalId::new("github.com/foo/bar\u{00A0}"),
            Err(CanonicalIdError::InvalidChar(_))
        ));
    }

    #[test]
    fn canonical_id_rejects_path_traversal_and_reserved_prefixes() {
        assert!(matches!(
            CanonicalId::new("github.com/foo/../bar"),
            Err(CanonicalIdError::PathTraversal)
        ));
        assert!(matches!(
            CanonicalId::new("github.com\\foo\\bar"),
            Err(CanonicalIdError::PathTraversal)
        ));
        // Reserved prefixes ARE accepted (with body) but flagged via
        // is_reserved() for the install layer.
        let id = CanonicalId::new("local:dev").unwrap();
        assert!(id.is_reserved());
        let id = CanonicalId::new("system:builtin.test").unwrap();
        assert!(id.is_reserved());
        // Reserved prefix without body rejected.
        assert!(matches!(
            CanonicalId::new("local:"),
            Err(CanonicalIdError::BadShape(_))
        ));
        assert!(matches!(
            CanonicalId::new("system:"),
            Err(CanonicalIdError::BadShape(_))
        ));
    }

    #[test]
    fn rejects_empty_and_whitespace() {
        assert!(matches!(CanonicalId::new(""), Err(CanonicalIdError::Empty)));
        assert!(matches!(
            CanonicalId::new(" github.com/x.y"),
            Err(CanonicalIdError::Whitespace)
        ));
        assert!(matches!(
            CanonicalId::new("github.com/x .y"),
            Err(CanonicalIdError::Whitespace)
        ));
    }

    #[test]
    fn rejects_no_dot() {
        // No `.` and no slash — fails dot check.
        assert!(matches!(
            CanonicalId::new("plain"),
            Err(CanonicalIdError::BadShape(_))
        ));
        // Has slash but no `.` before it.
        assert!(matches!(
            CanonicalId::new("group/proj.x"),
            Err(CanonicalIdError::BadShape(_))
        ));
    }

    #[test]
    fn rejects_invalid_chars() {
        // `:` outside reserved prefix is invalid.
        assert!(matches!(
            CanonicalId::new("github.com/x:y"),
            Err(CanonicalIdError::InvalidChar(':'))
        ));
        // `?` not allowed.
        assert!(matches!(
            CanonicalId::new("github.com/x?y"),
            Err(CanonicalIdError::InvalidChar('?'))
        ));
    }

    #[test]
    fn round_trip_to_str() {
        let id = CanonicalId::new("github.com/foo/bar").unwrap();
        assert_eq!(id.as_str(), "github.com/foo/bar");
        assert_eq!(format!("{id}"), "github.com/foo/bar");
    }
}
