//! Modern TOML-based routing configuration
//!
//! This module provides the new routing configuration system that works
//! entirely with TOML-based route definitions and supports any number of
//! languages dynamically.
//!
//! ARCHITECTURE: This module contains the core route loading logic and
//! configuration management. It uses core_types for shared types/caches (NO
//! circular dependency).

use super::core_types::{RouteConfigToml, RoutesConfig, ROUTES_CACHE};
use crate::i18n::language_config::get_language_registry;

// ============================================================================
// SITE-CONTRIBUTED ROUTES (consumer-owned routes and languages, htmx only)
// ============================================================================

/// Env var naming a directory of per-language route files the SITE owns:
/// `<dir>/<lang>.toml`, each a `RoutesConfig`. Unset ⇒ nothing changes, for
/// anyone.
const SITE_ROUTES_DIR: &str = "RUSTELO_SITE_ROUTES";

/// Routes a consumer site contributes at runtime, merged over the routes
/// embedded at build time.
///
/// WHY THIS EXISTS. Routes are embedded by `build.rs` from the IMAGE's
/// `site/config.ncl`, and several sites are built from one image. So the
/// language set — route paths are declared per language — belonged to the
/// image, and a consumer could not serve a language the image did not
/// declare. Everything else a page needs is already consumer-owned: the
/// templates (`HTMX_TEMPLATE_PATH`), the Fluent text (`site/i18n/locales/`),
/// the content (`site/content/`). The routes were the last baked thing.
///
/// The seam was already the right shape — `ROUTES_REGISTRY` is a `HashMap<lang,
/// TOML>` populated by `ResourceContributor::contribute_routes()`. What blocked
/// a second contributor was `register_routes()` rejecting a language that was
/// already registered. Rather than loosen that (a duplicate embedded language
/// IS a bug), a site contributes HERE, at load, where merging is unambiguous:
/// the site's routes are ADDED to the embedded ones, and a same-path route from
/// the site WINS — a site may correct its image, not be corrected by it.
///
/// THE GUARD, AND IT IS THE WHOLE REASON THIS IS SAFE. A contributed route's
/// component MUST end in `Page`. In htmx, `resolve_static_page` renders ANY
/// `*Page` component generically from this registry, deriving the page_id by
/// kebab-case and picking up the site's own `pages/<page_id>.j2`. Any OTHER
/// component name names a compiled Leptos component — and a consumer cannot add
/// one of those, because it does not compile the binary. A route the renderer
/// cannot serve is not a route: it is a 404 the site declared to itself. So
/// those are refused here, loudly, instead of failing silently at request time.
///
/// This is therefore a HTMX-mode capability by construction, not by a flag: in
/// Leptos the guard admits nothing a consumer could actually have contributed.
#[cfg(not(target_arch = "wasm32"))]
fn load_site_contributed_routes() -> Vec<RouteConfigToml> {
    match std::env::var(SITE_ROUTES_DIR) {
        Ok(d) if !d.trim().is_empty() => read_site_routes_dir(&d),
        _ => Vec::new(),
    }
}

