//! Unified site configuration loader
//!
//! Loads `site/config/index.ncl` (NCL → JSON via `nickel export`) and deserializes it
//! into [`UnifiedSiteConfig`], the single source of truth for routes, menus,
//! footer, content types, and site metadata.
//!
//! # Fail-fast policy
//!
//! This module panics with an actionable message if `index.ncl` is absent or
//! the Nickel CLI is unavailable.  No silent fallbacks.
//!
//! # Usage
//!
//! ```no_run
//! use build_config::site_config::load_unified_site_config;
//! use std::path::Path;
//!
//! let manifest_dir = Path::new("/path/to/website-impl");
//! let site = load_unified_site_config(manifest_dir).unwrap();
//!
//! let all_routes = site.routes_expanded();          // HashMap<lang, Vec<RouteConfigToml>>
//! let menu_toml  = site.menu_toml_unified();         // raw TOML string for server embedding
//! let footer_en  = site.menu_items_for_zone("footer", "en");
//! # Ok::<(), Box<dyn std::error::Error>>(())
//! ```

use anyhow::{Context, Result};
use rustelo_core_types::rendering::RenderingConfig;
use rustelo_core_types::routing::RouteConfigToml;
use serde::Deserialize;
use std::collections::HashMap;
use std::path::Path;

// Re-export for use by build scripts that depend on build_config
pub use rustelo_core_types::routing::RoutesConfig;

// ──────────────────────────────────────────────────────────────────────────────
// Public types — mirror the Nickel contracts in site/nickel/site/contracts.ncl
// ──────────────────────────────────────────────────────────────────────────────

#[derive(Debug, Clone, Deserialize)]
pub struct SiteMetadata {
    pub name: String,
    pub languages: Vec<String>,
    pub default_language: String,
}

#[derive(Debug, Clone, Deserialize)]
pub struct SitePaths {
    pub i18n: String,
    pub content: String,
    pub themes: String,
    pub assets: String,
    #[serde(default)]
    pub migrations: Option<String>,
    #[serde(default)]
    pub email_templates: Option<String>,
    #[serde(default)]
    pub work: Option<String>,
}

#[derive(Debug, Clone, Deserialize)]
pub struct ThemeConfig {
    pub default: String,
    pub available: Vec<String>,
}

/// Shell asset manifest — CSS/JS paths for source and bundled modes.
/// Sourced from `site/config/assets.ncl`; generated into `shell_asset_paths.rs`.
#[derive(Debug, Clone, Default, Deserialize)]
pub struct SiteAssets {
    #[serde(default)]
    pub source_css: Vec<String>,
    #[serde(default)]
    pub bundled_css: Vec<String>,
    #[serde(default)]
    pub htmx_css: Vec<String>,
    #[serde(default)]
    pub source_js: Vec<String>,
    #[serde(default)]
    pub bundled_js: Vec<String>,
}

/// Content type definition from `content_types` in config.ncl.
/// `features` kept as a generic JSON value so any extra fields survive round-trips.
#[derive(Debug, Clone, Deserialize)]
pub struct ContentTypeConfig {
    pub name: String,
    pub directory: String,
    #[serde(default = "default_enabled")]
    pub enabled: bool,
    pub features: serde_json::Value,
}

fn default_enabled() -> bool {
    true
}

/// Database configuration from `database` in config.ncl.
#[derive(Debug, Clone, Deserialize)]
pub struct NclDatabaseConfig {
    pub url: String,
    #[serde(default = "ncl_db_default_create_if_missing")]
    pub create_if_missing: bool,
    #[serde(default = "ncl_db_default_max_connections")]
    pub max_connections: u32,
    #[serde(default = "ncl_db_default_min_connections")]
    pub min_connections: u32,
    #[serde(default = "ncl_db_default_connect_timeout")]
    pub connect_timeout: u64,
    #[serde(default = "ncl_db_default_idle_timeout")]
    pub idle_timeout: u64,
    #[serde(default = "ncl_db_default_max_lifetime")]
    pub max_lifetime: u64,
}

