//! Parsing + validation of host and path patterns.
//!
//! Both kinds round-trip via the storage form (`(kind, raw_string)`)
//! and the parsed form (`HostPattern` / `PathPattern`). The
//! orchestrator parses once at table load; matching and conflict-checks
//! work over the parsed form.

use picloud_shared::{HostKind, PathKind};
use thiserror::Error;

// ----------------------------------------------------------------------------
// Errors
// ----------------------------------------------------------------------------

#[derive(Debug, Clone, Error, PartialEq, Eq)]
pub enum ParseError {
    #[error("path must not be empty")]
    EmptyPath,
    #[error("path must start with '/'")]
    PathMissingLeadingSlash,
    #[error("prefix path must end with '/*'")]
    PrefixMissingTail,
    #[error(
        "param path must contain at least one ':name' segment (use kind=exact for literal paths)"
    )]
    ParamWithoutCaptures,
    #[error("':' must start a whole path segment; bad segment: {0:?}")]
    MidSegmentParam(String),
    #[error("empty param name in segment {0:?}")]
    EmptyParamName(String),
    #[error("invalid param name {0:?}: must match [a-zA-Z_][a-zA-Z0-9_]*")]
    InvalidParamName(String),
    #[error("duplicate param name {0:?} within a single route")]
    DuplicateParamName(String),
    #[error("path '{0}' is reserved for system use")]
    ReservedPath(String),
    #[error("'{{name}}' syntax is reserved for a future SDK release; use ':name' for whole-segment params")]
    ReservedBraceSyntax,

    #[error("host must not be empty")]
    EmptyHost,
    #[error("wildcard host must start with '*.'")]
    WildcardMissingPrefix,
    #[error("wildcard suffix must not be empty (got just '*')")]
    EmptyWildcardSuffix,
    #[error("'{{name}}.example.com' subdomain capture is reserved for a future SDK release; use '*.example.com'")]
    ReservedHostBraceSyntax,
}

// ----------------------------------------------------------------------------
// Path patterns
// ----------------------------------------------------------------------------