/// The loader proper, taking the directory as an ARGUMENT.
///
/// Split out from the env-reading wrapper for one reason, learned the hard way:
/// the first version read the env var directly and its test had to SET that
/// var. Env vars are process-global, cargo runs tests in parallel, and
/// `ROUTES_CACHE` is a `OnceLock` initialised once per test binary —
/// so the test poisoned the route cache of whatever test happened to initialise
/// it first. Three unrelated i18n/content tests went red, intermittently, in a
/// crate whose baseline was green.
///
/// A function that reads global state cannot be tested without mutating global
/// state. So it does not read it: the wrapper does, once, and this does the
/// work.
#[cfg(not(target_arch = "wasm32"))]
fn read_site_routes_dir(dir: &str) -> Vec<RouteConfigToml> {
    let entries = match std::fs::read_dir(dir) {
        Ok(e) => e,
        Err(e) => {
            tracing::warn!(
                "{} = '{}' but it cannot be read: {}",
                SITE_ROUTES_DIR,
                dir,
                e
            );
            return Vec::new();
        }
    };

    let mut out: Vec<RouteConfigToml> = Vec::new();
    for entry in entries.flatten() {
        let path = entry.path();
        // `.ncl` FIRST, and that is the point. The rest of this stack authors config in
        // NCL — typed, contracted — and the htmx runtime already reads it:
        // `rustelo_config::format::load_config` dispatches by extension and the
        // shell loads `routes.ncl` and `footer.ncl` through it on every start.
        // The first version of this function accepted only raw TOML, which put the
        // consumer's routes OUTSIDE the contract that governs the image's: a typo in
        // `component` would have been a silent 404, checked by nothing. Data
        // outside its contract is the defect this whole seam exists to end, and
        // it had been reintroduced by the fix for it.
        //
        // `.toml` still works — `load_config` handles it, and a site that has not
        // written a schema is not a site that should be refused a route.
        let ext = path.extension().and_then(|s| s.to_str()).unwrap_or("");
        if ext != "ncl" && ext != "toml" {
            continue;
        }
        let lang = match path.file_stem().and_then(|s| s.to_str()) {
            Some(l) => l.to_string(),
            None => continue,
        };
        let cfg: RoutesConfig = match rustelo_config::format::load_config(&path) {
            Ok(v) => match serde_json::from_value::<RoutesConfig>(v) {
                Ok(c) => c,
                Err(e) => {
                    tracing::error!(
                        "site routes: {} is not a RoutesConfig: {}",
                        path.display(),
                        e
                    );
                    continue;
                }
            },
            Err(e) => {
                tracing::error!("site routes: cannot load {}: {}", path.display(), e);
                continue;
            }
        };

        let (mut kept, mut refused) = (0usize, 0usize);
        for mut r in cfg.routes {
            if !r.component.ends_with("Page") {
                // Not a silent drop. A consumer that writes this has a false belief about what
                // it can add, and it will otherwise hunt a 404 that the config
                // promised it.
                tracing::error!(
                    "site routes [{}]: refusing '{}' → component '{}' does not end in `Page`. \
                     Only generically-rendered `*Page` components can be contributed by a site; \
                     anything else must be compiled into the image.",
                    lang,
                    r.path,
                    r.component
                );
                refused += 1;
                continue;
            }
            // The file names the language; a per-route `language` field cannot disagree
            // with it.
            r.language = lang.clone();
            out.push(r);
            kept += 1;
        }
        tracing::info!(
            "site routes [{}]: {} contributed{}",
            lang,
            kept,
            if refused > 0 {
                format!(", {} REFUSED (non-`Page` component)", refused)
            } else {
                String::new()
            }
        );
    }
    out
}

#[cfg(target_arch = "wasm32")]
fn load_site_contributed_routes() -> Vec<RouteConfigToml> {
    Vec::new()
}

/// Merge site-contributed routes over a base set. A site route with the same
/// (path, language) REPLACES the embedded one — the consumer is closer to its
/// own truth than the shared image is.
fn merge_site_routes(mut base: Vec<RouteConfigToml>) -> Vec<RouteConfigToml> {
    let site = load_site_contributed_routes();
    if site.is_empty() {
        return base;
    }
    for r in site {
        if let Some(existing) = base
            .iter_mut()
            .find(|b| b.path == r.path && b.language == r.language)
        {
            *existing = r;
        } else {
            base.push(r);
        }
    }
    base
}

// ============================================================================
// ROUTE LOADING (Core implementation - used by all routing modules)
// ============================================================================