fn ncl_db_default_create_if_missing() -> bool {
    true
}
fn ncl_db_default_max_connections() -> u32 {
    5
}
fn ncl_db_default_min_connections() -> u32 {
    1
}
fn ncl_db_default_connect_timeout() -> u64 {
    30
}
fn ncl_db_default_idle_timeout() -> u64 {
    600
}
fn ncl_db_default_max_lifetime() -> u64 {
    1800
}

/// Logo configuration from `site/config/index.ncl`.
#[derive(Debug, Clone, Deserialize)]
pub struct LogoMetadata {
    pub light_src: String,
    pub dark_src: String,
    pub alt: String,
    pub show_in_nav: bool,
    pub show_in_footer: bool,
    pub width: Option<String>,
    pub height: Option<String>,
}

/// Footer metadata absorbed inline into `SiteConfig`.
#[derive(Debug, Clone, Deserialize)]
pub struct FooterMetadata {
    pub title: String,
    pub company_desc: HashMap<String, String>,
    pub copyright_text: HashMap<String, String>,
    #[serde(default)]
    pub social_links: Vec<SocialLink>,
}

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

/// Menu configuration embedded directly inside a [`UnifiedRoute`].
#[derive(Debug, Clone, Deserialize)]
pub struct MenuEntry {
    #[serde(default)]
    pub enabled: bool,
    pub order: Option<i32>,
    pub icon: Option<String>,
    /// Primary: FTL key for the label (preferred over inline labels).
    pub label_key: Option<String>,
    /// Fallback: inline labels keyed by language code.
    pub labels: Option<HashMap<String, String>>,
    #[serde(default)]
    pub visible_in: Vec<String>,
    /// Navigation zones this item appears in: "header", "footer", or both.
    #[serde(default = "default_use_in")]
    pub use_in: Vec<String>,
}

fn default_use_in() -> Vec<String> {
    vec!["header".to_string()]
}

/// A route covering all supported languages.
/// `paths` maps language code → URL path.
#[derive(Debug, Clone, Deserialize)]
pub struct UnifiedRoute {
    pub component: String,
    pub paths: HashMap<String, String>,
    #[serde(default = "default_priority")]
    pub priority: f32,
    pub content_type: Option<String>,
    pub menu: MenuEntry,
    pub props: Option<serde_json::Value>,
}

fn default_priority() -> f32 {
    0.8
}

/// Top-level unified site configuration.
#[derive(Debug, Clone, Deserialize)]
pub struct UnifiedSiteConfig {
    pub site: SiteMetadata,
    pub paths: SitePaths,
    pub themes: ThemeConfig,
    pub logo: LogoMetadata,
    pub content_types: Vec<ContentTypeConfig>,
    pub database: NclDatabaseConfig,
    pub footer: FooterMetadata,
    pub routes: Vec<UnifiedRoute>,
    #[serde(default)]
    pub assets: SiteAssets,
    /// Rendering profile configuration (ADR-006). Absent when the workspace
    /// has not added `site/config/rendering.ncl`; consumers should fall back to
    /// [`RenderingConfig::default`] in that case.
    #[serde(default)]
    pub rendering: Option<RenderingConfig>,
}

// ──────────────────────────────────────────────────────────────────────────────
// Loader
// ──────────────────────────────────────────────────────────────────────────────

