//! Declarative form endpoint — `POST /api/htmx/form/{id}`.
//!
//! ONE handler for every form on the site. The form is DECLARED, in NCL, by the
//! consumer (`site/config/forms/<id>.ncl`, contract `forms/contracts.ncl`), and
//! a new form is a `.ncl` plus a template: no Rust, no rebuild of the shared
//! image.
//!
//! WHY. Until now a form meant a typed struct, a handler, a `nest(...)` and a
//! recompile. It was the last part of a page that was not the consumer's —
//! templates, Fluent text, content and (since `merge_site_routes`) routes
//! already were. And the cost was not theoretical:
//!
//!   · `work-request` — a public "request a quote" form with eleven fields —
//! has a template and NO     handler. `POST /api/work-request` is registered
//! nowhere in the stack. Every submission has     gone to a route that does not
//! exist, for as long as the page has been up, because a form     that RENDERS
//! looks exactly like a form that WORKS.   · `contact.j2` posted to
//! `/api/contact`, which answers 403 — the handler had moved to     `/api/htmx/
//! contact` and the template never followed. Its own doc comment says so.
//!
//! Both failures are the same one: an endpoint declared in one place and
//! consumed in another, with nothing checking they agree. Here there is one
//! endpoint and the form's own declaration is the contract, so there is nothing
//! left to drift.
//!
//! THE FIELD DISCIPLINE, AND IT IS THE POINT. The handler takes a
//! `HashMap<String,String>` — no per-form struct. That is what removes the
//! build. What keeps it honest is the spec:
//!
//!   · a posted field the spec does not declare is REFUSED (400), not dropped.
//! `ContactForm` used     serde's default, which silently ignores unknown
//! fields — so routing work-request through it     would have returned 200,
//! said "sent", and delivered an email with the name and email and     NOTHING
//! ELSE: no description, no budget, no timeline. A form that appears to work
//! and throws     the data away is worse than one that visibly fails, and that
//! is the trap this exists to close.   · every field marked `deliver` appears
//! in the body, in declaration order. What the form     collects is what the
//! owner receives.

#[cfg(feature = "email")]
mod imp {
    use std::collections::HashMap;

    use axum::extract::{Form, Path};
    use axum::http::StatusCode;
    use axum::response::{Html, IntoResponse, Response};
    use axum::routing::post;
    use axum::{Extension, Router};

    use crate::app_state::AppState;
    use crate::security::rate_limit::RateLimitResult;

    pub fn htmx_form_routes() -> Router {
        Router::new().route("/{id}", post(submit))
    }

    // ── the spec, as the NCL declares it ─────────────────────────────────────
    #[derive(Debug, Clone, serde::Deserialize)]
    struct FieldSpec {
        name: String,
        #[serde(default)]
        kind: String,
        //#[serde(default)]
        ////required: bool,
        #[serde(default = "yes")]
        deliver: bool,
    }
    fn yes() -> bool {
        true
    }

    #[derive(Debug, Clone, serde::Deserialize)]
    struct FormSpec {
        id: String,
        deliver_to: String,
        #[serde(default)]
        subject: String,
        fields: Vec<FieldSpec>,
        #[serde(default)]
        required: Vec<String>,
    }

