//! Boundary validators for client-supplied strings.
//!
//! Every function here checks the *shape* of a string that crossed the
//! HTTP boundary, before any handler logic touches it. Pure functions —
//! no state, no side effects, no I/O. Each returns
//! `Result<(), &'static str>`; the caller decides whether a failure
//! becomes a 400, a sanitized "wrong credentials" page, or a redirect,
//! so info-leak-sensitive endpoints (login, magic-link) can fold the
//! rejection into their existing error path instead of leaking which
//! field was malformed.
//!
//! Design notes:
//!
//! - **Why a central module instead of per-handler helpers?** When a
//!   client format drifts (cf. the Wear OS microsecond bug), the
//!   regression test belongs next to the validator, not in whichever
//!   route happened to feed it. Centralising lets the test surface
//!   speak in one voice.
//! - **What is *not* here**: structural validation (missing fields,
//!   wrong types) is already done by axum's `Json<T>` / `Form<T>` /
//!   `Query<T>` extractors via Serde. We only check semantics atop
//!   that — does this `String` *look* like a timestamp / slug / token?
//! - **What also is *not* here**: validators that already live next to
//!   their handler and have tests there (`case_id::CaseIdPath`,
//!   `routes::web::validate_filename`, `routes::magic::is_safe_return_to`).
//!   Moving them would be churn without payoff.

use crate::error::AppError;

/// Strict UTC RFC3339 with second granularity, no subsecond fraction:
/// `YYYY-MM-DDTHH:MM:SSZ` (exactly 20 bytes). Mirrors the contract that
/// `doctate_common::timestamp::now_rfc3339` emits and that the filename
/// parser in `routes::user_web::utc_date_and_iso_of` requires.
///
/// A drift here (e.g. a client emitting `Instant.now().toString()` with
/// microseconds) would silently corrupt filenames and the day-grouping
/// would fall back to `today`, producing the duplicate "Heute" header
/// we hunted on 2026-04-27. Catching it at the upload boundary turns a
/// week-late UI symptom into an immediate 400.
pub fn validate_recorded_at_strict(s: &str) -> Result<(), &'static str> {
    if s.len() != 20 {
        return Err("expected 20 chars `YYYY-MM-DDTHH:MM:SSZ`");
    }
    let bytes = s.as_bytes();
    if bytes[19] != b'Z' {
        return Err("must end with `Z` (UTC)");
    }
    if bytes[10] != b'T' {
        return Err("missing `T` separator at index 10");
    }
    let digit_at = |i: usize| bytes[i].is_ascii_digit();
    let dash_at = |i: usize| bytes[i] == b'-';
    let colon_at = |i: usize| bytes[i] == b':';
    // YYYY-MM-DDTHH:MM:SS
    // 0123456789012345678
    let shape_ok = digit_at(0)
        && digit_at(1)
        && digit_at(2)
        && digit_at(3)
        && dash_at(4)
        && digit_at(5)
        && digit_at(6)
        && dash_at(7)
        && digit_at(8)
        && digit_at(9)
        && digit_at(11)
        && digit_at(12)
        && colon_at(13)
        && digit_at(14)
        && digit_at(15)
        && colon_at(16)
        && digit_at(17)
        && digit_at(18);
    if !shape_ok {
        return Err("malformed digits/separators");
    }
    Ok(())
}

/// Convenience wrapper: bubble a malformed `recorded_at` to the standard
/// 400 response. Use from handlers that want field-named errors in the
/// body (`/api/upload`); for info-leak-sensitive endpoints, call
/// [`validate_recorded_at_strict`] directly and route to a sanitized
/// error path.
pub fn require_recorded_at_strict(s: &str) -> Result<(), AppError> {
    validate_recorded_at_strict(s)
        .map_err(|why| AppError::BadRequest(format!("Invalid recorded_at: {why}")))
}

/// Login slug shape: lowercase alphanumerics plus `_`/`-`, 1–32 chars.
/// The slug becomes a directory name (`<data_path>/<slug>/`), so a
/// shape-check is defense-in-depth on top of the existing
/// configured-users lookup. `^[a-z0-9_-]{1,32}$` is deliberately tight:
/// a sysadmin can pick any slug they want at config time within this
/// alphabet, and an attacker cannot smuggle path separators or unicode
/// homographs through the form.
pub fn validate_slug(s: &str) -> Result<(), &'static str> {
    if s.is_empty() {
        return Err("empty");
    }
    if s.len() > 32 {
        return Err("too long (max 32)");
    }
    if !s
        .bytes()
        .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_' || b == b'-')
    {
        return Err("only [a-z0-9_-] allowed");
    }
    Ok(())
}