/// Load routes from the routes/ directory using SITE_CONFIG_PATH environment
/// variable
#[cfg(all(not(target_arch = "wasm32"), not(has_generated_resources)))]
fn load_routes_from_directory() -> Option<RoutesConfig> {
    use std::fs;
    use std::path::PathBuf;

    // Get the config path from environment variable
    let config_path = match std::env::var("SITE_CONFIG_PATH") {
        Ok(path) => path,
        Err(_) => {
            // Fallback to common paths
            let fallback_paths = [
                "site/config",
                "content/config",
                "config",
                "../../site/config",
                "../../content/config",
                "../../../site/config",
                "../../../content/config",
            ];

            // Find first existing config directory
            let mut found_path = None;
            for path in &fallback_paths {
                if PathBuf::from(path).exists() {
                    found_path = Some(path.to_string());
                    break;
                }
            }

            match found_path {
                Some(path) => {
                    tracing::info!("Using fallback config path: {}", path);
                    path
                }
                None => {
                    tracing::warn!(
                        "No SITE_CONFIG_PATH set and no fallback config directories found"
                    );
                    return None;
                }
            }
        }
    };

    let routes_dir = PathBuf::from(&config_path).join("routes");

    if !routes_dir.exists() || !routes_dir.is_dir() {
        tracing::warn!("Routes directory not found at: {}", routes_dir.display());
        return None;
    }

    let mut all_routes = Vec::new();

    // Read all .toml files in the routes directory
    match fs::read_dir(&routes_dir) {
        Ok(entries) => {
            for entry in entries.flatten() {
                let path = entry.path();

                // Only process .toml files
                if path.extension().and_then(|s| s.to_str()) == Some("toml") {
                    match fs::read_to_string(&path) {
                        Ok(content) => match toml::from_str::<RoutesConfig>(&content) {
                            Ok(config) => {
                                tracing::info!("Loaded routes from: {}", path.display());
                                all_routes.extend(config.routes);
                            }
                            Err(e) => {
                                tracing::error!(
                                    "Failed to parse routes from {}: {}",
                                    path.display(),
                                    e
                                );
                            }
                        },
                        Err(e) => {
                            tracing::error!("Failed to read routes file {}: {}", path.display(), e);
                        }
                    }
                }
            }
        }
        Err(e) => {
            tracing::error!(
                "Failed to read routes directory {}: {}",
                routes_dir.display(),
                e
            );
            return None;
        }
    }

    if all_routes.is_empty() {
        tracing::warn!("No route files found in {}", routes_dir.display());
        return None;
    }

    tracing::info!(
        "Successfully loaded {} routes from {} .toml files",
        all_routes.len(),
        routes_dir.display()
    );
    Some(RoutesConfig { routes: all_routes })
}

/// Get fallback routes configuration for development/testing
fn get_fallback_routes_config() -> &'static RoutesConfig {
    use std::sync::OnceLock;
    static FALLBACK_CONFIG: OnceLock<RoutesConfig> = OnceLock::new();
    FALLBACK_CONFIG.get_or_init(|| RoutesConfig {
        routes: vec![RouteConfigToml {
            path: "/".to_string(),
            component: "HomePage".to_string(),
            title_key: "page-home-title".to_string(),
            language: "en".to_string(),
            enabled: true,
            description_key: Some("page-home-description".to_string()),
            keywords: None,
            priority: Some(1.0),
            menu_group: None,
            menu_order: None,
            menu_icon: None,
            is_external: None,
            requires_auth: None,
            show_in_sitemap: Some(true),
            alternate_paths: None,
            canonical_path: None,
            content_type: None,
            rendering_profile: None,
            template: None,
            props: None,
            unified_component: None,
            lang_prefixes: None,
            params_component: None,
        }],
    })
}

/// Load routes from TOML configuration using dependency injection pattern
///
/// Priority order:
/// 1. `ROUTES_REGISTRY` — populated by
///    `WebsiteResourceContributor::contribute_routes()` at startup from the
///    embedded config generated by build.rs (site/config.ncl source of truth).
/// 2. Filesystem scan of `site/config/routes/*.toml` (native builds only,
///    legacy fallback).
/// 3. Minimal fallback with a single `/` route (development / test
///    environments).
pub fn load_routes_config() -> &'static RoutesConfig {
    ROUTES_CACHE.get_or_init(|| {
        // Priority 1: ROUTES_REGISTRY populated via WebsiteResourceContributor at
        // startup. This is set before any request hits ROUTES_CACHE, so the
        // OnceLock always sees it.
        if crate::registration::routes_registered() {
            let registry = crate::registration::ROUTES_REGISTRY
                .read()
                .unwrap_or_else(|e| e.into_inner());

            let mut all_routes = Vec::new();
            for (lang, content) in registry.iter() {
                match toml::from_str::<RoutesConfig>(content) {
                    Ok(cfg) => {
                        tracing::info!(
                            "Routes loaded from registry for lang '{}' ({} routes)",
                            lang,
                            cfg.routes.len()
                        );
                        all_routes.extend(cfg.routes);
                    }
                    Err(e) => {
                        tracing::error!(
                            "Failed to parse embedded routes for lang '{}': {}",
                            lang,
                            e
                        );
                    }
                }
            }

            // The consumer's routes ride on top of the image's. Applied HERE and not only
            // in the filesystem branch, because that branch is
            // `#[cfg(not(has_generated_resources))]` — compiled OUT of any
            // binary that ships embedded routes, which is every shipped binary.
            // A fix that only lived there would be a fix nobody could run.
            let all_routes = merge_site_routes(all_routes);

            if !all_routes.is_empty() {
                tracing::info!(
                    "✓ Using {} routes from embedded config (site/config.ncl)",
                    all_routes.len()
                );
                return RoutesConfig { routes: all_routes };
            }
        }

        // Priority 2: Try to load from directory using manifest/fallback paths
        #[cfg(all(not(target_arch = "wasm32"), not(has_generated_resources)))]
        {
            if let Some(config) = load_routes_from_directory() {
                return RoutesConfig {
                    routes: merge_site_routes(config.routes),
                };
            }
        }

        // Fallback to single route configuration
        tracing::warn!(
            "Unable to load routes from registry or files, using fallback single route \
             configuration"
        );
        // Even the single-route fallback merges: a site with contributed routes and no
        // image config is a legitimate (if unusual) configuration, and silently
        // serving one route instead would be the worst kind of green.
        let fb = get_fallback_routes_config().clone();
        RoutesConfig {
            routes: merge_site_routes(fb.routes),
        }
    })
}

