//! HTMX endpoints for theme and language switching.
//!
//! These endpoints power the htmx-ssr profile's stateful UI controls. They set
//! cookies for persistence and return fragments wrapped in `hx-swap-oob` so a
//! single request can refresh multiple disconnected DOM regions.
//!
//! Routes mounted under `/api/htmx`:
//!
//! - `POST /theme/toggle` — flip between `light` and `dark`, persist via
//!   cookie, return the updated `<button>` plus an OOB block toggling the
//!   `dark` class on `<html>`.
//! - `POST /lang/{code}` — set the `lang` cookie, emit `HX-Redirect` to the
//!   localized canonical URL when one exists, otherwise return updated nav and
//!   footer fragments via OOB.

use axum::extract::Path;
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::routing::post;
use axum::Router;
use rustelo_core_lib::i18n::language_config::get_language_registry;

use crate::utils::htmx::htmx_error_response;

const THEME_COOKIE: &str = "theme";
// Must match the cookie name the framework's language detection reads
// (see `rustelo_core_lib::routing::utils` / `language` modules — they look
// for `preferred_language`). Using a different name would set a cookie
// nothing server-side ever reads, so the user's choice would be lost on
// every navigation.
const LANG_COOKIE: &str = "preferred_language";

/// Mount the htmx fragment endpoints onto an Axum router.
pub fn htmx_routes() -> Router {
    Router::new()
        .route("/theme/toggle", post(theme_toggle))
        .route("/lang/{code}", post(lang_switch))
}

/// `POST /theme/toggle` — flip between light and dark themes.
///
/// Returns the updated `<button>` (primary swap) and an inline `<script>`
/// that toggles the `dark` class on `<html>` and updates the
/// `data-theme` attribute. We avoid OOB-swapping `<html>` directly because
/// htmx 2.x replaces the element wholesale, which would wipe the page.
async fn theme_toggle(headers: HeaderMap) -> Response {
    let current = read_cookie(&headers, THEME_COOKIE).unwrap_or_else(|| "light".to_string());
    let next = if current == "dark" { "light" } else { "dark" };
    let next_owned = next.to_string();

    let button_html = render_theme_button(&next_owned);
    let body = format!("{button_html}{}", theme_normalize_script(&next_owned));

    let mut resp = (StatusCode::OK, body).into_response();
    set_cookie(resp.headers_mut(), THEME_COOKIE, &next_owned);
    resp
}

/// Inline `<script>` that reconciles `<html>` to `next` after a toggle swap.
///
/// Keeps the `dark`/`light` classes mutually exclusive — toggling only `dark`
/// left a stale `light` class behind — and sets the `data-theme` attribute.
/// That attribute is load-bearing: DaisyUI keys its base tokens on
/// `[data-theme]` (`:root,[data-theme]{color:hsl(var(--bc))}`), so without it
/// the light `--bc` never resolves and low-opacity text renders invisible.
/// We script the change rather than OOB-swap `<html>` because htmx 2.x
/// replaces the element wholesale, which would wipe the page.
fn theme_normalize_script(next: &str) -> String {
    format!(
        r#"<script>var e=document.documentElement;e.classList.toggle('dark',{is_dark});e.classList.toggle('light',{is_light});e.dataset.theme='{next}';</script>"#,
        is_dark = next == "dark",
        is_light = next == "light",
    )
}

/// `POST /lang/{code}` — switch active language and re-emit dependent
/// fragments.
async fn lang_switch(Path(code): Path<String>, headers: HeaderMap) -> Response {
    let registry = get_language_registry();
    if !registry.languages.contains_key(&code) {
        return htmx_error_response(
            &headers,
            StatusCode::BAD_REQUEST,
            format!("Unsupported language: {code}"),
        );
    }

    let mut resp = (StatusCode::OK, render_language_selector_fragment(&code)).into_response();
    set_cookie(resp.headers_mut(), LANG_COOKIE, &code);

    // Always force a full shell re-render so nav items and all language-sensitive
    // content update. Without a navigation, the fragment swap only touches
    // #lang-selector; everything else stays in the old language.
    //
    // - Distinct localized URL (e.g. /projects→/proyectos): HX-Redirect to the new
    //   URL — the shell then detects language from the path.
    // - Same URL in both languages (e.g. /blog): HX-Refresh so the current page
    //   reloads and the shell picks up the new preferred_language cookie.
    if let Some(current_url) = headers.get("hx-current-url").and_then(|v| v.to_str().ok()) {
        if let Some(redirect) = build_localized_redirect(current_url, &code) {
            if let Ok(value) = HeaderValue::from_str(&redirect) {
                resp.headers_mut().insert("HX-Redirect", value);
            }
        } else {
            resp.headers_mut()
                .insert("HX-Refresh", HeaderValue::from_static("true"));
        }
    }

    resp
}

