//! HTMX-SSR shell. Emits a full document with `hx-boost` body, vendored htmx
//! runtime + declared extensions, and the HTMX peer component tree from
//! `rustelo_components_htmx`. No Leptos reactive constructs (no signals, no
//! `on:click`), so the page works without WASM.

use rustelo_server::run::SiteOptions;

use super::{common, seo};
use html_escape::encode_double_quoted_attribute;
use std::collections::HashMap;
use std::sync::OnceLock;

// ── Runtime menu/footer loaded from site/config/routes.ncl at startup ─────────
// Keyed by language. Populated once via load_nav_from_routes(); no rebuild needed
// when routes.ncl changes — only a server restart.

#[derive(Debug, Clone)]
struct MenuItem {
    label: String,
    path:  String,
    order: i64,
}

static NAV_ITEMS: OnceLock<HashMap<String, Vec<MenuItem>>>    = OnceLock::new();
static FOOTER_ITEMS: OnceLock<HashMap<String, Vec<MenuItem>>> = OnceLock::new();

#[derive(Debug, Clone, Default)]
struct FooterMeta {
    company_desc:   HashMap<String, String>,
    copyright_text: HashMap<String, String>,
    social_links:   Vec<SocialLink>,
}

#[derive(Debug, Clone)]
struct SocialLink {
    name: String,
    url:  String,
    icon: String,
}

static FOOTER_META: OnceLock<FooterMeta> = OnceLock::new();

fn load_footer_meta() -> FooterMeta {
    let json = match load_ncl_json(config_ncl_path("footer.ncl")) {
        Some(v) => v,
        None => return FooterMeta::default(),
    };
    let footer = match json.get("footer") {
        Some(f) => f,
        None => return FooterMeta::default(),
    };
    let mut meta = FooterMeta::default();
    if let Some(obj) = footer.get("company_desc").and_then(|v| v.as_object()) {
        for (k, v) in obj { meta.company_desc.insert(k.clone(), v.as_str().unwrap_or("").to_string()); }
    }
    if let Some(obj) = footer.get("copyright_text").and_then(|v| v.as_object()) {
        for (k, v) in obj { meta.copyright_text.insert(k.clone(), v.as_str().unwrap_or("").to_string()); }
    }
    if let Some(arr) = footer.get("social_links").and_then(|v| v.as_array()) {
        for s in arr {
            meta.social_links.push(SocialLink {
                name: s.get("name").and_then(|v| v.as_str()).unwrap_or("").to_string(),
                url:  s.get("url").and_then(|v| v.as_str()).unwrap_or("").to_string(),
                icon: s.get("icon").and_then(|v| v.as_str()).unwrap_or("").to_string(),
            });
        }
    }
    meta
}

fn load_ncl_json(path: std::path::PathBuf) -> Option<serde_json::Value> {
    match rustelo_config::format::load_config(&path) {
        Ok(v) => Some(v),
        Err(e) => {
            tracing::warn!("htmx shell: load_config({}) failed: {e}", path.display());
            None
        }
    }
}

fn routes_ncl_path() -> std::path::PathBuf {
    rustelo_utils::routes_path().with_extension("ncl")
}

fn config_ncl_path(file: &str) -> std::path::PathBuf {
    rustelo_utils::manifest_config_path().join(file)
}

fn parse_menu_items(use_in: &str) -> HashMap<String, Vec<MenuItem>> {
    let mut map: HashMap<String, Vec<MenuItem>> = HashMap::new();
    let Some(json) = load_ncl_json(routes_ncl_path()) else {
        tracing::warn!("htmx shell: could not load routes.ncl — menu will be empty");
        return map;
    };
    let routes = match json.get("routes").and_then(|r| r.as_array()) {
        Some(r) => r.clone(),
        None => return map,
    };
    for route in &routes {
        let menu = match route.get("menu") { Some(m) => m, None => continue };
        if !menu.get("enabled").and_then(|v| v.as_bool()).unwrap_or(false) { continue; }
        let use_in_list: Vec<&str> = menu.get("use_in")
            .and_then(|v| v.as_array())
            .map(|a| a.iter().filter_map(|s| s.as_str()).collect())
            .unwrap_or_default();
        if !use_in_list.contains(&use_in) { continue; }
        let order = menu.get("order").and_then(|v| v.as_i64()).unwrap_or(99);
        let labels = menu.get("labels").and_then(|v| v.as_object());
        let paths  = route.get("paths").and_then(|v| v.as_object());
        let langs: Vec<String> = labels
            .map(|l| l.keys().cloned().collect())
            .unwrap_or_default();
        for lang in &langs {
            let label = labels
                .and_then(|l| l.get(lang))
                .and_then(|v| v.as_str())
                .unwrap_or("")
                .to_string();
            let path = paths
                .and_then(|p| p.get(lang))
                .and_then(|v| v.as_str())
                .unwrap_or("")
                .to_string();
            if path.is_empty() { continue; }
            map.entry(lang.clone()).or_default().push(MenuItem { label, path, order });
        }
    }
    for items in map.values_mut() {
        items.sort_by_key(|i| i.order);
    }
    map
}