/// Get the current routes configuration from TOML
pub fn get_routing_config() -> &'static RoutesConfig {
    load_routes_config()
}

// ============================================================================
// ROUTE MAPPINGS (Bidirectional language translation)
// ============================================================================

/// Build route mappings from loaded configuration (dynamic for all languages)
///
/// Returns (forward_mappings, reverse_mappings) where:
/// - forward_mappings: default_language_path → target_language_path
/// - reverse_mappings: target_language_path → default_language_path
pub fn build_route_mappings() -> (
    std::collections::HashMap<String, String>,
    std::collections::HashMap<String, String>,
) {
    use std::collections::HashMap;

    use super::core_types::ROUTE_MAPPINGS_CACHE;

    // Use cached mappings if available
    if let Some(cached) = ROUTE_MAPPINGS_CACHE.get() {
        return cached.clone();
    }

    // Build mappings and cache them
    let mappings = build_route_mappings_uncached();
    let _ = ROUTE_MAPPINGS_CACHE.set(mappings.clone());
    mappings
}

/// Build route mappings without caching (internal implementation)
fn build_route_mappings_uncached() -> (
    std::collections::HashMap<String, String>,
    std::collections::HashMap<String, String>,
) {
    use std::collections::HashMap;

    let config = load_routes_config();
    let language_registry = get_language_registry();
    let available_languages = language_registry.get_available_languages();
    let default_language = language_registry.get_default_language();

    let mut mappings = HashMap::new();
    let mut reverse_mappings = HashMap::new();

    // Group routes by component (path, priority)
    let mut routes_by_component: HashMap<String, HashMap<String, (String, f32)>> = HashMap::new();

    // Build route mappings from configuration
    for route in &config.routes {
        if !route.enabled {
            continue;
        }

        let component_map = routes_by_component
            .entry(route.component.clone())
            .or_default();

        // Prioritize localized routes over /lang/ prefix routes and use priority values
        if let Some((existing_path, existing_priority)) = component_map.get(&route.language) {
            let existing_is_prefix = existing_path.starts_with(&format!("/{}/", route.language));
            let new_is_prefix = route.path.starts_with(&format!("/{}/", route.language));
            let new_priority = route.get_priority();

            // Prioritization logic:
            // 1. Prefer localized routes (non-prefix) over /lang/ prefix routes
            // 2. If both are same type (prefix/non-prefix), prefer higher priority
            let should_replace = if existing_is_prefix && !new_is_prefix {
                true // Always prefer localized over prefix
            } else if !existing_is_prefix && new_is_prefix {
                false // Always keep localized over prefix
            } else {
                // Both are same type, use priority
                new_priority > *existing_priority
            };

            if should_replace {
                component_map.insert(route.language.clone(), (route.path.clone(), new_priority));
            }
        } else {
            component_map.insert(
                route.language.clone(),
                (route.path.clone(), route.get_priority()),
            );
        }
    }

    // Build mappings between languages
    for component_routes in routes_by_component.values() {
        let default_route = component_routes.get(default_language);

        for language in &available_languages {
            if language == default_language {
                continue;
            }

            if let (Some((default_path, _)), Some((lang_path, _))) =
                (default_route, component_routes.get(language))
            {
                // Create bidirectional mapping
                mappings.insert(default_path.clone(), lang_path.clone());
                reverse_mappings.insert(lang_path.clone(), default_path.clone());
            }
        }
    }

    (mappings, reverse_mappings)
}

/// Check if a path is a valid route in the TOML configuration
pub fn is_valid_toml_route(path: &str) -> bool {
    let config = get_routing_config();
    config
        .routes
        .iter()
        .any(|route| route.enabled && route.path == path)
}