/// Load unified site configuration from `site/config/index.ncl`.
///
/// # Errors
/// Returns `Err` when:
/// - `index.ncl` does not exist at `manifest_dir/site/config/index.ncl`
/// - The Nickel CLI is not available
/// - NCL export or JSON deserialisation fails
pub fn load_unified_site_config(manifest_dir: &Path) -> Result<UnifiedSiteConfig> {
    // Resolve config path: env var takes priority, relative paths are resolved
    // against manifest_dir (workspace root) so build scripts work correctly.
    let site_ncl = if let Ok(config_path) = std::env::var("SITE_CONFIG_PATH") {
        let p = std::path::PathBuf::from(&config_path);
        let resolved = if p.is_absolute() {
            p
        } else {
            // Relative SITE_CONFIG_PATH → resolve against workspace root (manifest_dir).
            // This is necessary because Cargo build scripts run with CWD = package dir,
            // not the workspace root where the env var was intended to be relative to.
            manifest_dir.join(&p)
        };
        if resolved.is_dir() { resolved.join("index.ncl") } else { resolved }
    } else {
        manifest_dir.join("site").join("config").join("index.ncl")
    };

    anyhow::ensure!(
        site_ncl.exists(),
        "Site configuration not found at {}.\n\
         Create it or set SITE_CONFIG_PATH to point to your config file. This file is required.",
        site_ncl.display()
    );

    anyhow::ensure!(
        rustelo_config::is_nickel_available(),
        "Nickel CLI not found in PATH.\n\
         Install: cargo install nickel-lang-cli\n\
         Or: https://nickel-lang.org/user-manual/installation"
    );

    let json = rustelo_config::nickel::export_to_json(&site_ncl)
        .with_context(|| format!("Nickel export failed for {}", site_ncl.display()))?;

    let config: UnifiedSiteConfig = serde_json::from_str(&json)
        .with_context(|| format!("JSON deserialisation failed for {}", site_ncl.display()))?;

    Ok(config)
}

// ──────────────────────────────────────────────────────────────────────────────
// Helper methods
// ──────────────────────────────────────────────────────────────────────────────

impl UnifiedSiteConfig {
    /// Expand unified routes into per-language `RouteConfigToml` vectors.
    pub fn routes_expanded(&self) -> HashMap<String, Vec<RouteConfigToml>> {
        let mut result: HashMap<String, Vec<RouteConfigToml>> = HashMap::new();

        for route in &self.routes {
            let comp_lower = route.component.to_lowercase();

            for (lang, path) in &route.paths {
                let entry = result.entry(lang.clone()).or_default();
                entry.push(RouteConfigToml {
                    path: path.clone(),
                    component: route.component.clone(),
                    language: lang.clone(),
                    enabled: true,
                    title_key: format!("{}-page-title", comp_lower),
                    description_key: Some(format!("{}-page-description", comp_lower)),
                    keywords: None,
                    priority: Some(route.priority),
                    menu_group: None,
                    menu_order: route.menu.order,
                    menu_icon: route.menu.icon.clone(),
                    is_external: Some(route.component == "ExternalLink"),
                    requires_auth: None,
                    show_in_sitemap: Some(true),
                    alternate_paths: None,
                    canonical_path: Some(path.clone()),
                    content_type: route.content_type.clone(),
                    rendering_profile: None,
                    template: None,
                    props: None,
                    unified_component: None,
                    lang_prefixes: None,
                    params_component: None,
                });
            }
        }

        result
    }

    /// Generate a unified menu TOML string for server embedding.
    ///
    /// Produces `[[menu]]` TOML for a specific navigation zone and language.
    ///
    /// Filters routes to those with `menu.enabled`, `use_in` containing `zone`,
    /// and `visible_in` empty or containing `lang`.  Labels include all language
    /// variants so `Translations::get_for_language` works at runtime.
    ///
    /// Used by WASM menu registry generation to produce per-language keyed entries
    /// that `get_menu_resource("en")` can resolve without a filesystem fallback.
    /// Check whether a path is denied for anonymous users by RBAC patterns.
    /// Pattern semantics: `"/foo/*"` = prefix match, `"/foo"` = exact match.
    fn path_requires_auth(path: &str, rbac_deny_patterns: &[String]) -> bool {
        for pattern in rbac_deny_patterns {
            if let Some(prefix) = pattern.strip_suffix("/*") {
                if path == prefix || path.starts_with(&format!("{}/", prefix)) {
                    return true;
                }
            } else if path == pattern {
                return true;
            }
        }
        false
    }

    /// Generate menu TOML for `zone` + `lang`, annotating items with `auth_required`
    /// derived from `rbac_deny_patterns`.  Pass an empty slice to skip annotation.
    pub fn menu_toml_for_lang_with_rbac(
        &self,
        zone: &str,
        lang: &str,
        rbac_deny_patterns: &[String],
    ) -> String {
        self.menu_toml_for_lang_inner(zone, lang, rbac_deny_patterns)
    }

    pub fn menu_toml_for_lang(&self, zone: &str, lang: &str) -> String {
        self.menu_toml_for_lang_inner(zone, lang, &[])
    }