/// Delegates to the same template used by `render_theme_toggle` so the
/// post-swap button is structurally identical to the initial render.
fn render_theme_button(theme: &str) -> String {
    use rustelo_components_htmx::navigation::{render_theme_toggle, Theme};
    let t = if theme == "dark" {
        Theme::Dark
    } else {
        Theme::Light
    };
    render_theme_toggle(t)
}

/// Delegates to the same template used by `render_lang_selector` so the
/// post-swap dropdown is structurally identical to the initial render.
fn render_language_selector_fragment(code: &str) -> String {
    rustelo_components_htmx::navigation::render_lang_selector(code)
}

fn build_localized_redirect(current_url: &str, code: &str) -> Option<String> {
    // Sites that use localized URL slugs (`/about` ↔ `/acerca-de`, not
    // `/about` ↔ `/es/about`) need to consult the route config, not blindly
    // prefix the language code. `get_localized_url` does both:
    //   1. resolves the canonical slug for the target language from the per-route
    //      i18n alias table;
    //   2. falls back to the `/{lang}/{path}` prefix scheme when no alias is
    //      configured.
    let parsed = url::Url::parse(current_url).ok()?;
    let current_path = parsed.path();
    let localized = crate::utils::lang_detection::get_localized_url(current_path, code);
    if localized == current_path {
        None
    } else {
        Some(localized)
    }
}

fn read_cookie(headers: &HeaderMap, name: &str) -> Option<String> {
    let raw = headers.get("cookie")?.to_str().ok()?;
    for pair in raw.split(';') {
        let pair = pair.trim();
        if let Some((k, v)) = pair.split_once('=') {
            if k.trim() == name {
                return Some(v.trim().to_string());
            }
        }
    }
    None
}

fn set_cookie(headers: &mut HeaderMap, name: &str, value: &str) {
    let cookie = format!("{name}={value}; Path=/; Max-Age=31536000; SameSite=Lax");
    if let Ok(value) = HeaderValue::from_str(&cookie) {
        headers.append(HeaderName::from_static("set-cookie"), value);
    }
}

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

    fn header_map(pairs: &[(&'static str, &str)]) -> HeaderMap {
        let mut map = HeaderMap::new();
        for (k, v) in pairs {
            let value =
                HeaderValue::from_str(v).expect("static test header value must be valid ASCII");
            map.insert(HeaderName::from_static(k), value);
        }
        map
    }

    #[test]
    fn read_cookie_extracts_value() {
        let h = header_map(&[("cookie", "theme=dark; lang=en")]);
        assert_eq!(read_cookie(&h, "theme"), Some("dark".to_string()));
        assert_eq!(read_cookie(&h, "lang"), Some("en".to_string()));
        assert_eq!(read_cookie(&h, "missing"), None);
    }

    #[test]
    fn set_cookie_appends_header() {
        let mut h = HeaderMap::new();
        set_cookie(&mut h, "theme", "dark");
        let raw = h
            .get("set-cookie")
            .expect("set_cookie must append a header")
            .to_str()
            .expect("cookie value must be ASCII");
        assert!(raw.starts_with("theme=dark"));
        assert!(raw.contains("Path=/"));
        assert!(raw.contains("SameSite=Lax"));
    }

    #[test]
    fn theme_button_html_carries_attrs() {
        let html = render_theme_button("dark");
        assert!(html.contains(r#"hx-post="/api/htmx/theme/toggle""#));
        assert!(html.contains(r#"hx-target="this""#));
        assert!(html.contains(r#"hx-swap="outerHTML""#));
        assert!(html.contains(r#"data-theme="dark""#));
    }

    #[test]
    fn theme_normalize_script_keeps_classes_mutually_exclusive() {
        let to_light = theme_normalize_script("light");
        assert!(to_light.contains("classList.toggle('dark',false)"));
        assert!(to_light.contains("classList.toggle('light',true)"));
        assert!(to_light.contains("dataset.theme='light'"));

        let to_dark = theme_normalize_script("dark");
        assert!(to_dark.contains("classList.toggle('dark',true)"));
        assert!(to_dark.contains("classList.toggle('light',false)"));
        assert!(to_dark.contains("dataset.theme='dark'"));
    }
}
