//! Per-field form validation endpoints.
//!
//! Each form field carries `hx-trigger="blur"` and posts its value to
//! `/api/htmx/validate/{form}/{field}`. The server returns the field
//! container fragment (`.ds-field`) with the inline error span filled
//! when invalid, or empty/clear when valid. Pair with `idiomorph` swaps
//! on the parent form when you want focus preserved across the swap.
//!
//! The validator surface is intentionally small: every form/field
//! combination is registered explicitly here. Adding a new form means
//! adding a route entry and a validation function — no reflection, no
//! macro magic, easy to audit.

use axum::extract::Path;
use axum::http::StatusCode;
use axum::response::{Html, IntoResponse, Response};
use axum::routing::post;
use axum::{Form, Router};
use html_escape::{encode_double_quoted_attribute, encode_text};
use serde::Deserialize;

/// Mount the per-field validators.
pub fn htmx_validate_routes() -> Router {
    Router::new().route("/{form}/{field}", post(validate_field))
}

#[derive(Debug, Deserialize)]
struct AnyField {
    #[serde(default)]
    email: Option<String>,
    #[serde(default)]
    password: Option<String>,
    #[serde(default)]
    name: Option<String>,
    #[serde(default)]
    message: Option<String>,
    #[serde(default)]
    url: Option<String>,
    #[serde(default)]
    code: Option<String>,
}

async fn validate_field(
    Path((form, field)): Path<(String, String)>,
    Form(values): Form<AnyField>,
) -> Response {
    let value = match field.as_str() {
        "email" => values.email.unwrap_or_default(),
        "password" => values.password.unwrap_or_default(),
        "name" => values.name.unwrap_or_default(),
        "message" => values.message.unwrap_or_default(),
        "url" => values.url.unwrap_or_default(),
        "code" => values.code.unwrap_or_default(),
        _ => String::new(),
    };

    let validator = pick_validator(form.as_str(), field.as_str());
    let outcome = validator(&value);
    let html = render_field(&form, &field, &value, outcome);
    (StatusCode::OK, Html(html)).into_response()
}

#[derive(Debug, Clone)]
pub(crate) enum Outcome {
    Ok,
    Error(&'static str),
}

type Validator = fn(&str) -> Outcome;

fn pick_validator(form: &str, field: &str) -> Validator {
    match (form, field) {
        (_, "email") => validate_email,
        (_, "password") => validate_password,
        ("contact", "name") => validate_required_short,
        ("contact", "message") => validate_message,
        ("work-request", "name") => validate_required_short,
        ("work-request", "message") => validate_message,
        (_, "url") => validate_url_optional,
        (_, "code") => validate_otp_code,
        _ => validate_always_ok,
    }
}

fn validate_email(v: &str) -> Outcome {
    if v.is_empty() {
        return Outcome::Error("Email is required");
    }
    // Minimal RFC-light check: presence of @ with at least one char each side
    // and a dot in the domain part.
    if let Some(at) = v.find('@') {
        let (local, domain) = v.split_at(at);
        let domain = &domain[1..];
        if !local.is_empty()
            && domain.contains('.')
            && !domain.starts_with('.')
            && !domain.ends_with('.')
        {
            return Outcome::Ok;
        }
    }
    Outcome::Error("Enter a valid email address")
}

fn validate_password(v: &str) -> Outcome {
    if v.len() < 8 {
        return Outcome::Error("Password must be at least 8 characters");
    }
    if !v.chars().any(|c| c.is_ascii_digit()) {
        return Outcome::Error("Password must contain at least one digit");
    }
    if !v.chars().any(|c| c.is_ascii_alphabetic()) {
        return Outcome::Error("Password must contain at least one letter");
    }
    Outcome::Ok
}

fn validate_required_short(v: &str) -> Outcome {
    let trimmed = v.trim();
    if trimmed.is_empty() {
        Outcome::Error("This field is required")
    } else if trimmed.chars().count() > 100 {
        Outcome::Error("Keep it under 100 characters")
    } else {
        Outcome::Ok
    }
}

fn validate_message(v: &str) -> Outcome {
    let trimmed = v.trim();
    let count = trimmed.chars().count();
    if count < 10 {
        Outcome::Error("Message must be at least 10 characters")
    } else if count > 5000 {
        Outcome::Error("Message exceeds 5000 characters")
    } else {
        Outcome::Ok
    }
}

fn validate_url_optional(v: &str) -> Outcome {
    if v.trim().is_empty() {
        return Outcome::Ok;
    }
    if v.starts_with("http://") || v.starts_with("https://") {
        Outcome::Ok
    } else {
        Outcome::Error("URL must start with http:// or https://")
    }
}

fn validate_otp_code(v: &str) -> Outcome {
    if v.len() == 6 && v.chars().all(|c| c.is_ascii_digit()) {
        Outcome::Ok
    } else {
        Outcome::Error("Enter the 6-digit code")
    }
}

fn validate_always_ok(_: &str) -> Outcome {
    Outcome::Ok
}

fn render_field(form: &str, field: &str, value: &str, outcome: Outcome) -> String {
    let id = format!("field-{form}-{field}");
    let endpoint = format!("/api/htmx/validate/{form}/{field}");
    let error = match outcome {
        Outcome::Ok => String::new(),
        Outcome::Error(msg) => format!(
            r#"<span class="ds-field-error" role="alert">{}</span>"#,
            encode_text(msg)
        ),
    };
    let kind = input_type_for(field);
    let value_attr = encode_double_quoted_attribute(value);
    // Re-emit the whole `.ds-field` so the client can swap with hx-swap=outerHTML
    // on the field wrapper.
    format!(
        r##"<div class="ds-field"><label for="{id}">{label}</label><input type="{kind}" id="{id}" name="{field}" value="{value_attr}" hx-post="{endpoint}" hx-trigger="blur" hx-target="closest .ds-field" hx-swap="outerHTML">{error}</div>"##,
        id = encode_double_quoted_attribute(&id),
        label = encode_text(&humanise_label(field)),
        kind = kind,
        field = encode_double_quoted_attribute(field),
        endpoint = encode_double_quoted_attribute(&endpoint),
    )
}

fn input_type_for(field: &str) -> &'static str {
    match field {
        "email" => "email",
        "password" => "password",
        "url" => "url",
        "code" => "text",
        _ => "text",
    }
}