/// Path-side prefixes that user routes are not allowed to bind to.
/// Posting a route with a path beginning with any of these returns 422.
pub const RESERVED_PATH_PREFIXES: &[&str] = &["/api/", "/admin/", "/healthz", "/version"];

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PathPattern {
    Exact(String),
    /// Stored with the trailing slash (no `*`). Matches `path.starts_with(prefix)`.
    Prefix(String),
    Param(Vec<PathSegment>),
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PathSegment {
    Literal(String),
    Param(String),
}

impl PathPattern {
    /// Number of leading literal segments before the first variable segment.
    /// Used as the primary specificity score at match time.
    #[must_use]
    pub fn leading_literal_count(&self) -> usize {
        match self {
            Self::Exact(_) => usize::MAX, // exact wins absolute
            Self::Prefix(p) => p.split('/').filter(|s| !s.is_empty()).count(),
            Self::Param(segs) => segs
                .iter()
                .take_while(|s| matches!(s, PathSegment::Literal(_)))
                .count(),
        }
    }
}

/// Parse a stored path. The `kind` selects validation rules; the same
/// raw string can be valid under multiple kinds (e.g., `/greet/:name`
/// parses as `Exact` with a literal `:` or as `Param` with a capture).
pub fn parse_path(kind: PathKind, raw: &str) -> Result<PathPattern, ParseError> {
    if raw.is_empty() {
        return Err(ParseError::EmptyPath);
    }
    if !raw.starts_with('/') {
        return Err(ParseError::PathMissingLeadingSlash);
    }
    if raw.contains('{') || raw.contains('}') {
        return Err(ParseError::ReservedBraceSyntax);
    }
    check_reserved(raw)?;

    match kind {
        PathKind::Exact => Ok(PathPattern::Exact(raw.to_string())),
        PathKind::Prefix => parse_prefix(raw),
        PathKind::Param => parse_param(raw),
    }
}

fn check_reserved(raw: &str) -> Result<(), ParseError> {
    // Case-fold before comparing: request-time path matching is case-sensitive,
    // but the reserved namespace must be rejected regardless of case so a tenant
    // can't publish look-alikes like `/Admin/login` or `/API/v2/x`. Method/host
    // matching are already case-insensitive; this keeps the validation guard
    // durable even if path matching ever follows.
    let lowered = raw.to_ascii_lowercase();
    for r in RESERVED_PATH_PREFIXES {
        if lowered == r.trim_end_matches('/') || lowered.starts_with(r) {
            // Preserve the caller's original case in the error for clarity.
            return Err(ParseError::ReservedPath(raw.to_string()));
        }
    }
    Ok(())
}

fn parse_prefix(raw: &str) -> Result<PathPattern, ParseError> {
    let stripped = raw
        .strip_suffix("/*")
        .ok_or(ParseError::PrefixMissingTail)?;
    // Normalize: store with trailing `/` so starts_with matches exactly
    // the "more under here" semantic. `/greet/*` → "/greet/".
    let prefix = if stripped.is_empty() {
        "/".to_string()
    } else {
        format!("{stripped}/")
    };
    Ok(PathPattern::Prefix(prefix))
}

fn parse_param(raw: &str) -> Result<PathPattern, ParseError> {
    let mut segments = Vec::new();
    let mut seen_names = Vec::new();
    let mut any_param = false;

    // Skip the leading '/'; trailing '/' (other than the root case)
    // would create an empty segment and is rejected.
    for seg in raw.trim_start_matches('/').split('/') {
        if seg.is_empty() {
            return Err(ParseError::MidSegmentParam(raw.to_string()));
        }
        if let Some(rest) = seg.strip_prefix(':') {
            // The chars after `:` must be the WHOLE name and nothing
            // else. Distinguish "name starts with digit" (an invalid
            // identifier as such) from "name has trailing non-ident
            // characters" (mixing :name with literal text — what we
            // call mid-segment).
            if rest.is_empty() {
                return Err(ParseError::EmptyParamName(seg.to_string()));
            }
            let bad_first =
                !(rest.chars().next().unwrap().is_ascii_alphabetic() || rest.starts_with('_'));
            let all_ident_chars = rest.chars().all(|c| c.is_ascii_alphanumeric() || c == '_');
            if !all_ident_chars {
                return Err(ParseError::MidSegmentParam(seg.to_string()));
            }
            if bad_first {
                return Err(ParseError::InvalidParamName(rest.to_string()));
            }
            if seen_names.iter().any(|n: &String| n == rest) {
                return Err(ParseError::DuplicateParamName(rest.to_string()));
            }
            seen_names.push(rest.to_string());
            segments.push(PathSegment::Param(rest.to_string()));
            any_param = true;
        } else if seg.contains(':') {
            return Err(ParseError::MidSegmentParam(seg.to_string()));
        } else {
            segments.push(PathSegment::Literal(seg.to_string()));
        }
    }

    if !any_param {
        return Err(ParseError::ParamWithoutCaptures);
    }
    Ok(PathPattern::Param(segments))
}

// ----------------------------------------------------------------------------
// Host patterns
// ----------------------------------------------------------------------------

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HostPattern {
    Any,
    Strict(String),
    /// Suffix WITHOUT the leading `*.`; the matcher accepts any
    /// non-empty label followed by `.suffix`.
    Wildcard {
        suffix: String,
        capture: Option<String>,
    },
}

impl HostPattern {
    /// Specificity rank for host dispatch (higher wins).
    #[must_use]
    pub fn specificity(&self) -> HostSpecificity {
        match self {
            Self::Strict(_) => HostSpecificity::Strict,
            Self::Wildcard { suffix, .. } => HostSpecificity::Wildcard(suffix.len()),
            Self::Any => HostSpecificity::Any,
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum HostSpecificity {
    Any,
    /// Longer wildcard suffix = more specific.
    Wildcard(usize),
    Strict,
}

pub fn parse_host(
    kind: HostKind,
    raw: &str,
    capture: Option<&str>,
) -> Result<HostPattern, ParseError> {
    match kind {
        HostKind::Any => Ok(HostPattern::Any),
        HostKind::Strict => {
            if raw.is_empty() {
                return Err(ParseError::EmptyHost);
            }
            if raw.contains('{') || raw.contains('}') {
                return Err(ParseError::ReservedHostBraceSyntax);
            }
            Ok(HostPattern::Strict(raw.to_string()))
        }
        HostKind::Wildcard => {
            if raw.is_empty() {
                return Err(ParseError::EmptyHost);
            }
            if raw.contains('{') || raw.contains('}') {
                return Err(ParseError::ReservedHostBraceSyntax);
            }
            let suffix = raw
                .strip_prefix("*.")
                .ok_or(ParseError::WildcardMissingPrefix)?;
            if suffix.is_empty() {
                return Err(ParseError::EmptyWildcardSuffix);
            }
            Ok(HostPattern::Wildcard {
                suffix: suffix.to_string(),
                capture: capture.map(str::to_string),
            })
        }
    }
}

// ----------------------------------------------------------------------------
// App-domain patterns
// ----------------------------------------------------------------------------

use picloud_shared::DomainShape;

/// Result of parsing a user-supplied app domain claim. Carries the
/// host pattern (used at request time), the shape (used at write time
/// for collision checks), and the normalized shape_key.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParsedAppDomain {
    pub pattern: HostPattern,
    pub shape: DomainShape,
    /// Collision key: `"exact:<host>"` for exact; `"wildcard:<suffix>"`
    /// for both wildcard AND parameterized — they share a shape per
    /// blueprint §11.5 ("`{tenant}` has the same shape as `*` for this
    /// check").
    pub shape_key: String,
    /// Captured binding name for parameterized claims, e.g., `Some("tenant")`
    /// for `{tenant}.example.com`. Currently informational; the binding
    /// is surfaced into request context in a future iteration.
    pub binding: Option<String>,
}

/// Parse a user-supplied app domain claim. Accepts:
///   * `app.example.com`            — exact host
///   * `*.example.com`              — wildcard suffix
///   * `{tenant}.example.com`       — parameterized; same shape as wildcard
///
/// Distinct from `parse_host` (which is for route host fields): the
/// route parser still rejects `{...}` syntax — see
/// `ParseError::ReservedHostBraceSyntax`.
pub fn parse_app_domain(raw: &str) -> Result<ParsedAppDomain, ParseError> {
    let trimmed = raw.trim();
    if trimmed.is_empty() {
        return Err(ParseError::EmptyHost);
    }
    let lowered = trimmed.to_ascii_lowercase();

    // Wildcard: starts with "*."
    if let Some(suffix) = lowered.strip_prefix("*.") {
        if suffix.is_empty() {
            return Err(ParseError::EmptyWildcardSuffix);
        }
        return Ok(ParsedAppDomain {
            pattern: HostPattern::Wildcard {
                suffix: suffix.to_string(),
                capture: None,
            },
            shape: DomainShape::Wildcard,
            shape_key: format!("wildcard:{suffix}"),
            binding: None,
        });
    }

    // Parameterized: starts with "{name}." where `name` is an ident.
    if let Some(stripped) = lowered.strip_prefix('{') {
        let (binding, rest) = stripped
            .split_once('}')
            .ok_or(ParseError::ReservedHostBraceSyntax)?;
        if binding.is_empty()
            || !binding
                .chars()
                .all(|c| c.is_ascii_alphanumeric() || c == '_')
            || !binding.chars().next().unwrap().is_ascii_alphabetic()
        {
            return Err(ParseError::InvalidParamName(binding.to_string()));
        }
        let suffix = rest
            .strip_prefix('.')
            .ok_or(ParseError::ReservedHostBraceSyntax)?;
        if suffix.is_empty() || suffix.contains('{') || suffix.contains('}') {
            return Err(ParseError::ReservedHostBraceSyntax);
        }
        return Ok(ParsedAppDomain {
            pattern: HostPattern::Wildcard {
                suffix: suffix.to_string(),
                capture: Some(binding.to_string()),
            },
            shape: DomainShape::Parameterized,
            // Same shape_key as the equivalent wildcard — parameter
            // name is a binding, not a discriminator.
            shape_key: format!("wildcard:{suffix}"),
            binding: Some(binding.to_string()),
        });
    }

    // Anything else: exact host. Reject braces anywhere in the body
    // (they'd be a malformed parameterized form).
    if lowered.contains('{') || lowered.contains('}') {
        return Err(ParseError::ReservedHostBraceSyntax);
    }
    Ok(ParsedAppDomain {
        pattern: HostPattern::Strict(lowered.clone()),
        shape: DomainShape::Exact,
        shape_key: format!("exact:{lowered}"),
        binding: None,
    })
}

// ----------------------------------------------------------------------------
// Tests
// ----------------------------------------------------------------------------

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

    #[test]
    fn exact_takes_any_string() {
        let p = parse_path(PathKind::Exact, "/greet").unwrap();
        assert_eq!(p, PathPattern::Exact("/greet".into()));
        // Even one with a colon — exact stores it literally.
        let p2 = parse_path(PathKind::Exact, "/greet/:name").unwrap();
        assert_eq!(p2, PathPattern::Exact("/greet/:name".into()));
    }

    #[test]
    fn prefix_normalizes_trailing_slash() {
        let p = parse_path(PathKind::Prefix, "/greet/*").unwrap();
        assert_eq!(p, PathPattern::Prefix("/greet/".into()));
    }

    #[test]
    fn prefix_rejects_no_trailing_star() {
        let e = parse_path(PathKind::Prefix, "/greet").unwrap_err();
        assert_eq!(e, ParseError::PrefixMissingTail);
    }

    #[test]
    fn prefix_at_root() {
        let p = parse_path(PathKind::Prefix, "/*").unwrap();
        assert_eq!(p, PathPattern::Prefix("/".into()));
    }

    #[test]
    fn param_parses_segments_and_captures_names() {
        let p = parse_path(PathKind::Param, "/users/:id/posts/:post").unwrap();
        match p {
            PathPattern::Param(segs) => {
                assert_eq!(segs.len(), 4);
                assert_eq!(segs[0], PathSegment::Literal("users".into()));
                assert_eq!(segs[1], PathSegment::Param("id".into()));
                assert_eq!(segs[2], PathSegment::Literal("posts".into()));
                assert_eq!(segs[3], PathSegment::Param("post".into()));
            }
            _ => panic!("expected Param"),
        }
    }

    #[test]
    fn param_rejects_mid_segment_colon() {
        let e = parse_path(PathKind::Param, "/greet/my:name").unwrap_err();
        assert!(matches!(e, ParseError::MidSegmentParam(_)));
        let e2 = parse_path(PathKind::Param, "/greet/:name.json").unwrap_err();
        assert!(matches!(e2, ParseError::MidSegmentParam(_)));
    }

    #[test]
    fn param_rejects_duplicate_names() {
        let e = parse_path(PathKind::Param, "/users/:id/posts/:id").unwrap_err();
        assert_eq!(e, ParseError::DuplicateParamName("id".into()));
    }

    #[test]
    fn param_rejects_empty_or_invalid_names() {
        assert!(matches!(
            parse_path(PathKind::Param, "/greet/:"),
            Err(ParseError::EmptyParamName(_))
        ));
        assert!(matches!(
            parse_path(PathKind::Param, "/greet/:9id"),
            Err(ParseError::InvalidParamName(_))
        ));
    }

    #[test]
    fn param_requires_at_least_one_capture() {
        let e = parse_path(PathKind::Param, "/just/literal").unwrap_err();
        assert_eq!(e, ParseError::ParamWithoutCaptures);
    }

    #[test]
    fn rejects_brace_syntax_reserved() {
        let e = parse_path(PathKind::Exact, "/x/{name}").unwrap_err();
        assert_eq!(e, ParseError::ReservedBraceSyntax);
    }

    #[test]
    fn rejects_reserved_paths() {
        for raw in [
            "/api/v1/admin/scripts",
            "/api/v2/foo",
            "/admin/dashboard",
            "/healthz",
            "/version",
        ] {
            let e = parse_path(PathKind::Exact, raw).unwrap_err();
            assert!(
                matches!(e, ParseError::ReservedPath(_)),
                "expected reserved for {raw:?}, got {e:?}"
            );
        }
    }

    #[test]
    fn rejects_reserved_paths_case_insensitively() {
        // Case variants of every reserved prefix must be rejected: the reserved
        // namespace is a security boundary and must not be bypassable by casing.
        for raw in [
            "/API/v2/foo",
            "/Api/v2/foo",
            "/aPi/x",
            "/Admin/dashboard",
            "/ADMIN/x",
            "/HEALTHZ",
            "/HealthZ",
            "/Version",
            "/VERSION",
        ] {
            let e = parse_path(PathKind::Exact, raw).unwrap_err();
            assert!(
                matches!(e, ParseError::ReservedPath(_)),
                "expected reserved for {raw:?}, got {e:?}"
            );
        }
    }

    #[test]
    fn rejects_missing_leading_slash() {
        let e = parse_path(PathKind::Exact, "greet").unwrap_err();
        assert_eq!(e, ParseError::PathMissingLeadingSlash);
    }

    #[test]
    fn host_strict_and_wildcard() {
        assert_eq!(
            parse_host(HostKind::Strict, "sub.example.com", None).unwrap(),
            HostPattern::Strict("sub.example.com".into())
        );
        assert_eq!(
            parse_host(HostKind::Wildcard, "*.example.com", None).unwrap(),
            HostPattern::Wildcard {
                suffix: "example.com".into(),
                capture: None
            }
        );
        assert_eq!(
            parse_host(HostKind::Any, "", None).unwrap(),
            HostPattern::Any
        );
    }

    #[test]
    fn host_rejects_wildcard_without_star_dot() {
        let e = parse_host(HostKind::Wildcard, "example.com", None).unwrap_err();
        assert_eq!(e, ParseError::WildcardMissingPrefix);
    }

    #[test]
    fn host_rejects_brace_syntax() {
        let e = parse_host(HostKind::Wildcard, "{tenant}.example.com", None).unwrap_err();
        assert_eq!(e, ParseError::ReservedHostBraceSyntax);
    }

    #[test]
    fn parse_app_domain_exact() {
        let d = parse_app_domain("App.Example.COM").unwrap();
        assert_eq!(d.shape, DomainShape::Exact);
        assert_eq!(d.shape_key, "exact:app.example.com");
        assert_eq!(d.pattern, HostPattern::Strict("app.example.com".into()));
        assert!(d.binding.is_none());
    }

    #[test]
    fn parse_app_domain_wildcard_and_parameterized_share_shape_key() {
        let w = parse_app_domain("*.example.com").unwrap();
        let p = parse_app_domain("{tenant}.example.com").unwrap();
        assert_eq!(w.shape, DomainShape::Wildcard);
        assert_eq!(p.shape, DomainShape::Parameterized);
        // Same shape_key — they collide at claim time (blueprint §11.5).
        assert_eq!(w.shape_key, "wildcard:example.com");
        assert_eq!(p.shape_key, "wildcard:example.com");
        assert_eq!(p.binding.as_deref(), Some("tenant"));
    }

    #[test]
    fn parse_app_domain_rejects_garbage() {
        assert!(matches!(parse_app_domain(""), Err(ParseError::EmptyHost)));
        assert!(matches!(
            parse_app_domain("*."),
            Err(ParseError::EmptyWildcardSuffix)
        ));
        assert!(matches!(
            parse_app_domain("{}.example.com"),
            Err(ParseError::InvalidParamName(_))
        ));
        assert!(matches!(
            parse_app_domain("{1tenant}.example.com"),
            Err(ParseError::InvalidParamName(_))
        ));
        // Mid-host braces — disallowed.
        assert!(matches!(
            parse_app_domain("foo.{tenant}.example.com"),
            Err(ParseError::ReservedHostBraceSyntax)
        ));
    }

    #[test]
    fn leading_literal_count_works() {
        let exact = parse_path(PathKind::Exact, "/foo/users").unwrap();
        assert_eq!(exact.leading_literal_count(), usize::MAX);

        let prefix2 = parse_path(PathKind::Prefix, "/foo/users/*").unwrap();
        assert_eq!(prefix2.leading_literal_count(), 2);

        let prefix1 = parse_path(PathKind::Prefix, "/foo/*").unwrap();
        assert_eq!(prefix1.leading_literal_count(), 1);

        let param2 = parse_path(PathKind::Param, "/foo/users/:id").unwrap();
        assert_eq!(param2.leading_literal_count(), 2);

        let param1 = parse_path(PathKind::Param, "/foo/:section").unwrap();
        assert_eq!(param1.leading_literal_count(), 1);

        let param_then_lit = parse_path(PathKind::Param, "/foo/:s/list").unwrap();
        // Literal AFTER a param doesn't count for "leading literal".
        assert_eq!(param_then_lit.leading_literal_count(), 1);
    }
}