    fn menu_toml_for_lang_inner(
        &self,
        zone: &str,
        lang: &str,
        rbac_deny_patterns: &[String],
    ) -> String {
        let mut out = String::new();
        let default_lang = &self.site.default_language;

        let mut routes: Vec<&UnifiedRoute> = self
            .routes
            .iter()
            .filter(|r| {
                r.menu.enabled
                    && r.menu.use_in.iter().any(|z| z == zone)
                    && (r.menu.visible_in.is_empty() || r.menu.visible_in.iter().any(|l| l == lang))
                    && r.paths.contains_key(lang)
            })
            .collect();

        routes.sort_by_key(|r| (r.menu.order.unwrap_or(999), r.component.as_str()));

        for route in routes {
            let primary_path = route.paths[lang].clone();
            let is_external = route.component == "ExternalLink";
            let auth_req = Self::path_requires_auth(&primary_path, rbac_deny_patterns);

            out.push_str("[[menu]]\n");
            out.push_str(&format!("route = \"{}\"\n", primary_path));
            out.push_str(&format!("is_external = {}\n", is_external));
            if auth_req {
                out.push_str("auth_required = true\n");
            }

            if let Some(icon) = &route.menu.icon {
                out.push_str(&format!("icon = \"{}\"\n", icon));
            }

            if let Some(labels) = &route.menu.labels {
                out.push_str("[menu.label]\n");
                let mut sorted: Vec<(&String, &String)> = labels.iter().collect();
                sorted.sort_by_key(|(k, _)| k.as_str());
                for (l, text) in sorted {
                    out.push_str(&format!("{} = \"{}\"\n", l, text));
                }
            }

            let alt_paths: Vec<(&String, &String)> = route
                .paths
                .iter()
                .filter(|(l, p)| *l != default_lang && *p != &primary_path && *l != "external")
                .collect();

            if !alt_paths.is_empty() {
                out.push_str("[menu.localized_routes]\n");
                let mut sorted = alt_paths;
                sorted.sort_by_key(|(k, _)| k.as_str());
                for (l, p) in sorted {
                    if l.contains('-') {
                        out.push_str(&format!("\"{}\" = \"{}\"\n", l, p));
                    } else {
                        out.push_str(&format!("{} = \"{}\"\n", l, p));
                    }
                }
            }

            out.push('\n');
        }

        out
    }

    /// Produces `[[menu]]` array-of-tables entries covering all languages,
    /// with `[menu.label]` and `[menu.localized_routes]` sub-tables.
    pub fn menu_toml_unified(&self) -> String {
        let mut out = String::new();

        let mut menu_routes: Vec<&UnifiedRoute> =
            self.routes.iter().filter(|r| r.menu.enabled).collect();

        menu_routes.sort_by_key(|r| (r.menu.order.unwrap_or(999), r.component.as_str()));

        for route in menu_routes {
            let primary_path = route
                .paths
                .get("en")
                .or_else(|| route.paths.values().next())
                .cloned()
                .unwrap_or_default();

            let is_external = route.component == "ExternalLink";

            out.push_str("[[menu]]\n");
            out.push_str(&format!("route = \"{}\"\n", primary_path));
            out.push_str(&format!("is_external = {}\n", is_external));

            if let Some(icon) = &route.menu.icon {
                out.push_str(&format!("icon = \"{}\"\n", icon));
            }

            if !route.menu.visible_in.is_empty() {
                let langs: Vec<String> = route
                    .menu
                    .visible_in
                    .iter()
                    .map(|l| format!("\"{}\"", l))
                    .collect();
                out.push_str(&format!("visible_in = [{}]\n", langs.join(", ")));
            }

            if !route.menu.use_in.is_empty() {
                let zones: Vec<String> = route
                    .menu
                    .use_in
                    .iter()
                    .map(|z| format!("\"{}\"", z))
                    .collect();
                out.push_str(&format!("use_in = [{}]\n", zones.join(", ")));
            }

            if let Some(labels) = &route.menu.labels {
                out.push_str("[menu.label]\n");
                let mut sorted_labels: Vec<(&String, &String)> = labels.iter().collect();
                sorted_labels.sort_by_key(|(k, _)| k.as_str());
                for (lang, label) in sorted_labels {
                    out.push_str(&format!("{} = \"{}\"\n", lang, label));
                }
            }

            let alt_paths: Vec<(&String, &String)> = route
                .paths
                .iter()
                .filter(|(lang, path)| {
                    *lang != "en" && *path != &primary_path && *lang != "external"
                })
                .collect();

            if !alt_paths.is_empty() {
                out.push_str("[menu.localized_routes]\n");
                let mut sorted_paths = alt_paths;
                sorted_paths.sort_by_key(|(k, _)| k.as_str());
                for (lang, path) in sorted_paths {
                    if lang.contains('-') {
                        out.push_str(&format!("\"{}\" = \"{}\"\n", lang, path));
                    } else {
                        out.push_str(&format!("{} = \"{}\"\n", lang, path));
                    }
                }
            }

            out.push('\n');
        }

        out
    }

