//! HTMX newsletter-subscription endpoint.
//!
//! Mounted at `POST /api/htmx/newsletter`. Accepts the subscribe modal's form
//! and relays it to the configured provider (listmonk) SERVER-SIDE, returning
//! an HTML fragment the modal swaps in.
//!
//! Why a relay at all — the form used to POST natively, cross-origin, straight
//! to listmonk. That works, and it takes the reader with it: the browser
//! navigates away and lands on listmonk's own confirmation page. The subscriber
//! leaves the site to be told "thank you" by a stranger, and any failure is
//! likewise listmonk's page, in listmonk's language.
//!
//! The obvious client-side fix — `fetch()` from the modal — cannot work: the
//! response is opaque under CORS, so the page could not tell success from
//! failure and would have to *claim* success. Relaying through the server is
//! what makes the outcome KNOWABLE, and only then can the modal say
//! something true.
//!
//! The endpoint/list are never hardcoded: they come from the running site's own
//! `newsletter.ncl`, the same file the template reads.

#[cfg(feature = "email")]
mod imp {
    use axum::extract::Form;
    use axum::response::{Html, IntoResponse, Response};
    use axum::routing::post;
    use axum::Router;
    use serde::Deserialize;

    pub fn htmx_newsletter_routes() -> Router {
        Router::new().route("/", post(subscribe))
    }

    #[derive(Debug, Deserialize)]
    struct SubscribeForm {
        email: String,
        #[serde(default)]
        name: String,
        /// listmonk's honeypot. Any value means a bot filled a field no person
        /// can see.
        #[serde(default)]
        nonce: String,
        #[serde(default)]
        lang: String,
    }

    /// The provider endpoint and target list, read from the site's own
    /// `newsletter.ncl` — the same file the template reads, so the endpoint
    /// and the form cannot disagree about where a subscriber goes.
    /// (`rustelo_utils` is not a dependency here; core_lib re-exports its path
    /// resolver, which is the same function the htmx env uses.)
    fn newsletter_config() -> Option<(String, String)> {
        use rustelo_core_lib::routing::get_config_path_buf;
        let path = get_config_path_buf().join("newsletter.ncl");
        let json = rustelo_config::format::load_config(&path).ok()?;
        let nl = json.get("newsletter")?;
        let action = nl.get("action")?.as_str()?.to_string();
        let list = nl.get("list_uuid")?.as_str()?.to_string();
        if action.is_empty() || list.is_empty() {
            return None;
        }
        Some((action, list))
    }

    fn is_es(lang: &str) -> bool {
        lang.starts_with("es")
    }

    /// Percent-encode one application/x-www-form-urlencoded component.
    pub(super) fn percent_encode(s: &str) -> String {
        let mut out = String::with_capacity(s.len());
        for b in s.bytes() {
            match b {
                b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
                    out.push(b as char)
                }
                b' ' => out.push('+'),
                _ => out.push_str(&format!("%{b:02X}")),
            }
        }
        out
    }

    /// The fragment the modal swaps in. It carries its own close button because
    /// the modal's normal close lives on the form we are replacing — swap
    /// that away without one and the reader is left in a dialog with no way
    /// out but the Escape key.
    fn fragment(ok: bool, lang: &str) -> Html<String> {
        let (cls, msg) = match (ok, is_es(lang)) {
            (true, true) => (
                "nl-modal__result--ok",
                "¡Gracias! Revisa tu correo para confirmar la suscripción.",
            ),
            (true, false) => (
                "nl-modal__result--ok",
                "Thank you! Check your inbox to confirm the subscription.",
            ),
            (false, true) => (
                "nl-modal__result--err",
                "No hemos podido completar la suscripción. Inténtalo de nuevo en unos minutos.",
            ),
            (false, false) => (
                "nl-modal__result--err",
                "We could not complete the subscription. Please try again in a few minutes.",
            ),
        };
        let close = if is_es(lang) { "Cerrar" } else { "Close" };
        Html(format!(
            r#"<div class="nl-modal__card nl-modal__result {cls}" role="status">
  <p class="nl-modal__result-msg">{msg}</p>
  <button type="button" class="nl-cta__btn nl-cta__btn--primary" data-nl-close>{close}</button>
</div>"#
        ))
    }

    async fn subscribe(Form(form): Form<SubscribeForm>) -> Response {
        // Honeypot: confirm WITHOUT relaying, so the bot learns nothing from the
        // difference.
        if !form.nonce.is_empty() {
            return fragment(true, &form.lang).into_response();
        }

        let Some((action, list)) = newsletter_config() else {
            tracing::warn!(
                "newsletter: no action/list_uuid in newsletter.ncl — subscription refused"
            );
            return fragment(false, &form.lang).into_response();
        };

        // listmonk's public form contract: email, name, list uuid `l`, and `nonce` left
        // empty.
        //
        // The body is encoded here rather than with reqwest's `.form()` helper, which
        // this workspace's feature set does not enable. Percent-encoding is not
        // optional: an address like `a+b@x.com` would otherwise arrive as `a
        // b@x.com` and the subscription would fail for exactly the people whose
        // address contains a `+`.
        let body = [
            ("email", form.email.as_str()),
            ("name", form.name.as_str()),
            ("l", list.as_str()),
            ("nonce", ""),
        ]
        .iter()
        .map(|(k, v)| format!("{}={}", percent_encode(k), percent_encode(v)))
        .collect::<Vec<_>>()
        .join("&");

        let client = match reqwest::Client::builder()
            .timeout(std::time::Duration::from_secs(10))
            .build()
        {
            Ok(c) => c,
            Err(e) => {
                tracing::error!("newsletter: http client: {e}");
                return fragment(false, &form.lang).into_response();
            }
        };

        match client
            .post(&action)
            .header("Content-Type", "application/x-www-form-urlencoded")
            .body(body)
            .send()
            .await
        {
            // listmonk answers the public form with a redirect to its own confirmation page, so a
            // 2xx AND a 3xx both mean "accepted". Treating the redirect as failure would have told
            // every successful subscriber that they had failed.
            Ok(resp) if resp.status().is_success() || resp.status().is_redirection() => {
                fragment(true, &form.lang).into_response()
            }
            Ok(resp) => {
                tracing::warn!(
                    "newsletter: provider refused the subscription: {}",
                    resp.status()
                );
                fragment(false, &form.lang).into_response()
            }
            Err(e) => {
                tracing::error!("newsletter: relay failed: {e}");
                fragment(false, &form.lang).into_response()
            }
        }
    }
}

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

#[cfg(all(test, feature = "email"))]
mod tests {
    use super::imp::*;

    #[test]
    fn plus_in_an_address_survives_encoding() {
        // The classic silent failure: `a+tag@x.com` posted raw arrives as `a
        // tag@x.com`, and the subscription fails for precisely the people who
        // tag their addresses.
        assert_eq!(percent_encode("a+tag@x.com"), "a%2Btag%40x.com");
    }

    #[test]
    fn spaces_and_accents_are_encoded() {
        assert_eq!(percent_encode("Jesús Pérez"), "Jes%C3%BAs+P%C3%A9rez");
    }

    #[test]
    fn unreserved_characters_are_left_alone() {
        assert_eq!(percent_encode("simple-name_1.0~x"), "simple-name_1.0~x");
    }
}