fn nav_items_for_lang(lang: &str) -> Vec<MenuItem> {
    NAV_ITEMS
        .get_or_init(|| parse_menu_items("header"))
        .get(lang)
        .cloned()
        .unwrap_or_default()
}

fn footer_links_for_lang(lang: &str) -> Vec<MenuItem> {
    FOOTER_ITEMS
        .get_or_init(|| parse_menu_items("footer"))
        .get(lang)
        .cloned()
        .unwrap_or_default()
}

fn render_footer(lang: &str) -> String {
    use rustelo_pages_htmx::render_safe;
    let env = crate::htmx_env::htmx_env();
    let logo_cfg = crate::theme::load_logo_config();
    let meta = FOOTER_META.get_or_init(load_footer_meta);

    let logo = minijinja::context! {
        show_in_footer => logo_cfg.show_in_footer,
        has_dark_variant => logo_cfg.has_dark_variant(),
        light_src => logo_cfg.light_src.clone(),
        dark_src => logo_cfg.dark_src.clone(),
        alt => logo_cfg.alt.clone(),
    };

    let footer_links = footer_links_for_lang(lang);
    let sections: Vec<minijinja::Value> = if footer_links.is_empty() {
        vec![]
    } else {
        let links: Vec<minijinja::Value> = footer_links.iter().map(|l| minijinja::context! {
            href => l.path.clone(),
            label => l.label.clone(),
            is_external => false,
        }).collect();
        let title = if lang == "es" { "Legal" } else { "Legal" };
        vec![minijinja::context! { title => title, links => links }]
    };

    let social_links: Vec<minijinja::Value> = meta.social_links.iter().map(|s| {
        let icon_html = match s.icon.as_str() {
            ic if ic.contains("github") => r#"<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z"/></svg>"#.to_string(),
            ic if ic.contains("linkedin") => r#"<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/></svg>"#.to_string(),
            other => format!(r#"<i class="{}" aria-hidden="true"></i>"#, encode_double_quoted_attribute(other)),
        };
        minijinja::context! { url => s.url.clone(), name => s.name.clone(), icon_html => icon_html }
    }).collect();

    let company_desc   = meta.company_desc.get(lang).or_else(|| meta.company_desc.get("en")).cloned().unwrap_or_default();
    let copyright_text = meta.copyright_text.get(lang).or_else(|| meta.copyright_text.get("en")).cloned().unwrap_or_default();

    let ctx = minijinja::context! {
        logo => logo,
        company_desc => company_desc,
        copyright_text => copyright_text,
        sections => sections,
        social_links => social_links,
        social_title => if lang == "es" { "Redes Sociales" } else { "Connect" },
        built_label => if lang == "es" { "Construido con" } else { "Built with" },
    };
    render_safe(&env, "partials/shell/footer.j2", ctx)
}

/// Build the cookie consent banner with FTL-localized text and a
/// language-resolved privacy policy link.
fn render_cookie_banner(language: &str) -> String {
    use rustelo_web::rustelo_core_lib::i18n::{localize_url, t_for_language};

    let t = |key: &str| t_for_language(language, key);
    let labels = rustelo_components_htmx::overlays::CookieBannerLabels {
        message: t("cookies-banner-text"),
        policy_href: localize_url("/privacy", language),
        policy_link: t("cookies-policy-link"),
        policy_suffix: t("cookies-policy-suffix"),
        manage_label: t("cookies-manage"),
        reject_label: t("cookies-reject"),
        accept_label: t("cookies-accept-all"),
    };
    rustelo_components_htmx::overlays::render_cookie_banner(true, &labels)
}

/// Render the full HTMX-SSR page as a complete HTML document.
///
/// `htmx_extensions` controls which vendored extensions get loaded and are
/// activated via the `hx-ext="..."` attribute on `<body>`. The set is
/// configured in `site/config/rendering.ncl`.
pub fn render(
    _options: SiteOptions,
    path: Option<String>,
    htmx_extensions: Vec<String>,
) -> String {
    let current_path = path.unwrap_or_default();
    let language = shell_detect_language(&current_path);

    let base_url = seo::site_base_url();
    let page_seo = seo::build_page_seo(&current_path, &language, &base_url);
    let head_tags = rustelo_seo::render_head_tags(&page_seo).unwrap_or_else(|err| {
        tracing::error!("SEO render failed: {err}");
        String::new()
    });

    let htmx_runtime = r#"<script src="/assets/htmx/htmx.min.js" defer></script>"#;
    let mut htmx_ext_scripts = String::new();
    for name in &htmx_extensions {
        htmx_ext_scripts.push_str(&format!(
            r#"<script src="/assets/htmx/ext/{}.js" defer></script>"#,
            encode_double_quoted_attribute(name)
        ));
    }
    // Map lockfile `name` → htmx runtime `attribute`. Some extensions register
    // under a different identifier than their package name: idiomorph ships
    // its ext as `idiomorph.js` (the lockfile file/name) but registers as
    // `morph` via `htmx.defineExtension("morph", ...)`. The hx-ext attribute
    // must use the registered name or htmx silently ignores it.
    fn name_to_hx_attribute(name: &str) -> &str {
        match name {
            "idiomorph" => "morph",
            other => other,
        }
    }
    let hx_ext_names: Vec<&str> = htmx_extensions
        .iter()
        .map(|n| name_to_hx_attribute(n.as_str()))
        .collect();
    // Re-init pipeline: loads after htmx so the afterSettle listener is in
    // place when swaps happen. reinit-handlers.js registers handlers for
    // highlight.js, theme, tag-filters and content-graph against any
    // swapped region.
    let reinit_scripts = r#"<script src="/js/htmx-reinit.js" defer></script><script src="/js/reinit-handlers.js" defer></script>"#;
    let hx_ext_attr = if hx_ext_names.is_empty() {
        String::new()
    } else {
        format!(
            r#" hx-ext="{}""#,
            encode_double_quoted_attribute(&hx_ext_names.join(","))
        )
    };

    let is_dark = rustelo_server::run::current_request_headers()
        .and_then(|h| {
            h.get("cookie")?.to_str().ok().and_then(|raw| {
                raw.split(';').find_map(|pair| {
                    let (k, v) = pair.trim().split_once('=')?;
                    (k.trim() == "theme").then(|| v.trim() == "dark")
                })
            })
        })
        .unwrap_or(false);
    let html_class = if is_dark { r#" class="dark""# } else { "" };

    let body_inner = render_body_inner(&current_path, &language);

    let mut html = String::with_capacity(16 * 1024);
    html.push_str("<!DOCTYPE html>");
    html.push_str(&format!(
        r#"<html lang="{}"{html_class}>"#,
        encode_double_quoted_attribute(&language)
    ));
    html.push_str("<head>");
    html.push_str(common::baseline_meta());
    html.push_str(&head_tags);
    html.push_str(&common::stylesheet_links());
    for href in crate::htmx_mode::CSS {
        html.push_str(&format!(r#"<link rel="stylesheet" href="{href}">"#));
    }
    html.push_str(htmx_runtime);
    html.push_str(&htmx_ext_scripts);
    html.push_str(reinit_scripts);
    html.push_str(&common::bundle_scripts());
    html.push_str(r#"<script src="/public/console-logger.js"></script>"#);
    html.push_str(&common::i18n_script(&language));
    html.push_str(&common::content_data_script(&current_path, &language));
    html.push_str("</head>");
    html.push_str(&format!(r#"<body hx-boost="true"{hx_ext_attr}>"#));
    html.push_str(&body_inner);
    html.push_str("</body></html>");
    html
}

fn render_body_inner(current_path: &str, language: &str) -> String {
    use rustelo_components_htmx::auth::render_login_button_for_lang;
    use rustelo_components_htmx::navigation::{
        render_lang_selector, render_theme_toggle, Theme,
    };

    let env = crate::htmx_env::htmx_env();
    let logo_cfg = crate::theme::load_logo_config();

    // Controls — rendered by the component layer (template-backed)
    let current_theme = rustelo_server::run::current_request_headers()
        .and_then(|h| {
            h.get("cookie")?.to_str().ok().and_then(|raw| {
                raw.split(';').find_map(|pair| {
                    let (k, v) = pair.trim().split_once('=')?;
                    (k.trim() == "theme").then(|| v.trim().to_string())
                })
            })
        })
        .map(|t| if t == "dark" { Theme::Dark } else { Theme::Light })
        .unwrap_or(Theme::Light);
    let theme_html = render_theme_toggle(current_theme);
    let lang_html = render_lang_selector(language);
    let login_label = if language == "es" { "Entrar" } else { "Sign in" };
    let login_html = render_login_button_for_lang(login_label, language);

    // Menu items loaded from site/config/routes.ncl at startup — no rebuild needed
    let menu_items: Vec<minijinja::Value> = nav_items_for_lang(language).into_iter().map(|item| {
        let is_active = item.path == current_path
            || (item.path != "/" && current_path.starts_with(&format!("{}/", item.path)));
        minijinja::context! {
            href => item.path,
            label => item.label,
            is_active => is_active,
            is_external => false,
        }
    }).collect();

    let logo = minijinja::context! {
        show_in_nav => logo_cfg.show_in_nav,
        has_dark_variant => logo_cfg.has_dark_variant(),
        light_src => logo_cfg.light_src.clone(),
        dark_src => logo_cfg.dark_src.clone(),
        alt => logo_cfg.alt.clone(),
    };

    let nav_ctx = minijinja::context! {
        logo => logo,
        menu_items => menu_items,
        theme_html => theme_html,
        lang_html => lang_html,
        login_html => login_html,
        mobile_controls_label => if language == "es" { "Controles" } else { "Controls" },
    };
    let header_html = rustelo_pages_htmx::render_safe(&env, "partials/shell/nav.j2", nav_ctx);

    let main_body = render_content_or_grid(current_path, language);
    let footer = render_footer(language);
    let main_and_footer = format!(
        r##"<main id="main-content" class="max-w-7xl mx-auto py-2 sm:ds-container flex-grow page-content" hx-history-elt="">{main_body}</main>{footer}"##
    );

    let modal_slot = rustelo_components_htmx::overlays::render_modal_slot();
    let toast_container = String::new();
    let _ = rustelo_components_htmx::overlays::render_toast_container;

    let has_consent = rustelo_server::run::current_request_headers()
        .map(|h| rustelo_server::api::has_cookie_consent(&h))
        .unwrap_or(false);
    let cookie_banner = if has_consent {
        String::new()
    } else {
        render_cookie_banner(language)
    };

    format!(
        r#"<div class="min-h-screen ds-bg-page flex flex-col">{header_html}{main_and_footer}{modal_slot}{toast_container}{cookie_banner}</div>"#
    )
}

/// Render just the `<main>` element with the page body.
///
/// Reused by the fragment renderer for non-boosted `HX-Request` responses.
pub fn main_fragment(path: &str, language: &str) -> String {
    let body = render_content_or_grid(path, language);
    format!(
        r##"<main id="main-content" class="max-w-7xl mx-auto py-2 sm:ds-container flex-grow page-content" hx-history-elt="">{body}</main>"##
    )
}

/// Render the `<main>` body for a content path.
///
/// Resolution order (post-first, so a real post URL always loads the post):
///   1. Bare content index (`/blog`, `/projects`)          → grid (all items)
///   2. An existing content item at this path              → post
///   3. Content-kind path with a category segment          → grid (filtered)
///   4. Anything else                                      → static page / 404
///
/// Checking the post lookup before the category-grid fallback makes the
/// distinction authoritative (does the item exist?) rather than relying on URL
/// shape — which matters for localized ES slugs whose route the engine resolves
/// against the default-language index.
fn render_content_or_grid(path: &str, language: &str) -> String {
    use rustelo_core_lib::routing::config::load_routes_config;
    let trimmed = path.trim_start_matches('/');
    let segments: Vec<&str> = trimmed.split('/').filter(|s| !s.is_empty()).collect();
    let base = segments.first().copied().unwrap_or("");
    // PAP: resolve the URL's first segment → content kind from the routes
    // registry (site/config/routes.ncl), NOT a hardcoded list. A new content
    // kind works with NCL config alone — no code change here. Bilingual aliases
    // (/proyectos, /recetas, /recursos, …) resolve via their per-language route
    // entries, which all carry the same `content_type`.
    let kind: Option<String> = load_routes_config().routes.iter().find_map(|r| {
        if !r.enabled {
            return None;
        }
        let seg = r.path.trim_start_matches('/').split('/').next().unwrap_or("");
        (seg == base).then(|| r.content_type.clone()).flatten()
    });

    // 1. Bare content index → grid with no filter.
    if let Some(k) = &kind {
        if segments.len() <= 1 {
            return crate::htmx_grid::render_grid_host(k, language, None);
        }
    }

    // 2. Existing content post wins over any grid interpretation.
    if let Some(html) = crate::htmx_pages::try_content_post(path, language) {
        return html;
    }

    // 3. Content-kind path with a category segment → grid filtered by category.
    if let Some(k) = &kind {
        if segments.len() == 2 {
            return crate::htmx_grid::render_grid_host(k, language, segments.get(1).copied());
        }
    }

    // 4. Static project/product page or 404.
    crate::htmx_pages::dispatch_static(path, language)
}

/// Language detection for the HTMX shell.
///
/// Shared-URL routes (same path for both EN and ES, e.g. `/blog`, `/rustelo`)
/// cannot be distinguished by path alone — the preferred_language cookie is
/// the only signal. Exclusive routes (e.g. `/proyectos` only in ES) are
/// authoritative by URL.
///
/// Priority:
///   1. Exclusive non-default-language route → path determines language
///   2. Shared or default-language route → cookie determines language
///   3. Unknown path (no route match) → cookie determines language
///   4. Fallback → default language
fn shell_detect_language(path: &str) -> String {
    use rustelo_web::rustelo_core_lib::i18n::language_config::get_language_registry;
    use rustelo_web::rustelo_core_lib::routing::utils::load_routes_config;

    let registry = get_language_registry();
    let default_lang = registry.get_default_language().to_string();
    let config = load_routes_config();

    let path_in_default = config.routes.iter().any(|r| {
        r.enabled && r.language == default_lang && shell_route_matches(&r.path, path)
    });

    if path_in_default {
        // Shared route — must use cookie; path alone is ambiguous.
        cookie_lang_or(default_lang)
    } else {
        // Check for exclusive non-default route (e.g. /proyectos, /recetas/...).
        let exclusive = registry.get_available_languages().into_iter().find(|lang| {
            *lang != default_lang
                && config.routes.iter().any(|r| {
                    r.enabled && r.language == *lang && shell_route_matches(&r.path, path)
                })
        });
        exclusive.unwrap_or_else(|| cookie_lang_or(default_lang))
    }
}

/// Returns the `preferred_language` cookie value when present and supported,
/// otherwise the provided `fallback`.
fn cookie_lang_or(fallback: String) -> String {
    rustelo_server::run::current_request_headers()
        .and_then(|h| rustelo_server::utils::lang_detection::get_language_from_cookie(&h))
        .unwrap_or(fallback)
}

/// True when `route_path` (possibly parametric) covers `actual_path`.
fn shell_route_matches(route_path: &str, actual_path: &str) -> bool {
    if route_path == actual_path {
        return true;
    }
    // Strip the parametric tail (everything from `{` onwards) and check prefix.
    let static_prefix = route_path.split('{').next().unwrap_or("").trim_end_matches('/');
    !static_prefix.is_empty()
        && static_prefix != "/"
        && (actual_path == static_prefix
            || actual_path.starts_with(&format!("{static_prefix}/")))
}