/// Opaque session/magic-link token shape: 16–256 bytes of URL-safe
/// base64 alphabet (`A–Z`, `a–z`, `0–9`, `_`, `-`). Loose enough to
/// accommodate any reasonable token generator (we use
/// [`crate::web_session::generate_token`]) while bounding the length so
/// a malicious client cannot ship a megabyte-long "token" and force the
/// server to allocate it before the lookup misses.
pub fn validate_token(s: &str) -> Result<(), &'static str> {
    if s.len() < 16 {
        return Err("too short (min 16)");
    }
    if s.len() > 256 {
        return Err("too long (max 256)");
    }
    if !s
        .bytes()
        .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
    {
        return Err("only [A-Za-z0-9_-] allowed");
    }
    Ok(())
}

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

    #[test]
    fn recorded_at_accepts_strict_utc() {
        assert!(validate_recorded_at_strict("2026-04-15T10:32:00Z").is_ok());
        assert!(validate_recorded_at_strict("1999-12-31T23:59:59Z").is_ok());
    }

    #[test]
    fn recorded_at_rejects_microseconds() {
        // The exact shape Wear OS was emitting before the 2026-04-27 fix.
        assert!(validate_recorded_at_strict("2026-04-27T10:34:12.987072Z").is_err());
    }

    #[test]
    fn recorded_at_rejects_milliseconds() {
        assert!(validate_recorded_at_strict("2026-04-15T10:32:00.123Z").is_err());
    }

    #[test]
    fn recorded_at_rejects_offset_form() {
        // RFC3339 allows offsets like `+00:00`, but our filename
        // pipeline only handles the `Z` form.
        assert!(validate_recorded_at_strict("2026-04-15T10:32:00+00:00").is_err());
    }

    #[test]
    fn recorded_at_rejects_short_and_long() {
        assert!(validate_recorded_at_strict("").is_err());
        assert!(validate_recorded_at_strict("2026-04-15T10:32:00").is_err());
        assert!(validate_recorded_at_strict("2026-04-15T10:32:00Z ").is_err());
    }

    #[test]
    fn recorded_at_rejects_letters_in_digit_slots() {
        assert!(validate_recorded_at_strict("YYYY-04-15T10:32:00Z").is_err());
        assert!(validate_recorded_at_strict("2026-04-15T10:32:0aZ").is_err());
    }

    #[test]
    fn recorded_at_rejects_path_traversal_attempt() {
        // 20-byte length cap plus the digit-slot check make smuggling
        // path bytes into the filename impossible.
        assert!(validate_recorded_at_strict("../../../etc/passwdXX").is_err());
    }

    #[test]
    fn slug_accepts_canonical_shapes() {
        assert!(validate_slug("dr_mueller").is_ok());
        assert!(validate_slug("a").is_ok());
        assert!(validate_slug("abc-123_xyz").is_ok());
    }

    #[test]
    fn slug_rejects_traversal_and_unicode() {
        assert!(validate_slug("").is_err());
        assert!(validate_slug("../etc").is_err());
        assert!(validate_slug("dr/mueller").is_err());
        assert!(validate_slug("Dr_Mueller").is_err()); // uppercase
        assert!(validate_slug("dr.mueller").is_err()); // dot
        assert!(validate_slug("möller").is_err()); // non-ASCII
    }

    #[test]
    fn slug_rejects_too_long() {
        let s33 = "a".repeat(33);
        assert!(validate_slug(&s33).is_err());
    }

    #[test]
    fn token_accepts_base64url_lengths() {
        let t = "A".repeat(16);
        assert!(validate_token(&t).is_ok());
        let t = "A".repeat(256);
        assert!(validate_token(&t).is_ok());
        // Mixed alphabet — what `generate_token` would produce.
        assert!(validate_token("aB3-_xyz_AB3-_xyzpqrXYZ").is_ok());
    }

    #[test]
    fn token_rejects_too_short_and_too_long() {
        let t = "A".repeat(15);
        assert!(validate_token(&t).is_err());
        let t = "A".repeat(257);
        assert!(validate_token(&t).is_err());
    }

    #[test]
    fn token_rejects_non_url_safe_bytes() {
        assert!(validate_token("AAAAAAAAAAAAAAAAA+/=").is_err());
        assert!(validate_token("AAAAAAAAAAAAAAAA?=&").is_err());
        assert!(validate_token("AAAAAAAAAAAAAAAA\nBB").is_err());
    }
}