    /// Menu items visible in a specific navigation zone and language.
    ///
    /// `zone` is one of `"header"` or `"footer"`.
    pub fn menu_items_for_zone(&self, zone: &str, lang: &str) -> Vec<MenuItemFlat> {
        let mut items: Vec<MenuItemFlat> = self
            .routes
            .iter()
            .filter(|r| {
                r.menu.enabled
                    && r.menu.use_in.iter().any(|z| z == zone)
                    && (r.menu.visible_in.is_empty() || r.menu.visible_in.iter().any(|l| l == lang))
                    && r.paths.contains_key(lang)
            })
            .map(|r| MenuItemFlat {
                route: r.paths[lang].clone(),
                label: r
                    .menu
                    .labels
                    .as_ref()
                    .and_then(|ls| ls.get(lang))
                    .cloned()
                    .unwrap_or_else(|| r.component.clone()),
                icon: r.menu.icon.clone(),
                order: r.menu.order.unwrap_or(999),
                is_external: r.component == "ExternalLink",
            })
            .collect();

        items.sort_by_key(|i| i.order);
        items
    }

    /// Convenience wrapper: menu items for the `"header"` zone.
    pub fn menu_items_for_lang(&self, lang: &str) -> Vec<MenuItemFlat> {
        self.menu_items_for_zone("header", lang)
    }

    /// Language codes declared in the site config.
    pub fn languages(&self) -> &[String] {
        &self.site.languages
    }

    /// Default language code.
    pub fn default_language(&self) -> &str {
        &self.site.default_language
    }
}

/// Flat menu item for a single language — used by build.rs generators.
#[derive(Debug, Clone)]
pub struct MenuItemFlat {
    pub route: String,
    pub label: String,
    pub icon: Option<String>,
    pub order: i32,
    pub is_external: bool,
}