    /// Read `site/config/forms/<id>.ncl` at request time.
    ///
    /// `.ncl` at runtime is not new here: the htmx shell already loads
    /// `routes.ncl` and `footer.ncl` through this same `load_config`, which
    /// dispatches by extension and shells out to `nickel`. The build script
    /// says as much — "the htmx shell reads routes.ncl / footer.ncl
    /// directly, no baked data, no rebuild needed when config changes". A form
    /// is one more of those.
    ///
    /// The id is taken from the URL, so it is treated as hostile: anything that
    /// is not `[a-z0-9-]` is refused before it can become a path.
    fn load_spec(id: &str) -> Option<FormSpec> {
        if id.is_empty()
            || !id
                .chars()
                .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
        {
            tracing::warn!("form: refusing id '{id}' — not [a-z0-9-]");
            return None;
        }
        // SITE_CONFIG_PATH may name a FILE (`site/config/index.ncl`) rather than the
        // directory — the build-side loader already handles that case, and this
        // one did not: it joined `forms/` onto a file path and asked nickel to
        // read `.../index.ncl/forms/work-request.ncl`. The error was an "IO
        // error" with no hint, which is why the log line below prints the path it
        // actually tried.
        let base = std::env::var("SITE_CONFIG_PATH").unwrap_or_else(|_| "site/config".into());
        let base = {
            let p = std::path::PathBuf::from(&base);
            if p.extension().is_some() {
                p.parent().map(|x| x.to_path_buf()).unwrap_or(p)
            } else {
                p
            }
        };
        let path = base.join("forms").join(format!("{id}.ncl"));
        match rustelo_config::format::load_config(&path) {
            Ok(v) => match serde_json::from_value::<FormSpec>(v) {
                Ok(s) => Some(s),
                Err(e) => {
                    tracing::error!("form '{id}': spec does not deserialize: {e}");
                    None
                }
            },
            Err(e) => {
                tracing::warn!("form '{id}': {} not loadable: {e}", path.display());
                None
            }
        }
    }

    async fn submit(
        Path(id): Path<String>,
        Extension(state): Extension<AppState>,
        Form(posted): Form<HashMap<String, String>>,
    ) -> Response {
        let lang = normalize_lang(posted.get("lang").map(String::as_str));

        let spec = match load_spec(&id) {
            Some(s) => s,
            None => {
                return (
                    StatusCode::NOT_FOUND,
                    Html(error_fragment(t(
                        &lang,
                        "This form is not declared.",
                        "Este formulario no está declarado.",
                    ))),
                )
                    .into_response()
            }
        };

        // A field the template posts and the spec does not declare is REFUSED. Dropping
        // it silently is precisely the defect that made this handler necessary,
        // and a 400 that names the field is how the author finds out in one
        // request instead of one quarter.
        let declared: Vec<&str> = spec.fields.iter().map(|f| f.name.as_str()).collect();
        let stray: Vec<&String> = posted
            .keys()
            .filter(|k| k.as_str() != "lang" && !declared.contains(&k.as_str()))
            .collect();
        if !stray.is_empty() {
            tracing::error!(
                "form '{}': posted fields not declared in the spec: {:?}",
                spec.id,
                stray
            );
            return (
                StatusCode::BAD_REQUEST,
                Html(error_fragment(t(
                    &lang,
                    "This form posted a field it does not declare.",
                    "Este formulario ha enviado un campo que no declara.",
                ))),
            )
                .into_response();
        }

        // Honeypot: any 'Honeypot field with a value is a bot. Confirm WITHOUT
        // delivering, so the bot learns nothing.
        let honeypot_tripped = spec
            .fields
            .iter()
            .filter(|f| f.kind == "Honeypot")
            .any(|f| {
                !posted
                    .get(&f.name)
                    .map(|v| v.trim())
                    .unwrap_or("")
                    .is_empty()
            });
        if honeypot_tripped {
            return (StatusCode::OK, Html(thanks_fragment(&lang))).into_response();
        }

        // Required fields, checked BEFORE delivery.
        let missing: Vec<&String> = spec
            .required
            .iter()
            .filter(|r| posted.get(*r).map(|v| v.trim()).unwrap_or("").is_empty())
            .collect();
        if !missing.is_empty() {
            return (
                StatusCode::BAD_REQUEST,
                Html(error_fragment(t(
                    &lang,
                    "Please fill in the required fields.",
                    "Rellena los campos obligatorios.",
                ))),
            )
                .into_response();
        }

        // Rate limit on the submitter's email when the form has one, else on the form
        // id. Bots that vary the address still get past this; the global
        // limiter caps total volume.
        let ident = match posted.get("email") {
            Some(e) if !e.trim().is_empty() => {
                format!("form:{}:{}", spec.id, e.trim().to_lowercase())
            }
            _ => format!("form:{}", spec.id),
        };
        if matches!(
            state.rate_limiter.is_allowed(&ident),
            RateLimitResult::RateLimited { .. }
        ) {
            return (
                StatusCode::TOO_MANY_REQUESTS,
                Html(error_fragment(t(
                    &lang,
                    "Too many requests — please try again later.",
                    "Demasiadas peticiones — inténtalo más tarde.",
                ))),
            )
                .into_response();
        }

        // ── Compose. EVERY delivered field, in declaration order. ────────────
        // This is the whole contract: what the form collects is what the owner
        // receives. Composing from the SPEC and not from the POST means a field
        // cannot go missing because the template forgot it — it appears, empty,
        // and the gap is visible.
        let site = std::env::var("SITE_NAME").unwrap_or_else(|_| "site".to_string());
        let subject = {
            let raw = interpolate(&spec.subject, &posted);
            let raw = raw.trim();
            if raw.is_empty() {
                format!("[{site}] {}", spec.id)
            } else {
                format!("[{site}] {raw}")
            }
        };

        let mut body = String::new();
        for f in spec.fields.iter().filter(|f| f.deliver) {
            let v = posted.get(&f.name).map(|s| s.trim()).unwrap_or("");
            body.push_str(&format!(
                "{}: {}\n",
                f.name,
                if v.is_empty() { "—" } else { v }
            ));
        }

        let recipient = std::env::var(&spec.deliver_to)
            .ok()
            .filter(|s| !s.trim().is_empty());
        match recipient {
            Some(to) => {
                if let Err(e) = state
                    .email_service
                    .send_simple_email_with_language(&to, &subject, &body, &lang)
                    .await
                {
                    tracing::warn!("form '{}': delivery failed: {e}", spec.id);
                }
            }
            None => {
                // The visitor still gets a confirmation. A form that 500s because the OWNER did
                // not set an env var punishes the wrong person — and loses the
                // lead twice.
                tracing::info!(
                    "form '{}' submitted; set {} to deliver it:\n{}",
                    spec.id,
                    spec.deliver_to,
                    body
                );
            }
        }

        (StatusCode::OK, Html(thanks_fragment(&lang))).into_response()
    }