fn humanise_label(field: &str) -> String {
    let mut chars = field.chars();
    let first = chars
        .next()
        .map(|c| c.to_ascii_uppercase().to_string())
        .unwrap_or_default();
    format!(
        "{first}{}",
        chars.as_str().replace('-', " ").replace('_', " ")
    )
}

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

    #[test]
    fn email_validator_accepts_well_formed() {
        assert!(matches!(validate_email("a@b.co"), Outcome::Ok));
        assert!(matches!(
            validate_email("first.last+tag@sub.example.com"),
            Outcome::Ok
        ));
    }

    #[test]
    fn email_validator_rejects_garbage() {
        assert!(matches!(validate_email(""), Outcome::Error(_)));
        assert!(matches!(validate_email("plain"), Outcome::Error(_)));
        assert!(matches!(validate_email("no@dot"), Outcome::Error(_)));
        assert!(matches!(validate_email("@example.com"), Outcome::Error(_)));
        assert!(matches!(validate_email("a@.com"), Outcome::Error(_)));
    }

    #[test]
    fn password_validator_enforces_complexity() {
        assert!(matches!(validate_password("short1a"), Outcome::Error(_)));
        assert!(matches!(validate_password("12345678"), Outcome::Error(_)));
        assert!(matches!(validate_password("abcdefgh"), Outcome::Error(_)));
        assert!(matches!(validate_password("password1"), Outcome::Ok));
    }

    #[test]
    fn otp_code_validator_requires_six_digits() {
        assert!(matches!(validate_otp_code("123456"), Outcome::Ok));
        assert!(matches!(validate_otp_code("12345"), Outcome::Error(_)));
        assert!(matches!(validate_otp_code("12345a"), Outcome::Error(_)));
    }

    #[test]
    fn message_validator_bounds() {
        assert!(matches!(validate_message("short"), Outcome::Error(_)));
        assert!(matches!(validate_message(&"x".repeat(20)), Outcome::Ok));
        assert!(matches!(
            validate_message(&"x".repeat(5001)),
            Outcome::Error(_)
        ));
    }

    #[test]
    fn render_field_includes_error_when_invalid() {
        let html = render_field("contact", "email", "bad", Outcome::Error("Bad"));
        assert!(html.contains("Bad"));
        assert!(html.contains(r#"role="alert""#));
    }

    #[test]
    fn render_field_omits_error_when_ok() {
        let html = render_field("contact", "email", "ok@x.co", Outcome::Ok);
        assert!(!html.contains(r#"role="alert""#));
    }

    #[test]
    fn render_field_carries_endpoint_and_blur_trigger() {
        let html = render_field("contact", "email", "", Outcome::Ok);
        assert!(html.contains(r#"hx-post="/api/htmx/validate/contact/email""#));
        assert!(html.contains(r#"hx-trigger="blur""#));
    }
}