// ──────────────────────────────────────────────────────────────────────────────
// Tests
// ──────────────────────────────────────────────────────────────────────────────

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

    fn make_test_config() -> UnifiedSiteConfig {
        UnifiedSiteConfig {
            site: SiteMetadata {
                name: "Test".into(),
                languages: vec!["en".into(), "es".into()],
                default_language: "en".into(),
            },
            paths: SitePaths {
                i18n: "site/i18n".into(),
                content: "site/content".into(),
                themes: "site/config/themes".into(),
                assets: "public".into(),
                migrations: None,
                email_templates: None,
                work: None,
            },
            themes: ThemeConfig {
                default: "default".into(),
                available: vec!["default".into()],
            },
            logo: LogoMetadata {
                light_src: "/images/logo.svg".into(),
                dark_src: "/images/logo-dark.svg".into(),
                alt: "Logo".into(),
                show_in_nav: true,
                show_in_footer: true,
                width: None,
                height: None,
            },
            database: NclDatabaseConfig {
                url: "sqlite:data/test.db".into(),
                create_if_missing: true,
                max_connections: 5,
                min_connections: 1,
                connect_timeout: 30,
                idle_timeout: 600,
                max_lifetime: 1800,
            },
            assets: SiteAssets::default(),
            content_types: vec![],
            footer: FooterMetadata {
                title: "Test Author".into(),
                company_desc: HashMap::from([
                    ("en".into(), "English desc".into()),
                    ("es".into(), "Spanish desc".into()),
                ]),
                copyright_text: HashMap::from([
                    ("en".into(), "© 2024".into()),
                    ("es".into(), "© 2024".into()),
                ]),
                social_links: vec![],
            },
            routes: vec![
                UnifiedRoute {
                    component: "HomePage".into(),
                    paths: HashMap::from([
                        ("en".into(), "/".into()),
                        ("es".into(), "/inicio".into()),
                    ]),
                    priority: 1.0,
                    content_type: None,
                    menu: MenuEntry {
                        enabled: true,
                        order: Some(1),
                        icon: Some("home".into()),
                        label_key: None,
                        labels: Some(HashMap::from([
                            ("en".into(), "Home".into()),
                            ("es".into(), "Inicio".into()),
                        ])),
                        visible_in: vec![],
                        use_in: vec!["header".into()],
                    },
                    props: None,
                },
                UnifiedRoute {
                    component: "PrivacyPage".into(),
                    paths: HashMap::from([
                        ("en".into(), "/privacy".into()),
                        ("es".into(), "/privacidad".into()),
                    ]),
                    priority: 0.5,
                    content_type: None,
                    menu: MenuEntry {
                        enabled: true,
                        order: Some(1),
                        icon: None,
                        label_key: None,
                        labels: Some(HashMap::from([
                            ("en".into(), "Privacy".into()),
                            ("es".into(), "Privacidad".into()),
                        ])),
                        visible_in: vec![],
                        use_in: vec!["footer".into()],
                    },
                    props: None,
                },
            ],
            rendering: None,
        }
    }

    #[test]
    fn routes_expanded_produces_correct_lang_map() {
        let cfg = make_test_config();
        let expanded = cfg.routes_expanded();

        let en = expanded.get("en").expect("en routes");
        let es = expanded.get("es").expect("es routes");

        assert_eq!(en.len(), 2, "two EN routes");
        assert_eq!(es.len(), 2, "two ES routes");

        let home_en = en.iter().find(|r| r.path == "/").expect("home EN");
        assert_eq!(home_en.component, "HomePage");
        assert_eq!(home_en.language, "en");
        assert_eq!(home_en.priority, Some(1.0));
    }

    #[test]
    fn menu_toml_unified_contains_home_entry() {
        let cfg = make_test_config();
        let toml = cfg.menu_toml_unified();

        assert!(toml.contains("[[menu]]"), "has menu array");
        assert!(toml.contains("route = \"/\""), "has root path");
        assert!(toml.contains("is_external = false"), "not external");
    }

    #[test]
    fn menu_items_for_zone_header_excludes_footer_only() {
        let cfg = make_test_config();
        let header_items = cfg.menu_items_for_zone("header", "en");
        let footer_items = cfg.menu_items_for_zone("footer", "en");

        assert_eq!(header_items.len(), 1, "only home in header");
        assert_eq!(footer_items.len(), 1, "only privacy in footer");
        assert_eq!(header_items[0].route, "/");
        assert_eq!(footer_items[0].route, "/privacy");
    }

    #[test]
    fn menu_items_for_lang_filters_visibility() {
        let mut cfg = make_test_config();
        cfg.routes.push(UnifiedRoute {
            component: "ExternalLink".into(),
            paths: HashMap::from([("external".into(), "/kitdigital.html".into())]),
            priority: 0.3,
            content_type: None,
            menu: MenuEntry {
                enabled: true,
                order: Some(100),
                icon: None,
                label_key: None,
                labels: Some(HashMap::from([("es".into(), "Kit-Digital".into())])),
                visible_in: vec!["es".into()],
                use_in: vec!["header".into()],
            },
            props: None,
        });

        let en_items = cfg.menu_items_for_lang("en");
        let es_items = cfg.menu_items_for_lang("es");

        assert!(!en_items.iter().any(|i| i.route.contains("kitdigital")));
        assert!(
            es_items.iter().any(|i| i.is_external),
            "ES menu has external item"
        );
    }
}