    /// `{field}` → the submitted value. An unknown field interpolates to
    /// nothing rather than failing: a subject line is not worth losing a
    /// submission over.
    fn interpolate(tpl: &str, posted: &HashMap<String, String>) -> String {
        let mut out = String::with_capacity(tpl.len());
        let mut rest = tpl;
        while let Some(open) = rest.find('{') {
            out.push_str(&rest[..open]);
            match rest[open + 1..].find('}') {
                Some(close) => {
                    let key = &rest[open + 1..open + 1 + close];
                    out.push_str(posted.get(key).map(|s| s.trim()).unwrap_or(""));
                    rest = &rest[open + 1 + close + 1..];
                }
                None => {
                    out.push_str(&rest[open..]);
                    rest = "";
                }
            }
        }
        out.push_str(rest);
        out
    }

    fn normalize_lang(v: Option<&str>) -> String {
        match v.map(str::trim).unwrap_or("") {
            "es" => "es".into(),
            _ => "en".into(),
        }
    }

    fn t(lang: &str, en: &str, es: &str) -> String {
        if lang == "es" {
            es.to_string()
        } else {
            en.to_string()
        }
    }

    fn thanks_fragment(lang: &str) -> String {
        format!(
            r#"<div class="ds-callout ds-callout-success" role="status">{}</div>"#,
            t(
                lang,
                "Thanks — your message is on its way.",
                "Gracias — tu mensaje va en camino."
            )
        )
    }

    fn error_fragment(msg: String) -> String {
        format!(r#"<div class="ds-callout ds-callout-error" role="alert">{msg}</div>"#)
    }
}

#[cfg(feature = "email")]
pub use imp::htmx_form_routes;

#[cfg(not(feature = "email"))]
pub fn htmx_form_routes() -> axum::Router {
    axum::Router::new()
}