/// Get route configuration for a specific path and language
pub fn get_route_for_language(path: &str, language: &str) -> Option<&'static RouteConfigToml> {
    let config = get_routing_config();
    config
        .routes
        .iter()
        .find(|route| route.enabled && route.path == path && route.language == language)
}

/// Get all routes for a specific language
pub fn get_routes_for_language(language: &str) -> Vec<&'static RouteConfigToml> {
    let config = get_routing_config();
    config
        .routes
        .iter()
        .filter(|route| route.enabled && route.language == language)
        .collect()
}

/// Get all enabled routes
pub fn get_all_enabled_routes() -> Vec<&'static RouteConfigToml> {
    let config = get_routing_config();
    config.routes.iter().filter(|route| route.enabled).collect()
}

// build_route_mappings is defined above (lines 175-269) - no duplicate needed

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

    #[test]
    fn test_routing_config_access() {
        let config = get_routing_config();
        assert!(!config.routes.is_empty());
    }

    #[test]
    fn test_route_validation() {
        // Test with some basic paths that should exist
        let config = get_routing_config();
        let has_home = config.routes.iter().any(|r| r.path == "/" && r.enabled);
        assert!(has_home, "Should have a home route");
    }

    #[test]
    fn test_language_routes() {
        let config = get_routing_config();

        // Skip test if no routes are configured (e.g., in minimal test environments)
        if config.routes.is_empty() {
            println!("Skipping test - no routes configured");
            return;
        }

        let language_registry = crate::i18n::language_config::get_language_registry();
        let available_languages = language_registry.get_available_languages();

        // Only check languages that have configured routes
        for language in &available_languages {
            let routes = get_routes_for_language(language);
            if routes.is_empty() {
                println!("No routes configured for language: {language}, skipping assertion");
                continue;
            }
            assert!(
                !routes.is_empty(),
                "Should have routes for language {language}"
            );
        }
    }
}

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

    // The two halves of the contract, and they are inseparable: a site CAN add a
    // page the image never declared, and CANNOT smuggle in a component the
    // binary does not carry. Without the second half the first is a footgun — a
    // route that config promises and the renderer cannot serve, i.e. a 404 the
    // site declared to itself.
    #[test]
    fn site_may_add_a_page_and_may_not_add_a_compiled_component() {
        let dir = std::env::temp_dir().join("rustelo_site_routes_test");
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(&dir).unwrap();

        // A language the IMAGE never declared, with one legal page and one illegal
        // component.
        std::fs::write(
            dir.join("fr.toml"),
            r#"
[[routes]]
path = "/a-propos-de-la-langue"
component = "LenguajePage"
title_key = "lenguaje-page-title"
language = "fr"
enabled = true

[[routes]]
path = "/tableau-de-bord"
component = "AdminDashboard"
title_key = "admin-page-title"
language = "fr"
enabled = true
"#,
        )
        .unwrap();

        // The PURE loader, by path. No env var is set, so no other test's ROUTES_CACHE
        // is poisoned.
        let mut merged = Vec::new();
        for r in read_site_routes_dir(dir.to_str().unwrap()) {
            merged.push(r);
        }
        let _ = std::fs::remove_dir_all(&dir);

        // The `*Page` route survives — a consumer just gained a language the image does
        // not have.
        assert_eq!(merged.len(), 1, "exactly one route should survive");
        assert_eq!(merged[0].component, "LenguajePage");
        assert_eq!(merged[0].language, "fr");
        assert_eq!(merged[0].path, "/a-propos-de-la-langue");

        // The compiled component is REFUSED. It is not in the binary, and a route the
        // renderer cannot serve must never reach the router.
        assert!(
            !merged.iter().any(|r| r.component == "AdminDashboard"),
            "a non-`Page` component must be refused: a site cannot compile one"
        );

        // A directory that does not exist contributes NOTHING — which is the shipped
        // default for every existing consumer (the env var is unset, so the
        // loader is never even called).
        let base: RoutesConfig = toml::from_str(
            r#"
[[routes]]
path = "/privacy"
component = "PrivacyPage"
title_key = "privacy-page-title"
language = "en"
enabled = true
"#,
        )
        .unwrap();
        let mut untouched = base.routes;
        untouched.extend(read_site_routes_dir("/nonexistent/rustelo/site/routes"));
        assert_eq!(untouched.len(), 1, "no site routes dir ⇒ no contribution");
        assert_eq!(untouched[0].path, "/privacy");
    }
}
