//! Client build script - UnoCSS integration and asset optimization
//!
//! This build script handles:
//! - UnoCSS style generation from component usage
//! - Asset processing and optimization
//! - WASM-specific configuration constants
//! - Client-side route pre-compilation

use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::env;
use std::fmt::Debug;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use tera::{Context, Tera};
use walkdir::WalkDir;

/// UnoCSS configuration
#[derive(Deserialize, Serialize, Debug)]
pub struct UnocssConfig {
    pub content: Vec<String>,
    pub theme: HashMap<String, serde_json::Value>,
    pub shortcuts: HashMap<String, String>,
    pub presets: Vec<String>,
}

/// Smart cache for client assets
struct ClientCache {
    cache_dir: PathBuf,
}

impl ClientCache {
    fn new() -> Result<Self, Box<dyn std::error::Error>> {
        let cache_dir = PathBuf::from(env::var("OUT_DIR")?).join("..\\..\\.rustelo-cache\\client");
        fs::create_dir_all(&cache_dir)?;
        Ok(Self { cache_dir })
    }

    fn get_cache_key(&self, content: &str) -> String {
        let mut hasher = Sha256::new();
        hasher.update(content.as_bytes());
        hasher.finalize().iter().fold(String::new(), |mut s, b| {
            use std::fmt::Write;
            let _ = write!(s, "{b:02x}");
            s
        })
    }

    fn is_cached(&self, key: &str) -> bool {
        self.cache_dir.join(format!("{}.css", key)).exists()
    }

    fn read_cache(&self, key: &str) -> Result<String, Box<dyn std::error::Error>> {
        let cache_file = self.cache_dir.join(format!("{}.css", key));
        Ok(fs::read_to_string(cache_file)?)
    }

    fn write_cache(&self, key: &str, content: &str) -> Result<(), Box<dyn std::error::Error>> {
        let cache_file = self.cache_dir.join(format!("{}.css", key));
        fs::write(cache_file, content)?;
        Ok(())
    }
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Initialize build coordinator to prevent duplicate builds
    let _build_coordinator = if rustelo_tools::is_cache_available() {
        Some(rustelo_tools::BuildCoordinator::new("client")?)
    } else {
        println!("cargo:warning=Build coordinator not available - using direct build");
        None
    };

    // Use new PAP-compliant manifest system
    let manifest = rustelo_utils::ManifestResolver::load()?;
    let content_path = rustelo_utils::manifest_content_path();
    let config_path = rustelo_utils::manifest_config_path();
    let workspace_root = manifest.root();

    println!("cargo:rerun-if-changed={}/config", content_path.display());
    println!("cargo:rerun-if-changed={}", config_path.display());
    println!(
        "cargo:rerun-if-changed={}/site/config/index.ncl",
        workspace_root.display()
    );
    // NOTE: site/rbac.ncl is intentionally NOT a rerun-if-changed dependency.
    // Policy is delivered at runtime via WebSocket (RbacPolicyUpdated message).
    // Changing rbac.ncl does not require a WASM rebuild.
    println!("cargo:rerun-if-changed=src/");
    println!("cargo:rerun-if-changed=../pages/src/");
    println!(
        "cargo:rerun-if-changed={}/unocss.config.js",
        workspace_root.display()
    );
    println!(
        "cargo:rerun-if-changed={}/package.json",
        workspace_root.display()
    );

    // Initialize smart cache
    let cache = if rustelo_tools::is_cache_available() {
        Some(rustelo_tools::SmartCache::new("client")?)
    } else {
        println!("cargo:warning=Smart cache not available - using legacy cache");
        None
    };

    // Each generator returns the filename it wrote — no filename literals in main().
    let mut generated_files: Vec<&'static str> = Vec::new();

    generated_files.push(generate_client_config(&manifest)?);

    // manifest_constants.rs is internal — not included in lib.rs directly.
    generate_manifest_constants(&manifest)?;

    // theme_constants.rs is included directly by src/theme.rs — NOT via aggregator.
    // Including it in the aggregator would create a second `pub mod theme` at crate root,
    // conflicting with the `pub mod theme;` declaration in lib.rs.
    generate_theme_constants(&manifest)?;

    // Process UnoCSS with smart caching
    if let Some(ref smart_cache) = cache {
        if let Err(e) = process_unocss_smart(smart_cache) {
            println!(
                "cargo:warning=Smart UnoCSS processing failed: {}. Falling back to legacy method.",
                e
            );
            let legacy_cache = ClientCache::new()?;
            if let Err(e2) = process_unocss(&legacy_cache) {
                println!(
                    "cargo:warning=UnoCSS processing failed: {}. Using fallback CSS.",
                    e2
                );
                generate_fallback_css()?;
            }
        }
    } else {
        let legacy_cache = ClientCache::new()?;
        if let Err(e) = process_unocss(&legacy_cache) {
            println!(
                "cargo:warning=UnoCSS processing failed: {}. Falling back to basic CSS.",
                e
            );
            generate_fallback_css()?;
        }
    }

    generated_files.push(generate_client_routes(&manifest)?);
    generated_files.push(generate_page_provider_impl(&manifest)?);
    generated_files.push(generate_menu_registry_entries(&manifest)?);
    generated_files.push(generate_footer_registry_entries(&manifest)?);
    generated_files.push(generate_ftl_registry_entries(&manifest)?);
    generated_files.push(generate_palette_app_pages(&manifest)?);

    // Generate client documentation using rustelo_tools
    generate_client_documentation(&manifest)?;

    // Emit the aggregator from the registry built above.
    let out_dir = std::env::var("OUT_DIR")?;
    generate_includes_aggregator(&generated_files, &out_dir)?;

    println!("cargo:warning=Client build completed with smart caching and documentation");
    Ok(())
}

/// Emit `generated_includes.rs` from the filenames returned by each generator.
/// No filename is hardcoded here — generators own their artifact names.
fn generate_includes_aggregator(
    generated_files: &[&str],
    out_dir: &str,
) -> Result<(), Box<dyn std::error::Error>> {
    let mut content =
        String::from("// Auto-generated aggregator — do not edit directly.\n");
    for name in generated_files {
        content.push_str(&format!(
            "include!(concat!(env!(\"OUT_DIR\"), \"/{}\"));\n",
            name
        ));
    }
    std::fs::write(
        std::path::Path::new(out_dir).join("generated_includes.rs"),
        content,
    )?;
    Ok(())
}

fn generate_client_config(
    manifest_resolver: &rustelo_utils::ManifestResolver,
) -> Result<&'static str, Box<dyn std::error::Error>> {
    const OUTPUT: &str = "client_config.rs";
    let manifest = manifest_resolver.manifest();
    let mut tera = Tera::new("templates/**/*").unwrap_or_else(|err| {
        eprintln!(
            "Warning: Failed to load Tera templates: {}. Using default.",
            err
        );
        Tera::default()
    });

    let template = r#"
// Auto-generated client configuration constants
// Generated at build time from config.toml

/// Client-side configuration constants
pub mod config {
    pub const CONTENT_URL: &str = "{{ content_url }}";
    pub const DEFAULT_LANGUAGE: &str = "{{ default_language }}";
    pub const SUPPORTED_LANGUAGES: &[&str] = &[{% for lang in languages %}"{{ lang }}"{% if not loop.last %}, {% endif %}{% endfor %}];
    pub const CONTENT_TYPES: &[&str] = &[{% for content_type in content_types %}"{{ content_type }}"{% if not loop.last %}, {% endif %}{% endfor %}];

    {% if debug %}
    pub const DEBUG_MODE: bool = true;
    {% else %}
    pub const DEBUG_MODE: bool = false;
    {% endif %}
}
"#;

    tera.add_raw_template("client_config", template)?;

    let mut context = Context::new();

    // Extract configuration values
    context.insert("content_url", &manifest.deployment.content_url);
    context.insert("default_language", &manifest.discovery.default_lang);

    if manifest.discovery.languages != "auto" {
        context.insert("languages", &manifest.discovery.languages);
    } else {
        context.insert("languages", &vec!["en"]);
    }

    if manifest.discovery.content_types != "auto" {
        context.insert("content_types", &manifest.discovery.content_types);
    } else {
        context.insert("content_types", &vec!["page"]);
    }

    context.insert("debug", &manifest.build.debug);

    let generated_code = tera.render("client_config", &context)?;

    // Write generated configuration
    let out_dir = env::var("OUT_DIR")?;
    let config_file = Path::new(&out_dir).join(OUTPUT);
    fs::write(config_file, generated_code)?;

    Ok(OUTPUT)
}

fn generate_manifest_constants(
    manifest_resolver: &rustelo_utils::ManifestResolver,
) -> Result<(), Box<dyn std::error::Error>> {
    // Prefer rustelo.manifest.ncl; fall back to rustelo.manifest.toml.
    let ncl_path  = manifest_resolver.root().join("rustelo").join("manifest.ncl");
    let toml_path = manifest_resolver.root().join("rustelo.manifest.toml"); // legacy

    let manifest_content = if ncl_path.exists() {
        let output = std::process::Command::new("nickel")
            .args(["export", "--format", "json", ncl_path.to_str().unwrap_or("")])
            .output()
            .map_err(|e| format!("nickel export failed: {e}"))?;
        if !output.status.success() {
            return Err(String::from_utf8_lossy(&output.stderr).trim().to_string().into());
        }
        String::from_utf8(output.stdout)?
    } else if toml_path.exists() {
        fs::read_to_string(&toml_path)?
    } else {
        return Err(format!(
            "Manifest file not found at: {} (or .toml)",
            ncl_path.display()
        )
        .into());
    };

    let escaped_content = manifest_content.replace('\\', "\\\\").replace('"', "\\\"");

    let manifest_constants = format!(
        "// Auto-generated manifest constants for WASM client\n\n\
         /// Raw manifest content (JSON or TOML) embedded at build time\n\
         pub const MANIFEST_CONTENT: &str = r#\"{escaped_content}\"#;\n"
    );

    let out_dir = env::var("OUT_DIR")?;
    let manifest_file = Path::new(&out_dir).join("manifest_constants.rs");
    fs::write(manifest_file, manifest_constants)?;

    Ok(())
}

fn process_unocss(cache: &ClientCache) -> Result<(), Box<dyn std::error::Error>> {
    let workspace_root = rustelo_utils::manifest::manifest_root();
    // Check if UnoCSS is configured
    let unocss_config = workspace_root.join("uno.config.ts");
    let package_json = workspace_root.join("package.json");

    if !unocss_config.exists() || !package_json.exists() {
        return Err("UnoCSS not configured".into());
    }

    // Scan for CSS classes in Rust files
    let class_usage = scan_for_css_classes(&workspace_root)?;
    let cache_key = cache.get_cache_key(&class_usage);

    let generated_css = if cache.is_cached(&cache_key) {
        println!("cargo:warning=Using cached UnoCSS output");
        cache.read_cache(&cache_key)?
    } else {
        println!("cargo:warning=Generating UnoCSS styles");

        // Write detected classes to temp file for UnoCSS processing
        let temp_classes_file = workspace_root.join("works-pv").join("temp_classes.txt");
        fs::write(&temp_classes_file, &class_usage)?;

        // Run UnoCSS
        let output = Command::new("npm")
            .args(["run", "css:build"])
            .current_dir(&workspace_root)
            .output()?;

        if !output.status.success() {
            return Err(format!(
                "UnoCSS build failed: {}",
                String::from_utf8_lossy(&output.stderr)
            )
            .into());
        }

        // Read generated CSS
        let css_output = workspace_root.join("dist").join("styles.css");
        if css_output.exists() {
            let css_content = fs::read_to_string(&css_output)?;
            cache.write_cache(&cache_key, &css_content)?;

            // Clean up
            let _ = fs::remove_file(&temp_classes_file);

            css_content
        } else {
            return Err("UnoCSS output not found".into());
        }
    };

    // Embed CSS as constant
    let out_dir = env::var("OUT_DIR")?;
    let css_file = Path::new(&out_dir).join("styles.rs");
    fs::write(
        css_file,
        format!(
            "pub const STYLES: &str = r#\"{}\"#;",
            generated_css.replace('\"', "\\\"")
        ),
    )?;

    Ok(())
}

fn scan_for_css_classes(workspace_root: &Path) -> Result<String, Box<dyn std::error::Error>> {
    let mut classes = std::collections::HashSet::new();

    // Scan Rust files for class attributes, excluding build scripts and generated files
    for entry in WalkDir::new(workspace_root.join("crates")) {
        let entry = entry?;
        if entry.path().extension().is_some_and(|ext| ext == "rs") {
            // Skip build scripts and generated files that might change between builds
            let file_name = entry
                .path()
                .file_name()
                .and_then(|n| n.to_str())
                .unwrap_or("unknown");
            let path_str = entry.path().to_str().unwrap_or("");

            if file_name == "build.rs"
                || file_name.starts_with("build_")
                || path_str.contains("/target/")
                || path_str.contains("/generated/")
            {
                continue;
            }

            let content = fs::read_to_string(entry.path())?;

            // Simple regex to find class attributes (can be enhanced)
            for line in content.lines() {
                if line.contains("class=") {
                    // Extract classes between quotes
                    if let Some(start) = line.find("class=\"") {
                        let start = start + 7; // Skip 'class="'
                        if let Some(end) = line[start..].find('"') {
                            let class_str = &line[start..start + end];
                            for class in class_str.split_whitespace() {
                                classes.insert(class.to_string());
                            }
                        }
                    }
                }
            }
        }
    }

    // Sort classes for deterministic hash generation
    let mut sorted_classes: Vec<_> = classes.into_iter().collect();
    sorted_classes.sort();
    Ok(sorted_classes.join(" "))
}

fn generate_fallback_css() -> Result<(), Box<dyn std::error::Error>> {
    let basic_css = r#"
/* Basic fallback styles for Rustelo website */
body { font-family: system-ui, sans-serif; line-height: 1.6; margin: 0; }
.container { max-width: 1200px; margin: 0 auto; padding: 0 1rem; }
.btn { display: inline-block; padding: 0.5rem 1rem; border: none; border-radius: 0.25rem; cursor: pointer; }
.btn-primary { background: #3b82f6; color: white; }
.text-center { text-align: center; }
.mt-4 { margin-top: 1rem; }
.mb-4 { margin-bottom: 1rem; }
.grid { display: grid; gap: 1rem; }
.grid-cols-2 { grid-template-columns: repeat(2, 1fr); }
.grid-cols-3 { grid-template-columns: repeat(3, 1fr); }
"#;

    let out_dir = env::var("OUT_DIR")?;
    let css_file = Path::new(&out_dir).join("styles.rs");
    fs::write(
        css_file,
        format!(
            "pub const STYLES: &str = r#\"{}\"#;",
            basic_css.replace('\"', "\\\"")
        ),
    )?;

    Ok(())
}

fn generate_client_routes(
    manifest_resolver: &rustelo_utils::ManifestResolver,
) -> Result<&'static str, Box<dyn std::error::Error>> {
    const OUTPUT: &str = "client_routes.rs";
    let manifest_dir = manifest_resolver.root().to_path_buf();

    let mut all_routes: HashMap<String, HashMap<String, String>> = HashMap::new();
    let mut path_to_component: HashMap<String, String> = HashMap::new();
    let mut path_to_content_type: HashMap<String, String> = HashMap::new();
    // Tracks all languages that share each path — used below to exclude language-neutral paths.
    let mut path_language_set: HashMap<String, std::collections::HashSet<String>> = HashMap::new();

    // Load from site/config/index.ncl — strict, no fallback
    let site_cfg = build_config::site_config::load_unified_site_config(&manifest_dir)
        .unwrap_or_else(|e| {
            panic!(
                "Cannot load site/config/index.ncl for client routes: {e}\n\
             Install Nickel: cargo install nickel-lang-cli"
            )
        });

    // rbac_deny_patterns intentionally empty: WASM policy is driven entirely at runtime
    // via the WebSocket RbacPolicyUpdated message.  No compile-time coupling to rbac.ncl.
    let rbac_deny_patterns: Vec<String> = Vec::new();

    println!(
        "cargo:warning=Client: Using site/config/index.ncl for routes ({} languages)",
        site_cfg.languages().len()
    );
    for (lang, routes) in site_cfg.routes_expanded() {
        let mut lang_routes = HashMap::new();
        for route in routes {
            if route.is_external.unwrap_or(false) || !route.path.starts_with('/') {
                continue;
            }
            // Key encodes component + content_type + param arity so that routes like
            // ContentIndex/projects (0 params) and ContentIndex/projects/{category} (1 param)
            // don't overwrite each other in the map.
            let param_count = route
                .path
                .split('/')
                .filter(|s| s.starts_with('{') && s.ends_with('}'))
                .count();
            let route_key = match route.content_type.as_deref() {
                Some(ct) => format!("{}_{}_{}", route.component.to_lowercase(), ct, param_count),
                None => format!("{}_{}", route.component.to_lowercase(), param_count),
            };
            lang_routes.insert(route_key, route.path.clone());
            path_to_component
                .entry(route.path.clone())
                .or_insert_with(|| route.component.clone());
            // Track which languages each path belongs to — shared paths (same URL in
            // multiple languages, e.g. "/blog" for both EN and ES) must be excluded from
            // path_to_language so get_language_for_path returns None for them, allowing
            // the caller to preserve the current language instead of forcing a switch.
            path_language_set
                .entry(route.path.clone())
                .or_default()
                .insert(lang.clone());
            if let Some(ct) = route.content_type.as_deref() {
                path_to_content_type
                    .entry(route.path.clone())
                    .or_insert_with(|| ct.to_string());
            }
        }
        all_routes.insert(lang, lang_routes);
    }

    // Build path_to_language: only include paths that unambiguously belong to ONE language.
    // Paths shared across languages (en = "/blog", es = "/blog") are intentionally excluded.
    let mut path_to_language: HashMap<String, String> = HashMap::new();
    for (path, langs) in &path_language_set {
        if langs.len() == 1 {
            if let Some(lang) = langs.iter().next() {
                path_to_language.insert(path.clone(), lang.clone());
            }
        }
    }

    // Build path_localization: from_path → (to_lang → new_path)
    // Enables WASM-safe URL switching when the user changes language.
    let mut path_localization: HashMap<String, HashMap<String, String>> = HashMap::new();
    for (from_path, component) in &path_to_component {
        let param_count = from_path
            .split('/')
            .filter(|s| s.starts_with('{') && s.ends_with('}'))
            .count();
        let comp_key = match path_to_content_type.get(from_path) {
            Some(ct) => format!("{}_{}_{}", component.to_lowercase(), ct, param_count),
            None => format!("{}_{}", component.to_lowercase(), param_count),
        };
        let mut lang_map: HashMap<String, String> = HashMap::new();
        for (to_lang, lang_routes) in &all_routes {
            if let Some(new_path) = lang_routes.get(&comp_key) {
                if new_path != from_path {
                    lang_map.insert(to_lang.clone(), new_path.clone());
                }
            }
        }
        if !lang_map.is_empty() {
            path_localization.insert(from_path.clone(), lang_map);
        }
    }

    // Parametric patterns (paths containing `{...}` segments) for runtime matching
    #[derive(Serialize)]
    struct ParametricCompPattern {
        path: String,
        component: String,
    }
    #[derive(Serialize)]
    struct ParametricLangPattern {
        path: String,
        lang: String,
    }
    #[derive(Serialize)]
    struct ParametricCtPattern {
        path: String,
        ct: String,
    }

    let parametric_patterns: Vec<ParametricCompPattern> = path_to_component
        .iter()
        .filter(|(p, _)| p.contains('{'))
        .map(|(path, comp)| ParametricCompPattern {
            path: path.clone(),
            component: comp.clone(),
        })
        .collect();

    let parametric_lang_patterns: Vec<ParametricLangPattern> = path_to_language
        .iter()
        .filter(|(p, _)| p.contains('{'))
        .map(|(path, lang)| ParametricLangPattern {
            path: path.clone(),
            lang: lang.clone(),
        })
        .collect();

    let parametric_ct_patterns: Vec<ParametricCtPattern> = path_to_content_type
        .iter()
        .filter(|(p, _)| p.contains('{'))
        .map(|(path, ct)| ParametricCtPattern {
            path: path.clone(),
            ct: ct.clone(),
        })
        .collect();

    // Flat list of route entries for code generation (avoids Tera map key lookup limitations)
    #[derive(Serialize)]
    struct RouteEntry {
        path: String,
        component: String,
        language: String,
        content_type: Option<String>,
    }
    let route_entries: Vec<RouteEntry> = path_to_component
        .iter()
        .map(|(path, component)| {
            let language = path_to_language
                .get(path)
                .cloned()
                .unwrap_or_else(|| "en".to_string());
            let content_type = path_to_content_type.get(path).cloned();
            RouteEntry {
                path: path.clone(),
                component: component.clone(),
                language,
                content_type,
            }
        })
        .collect();

    let mut tera = Tera::new("templates/**/*").unwrap_or_else(|err| {
        eprintln!(
            "Warning: Failed to load Tera templates: {}. Using default.",
            err
        );
        Tera::default()
    });

    let template = r#"
// Auto-generated client-side route constants
// Generated from site/config/routes/*.toml

/// Client-side route constants for navigation
pub mod routes {
    {% for lang, routes in languages %}
    pub mod {{ lang }} {
        {% for route_name, route_path in routes %}
        pub const {{ route_name | upper }}: &str = "{{ route_path }}";
        {% endfor %}
    }
    {% endfor %}

    /// Get route path for language
    pub fn get_route(language: &str, route_name: &str) -> Option<&'static str> {
        match (language, route_name) {
            {% for lang, routes in languages %}
            {% for route_name, route_path in routes %}
            ("{{ lang }}", "{{ route_name }}") => Some("{{ route_path }}"),
            {% endfor %}
            {% endfor %}
            _ => None,
        }
    }

    /// Get component name for a given path (reverse route lookup)
    pub fn get_component_for_path(path: &str) -> Option<&'static str> {
        let exact = match path {
            {% for route_path, component_name in path_to_component %}
            "{{ route_path }}" => Some("{{ component_name }}"),
            {% endfor %}
            _ => None,
        };
        if exact.is_some() { return exact; }
        static COMP_PATTERNS: &[(&str, &str)] = &[
            {% for p in parametric_patterns %}("{{ p.path }}", "{{ p.component }}"),
            {% endfor %}
        ];
        let path_segs: Vec<&str> = path.split('/').collect();
        for (pattern, comp) in COMP_PATTERNS {
            let pat_segs: Vec<&str> = pattern.split('/').collect();
            if pat_segs.len() != path_segs.len() { continue; }
            if pat_segs.iter().zip(path_segs.iter()).all(|(p, s)| (p.starts_with('{') && p.ends_with('}')) || p == s) {
                return Some(comp);
            }
        }
        None
    }

    /// Get language for a given path (reverse route lookup — WASM-safe, no filesystem access)
    pub fn get_language_for_path(path: &str) -> Option<&'static str> {
        let exact = match path {
            {% for route_path, lang in path_to_language %}
            "{{ route_path }}" => Some("{{ lang }}"),
            {% endfor %}
            _ => None,
        };
        if exact.is_some() { return exact; }
        static LANG_PATTERNS: &[(&str, &str)] = &[
            {% for p in parametric_lang_patterns %}("{{ p.path }}", "{{ p.lang }}"),
            {% endfor %}
        ];
        let path_segs: Vec<&str> = path.split('/').collect();
        for (pattern, lang) in LANG_PATTERNS {
            let pat_segs: Vec<&str> = pattern.split('/').collect();
            if pat_segs.len() != path_segs.len() { continue; }
            if pat_segs.iter().zip(path_segs.iter()).all(|(p, s)| (p.starts_with('{') && p.ends_with('}')) || p == s) {
                return Some(lang);
            }
        }
        None
    }

    /// Compile-time anonymous deny patterns from `site/rbac.ncl`.
    /// Used by `policy::init_policy_signal` as the initial value for the runtime signal
    /// so SSR and the first WASM render produce identical DOM (hydration safe).
    pub static AUTH_PATTERNS: &[&str] = &[
        {% for p in rbac_deny_patterns %}"{{ p }}",
        {% endfor %}
    ];

    /// Returns true when `path` requires an authenticated user.
    ///
    /// Derived exclusively from `site/rbac.ncl` `defaults.unauthenticated_allow` deny entries —
    /// single source of truth.  Pattern semantics match the RBAC policy:
    ///   - `"/foo"` → exact match
    ///   - `"/foo/*"` → prefix match (`/foo/...`)
    pub fn requires_auth_for_path(path: &str) -> bool {
        for pattern in AUTH_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
    }

    /// Get content_type for a given path — derived from [routes.props] content_type (WASM-safe).
    pub fn get_content_type_for_path(path: &str) -> Option<&'static str> {
        let exact = match path {
            {% for route_path, ct in path_to_content_type %}
            "{{ route_path }}" => Some("{{ ct }}"),
            {% endfor %}
            _ => None,
        };
        if exact.is_some() { return exact; }
        static CT_PATTERNS: &[(&str, &str)] = &[
            {% for p in parametric_ct_patterns %}("{{ p.path }}", "{{ p.ct }}"),
            {% endfor %}
        ];
        let path_segs: Vec<&str> = path.split('/').collect();
        for (pattern, ct) in CT_PATTERNS {
            let pat_segs: Vec<&str> = pattern.split('/').collect();
            if pat_segs.len() != path_segs.len() { continue; }
            if pat_segs.iter().zip(path_segs.iter()).all(|(p, s)| (p.starts_with('{') && p.ends_with('}')) || p == s) {
                return Some(ct);
            }
        }
        None
    }

    /// Translate a path to its equivalent in the target language (WASM-safe).
    /// Returns None if path is already in target language or no translation is known.
    pub fn get_localized_path(path: &str, to_lang: &str) -> Option<&'static str> {
        match (path, to_lang) {
            {% for from_path, lang_map in path_localization %}
            {% for to_lang, new_path in lang_map %}
            ("{{ from_path }}", "{{ to_lang }}") => Some("{{ new_path }}"),
            {% endfor %}
            {% endfor %}
            _ => None,
        }
    }

    /// Seed the framework ROUTES_CACHE with build-time route data (WASM only).
    /// Must be called before any code that uses `load_routes_config()` to ensure
    /// language switching and route resolution work without filesystem access.
    pub fn seed_routes_cache() {
        use rustelo_core_lib::routing::core_types::ROUTES_CACHE;
        use rustelo_core_lib::routing::{RouteConfigToml, RoutesConfig};
        let routes = vec![
            {% for entry in route_entries %}
            RouteConfigToml {
                path: "{{ entry.path }}".to_string(),
                component: "{{ entry.component }}".to_string(),
                title_key: "page-title".to_string(),
                language: "{{ entry.language }}".to_string(),
                enabled: true,
                description_key: None,
                keywords: None,
                priority: None,
                menu_group: None,
                menu_order: None,
                menu_icon: None,
                is_external: None,
                requires_auth: None,
                show_in_sitemap: None,
                alternate_paths: None,
                canonical_path: None,
                content_type: {% if entry.content_type %}Some("{{ entry.content_type }}".to_string()){% else %}None{% endif %},
                rendering_profile: None,
                template: None,
                props: None,
                unified_component: None,
                lang_prefixes: None,
                params_component: None,
            },
            {% endfor %}
        ];
        let _ = ROUTES_CACHE.set(RoutesConfig { routes });
    }
}
"#;

    tera.add_raw_template("client_routes", template)?;

    let mut context = Context::new();
    context.insert("languages", &all_routes);
    context.insert("path_to_component", &path_to_component);
    context.insert("path_to_language", &path_to_language);
    context.insert("path_to_content_type", &path_to_content_type);
    context.insert("path_localization", &path_localization);
    context.insert("route_entries", &route_entries);
    context.insert("rbac_deny_patterns", &rbac_deny_patterns);
    context.insert("parametric_patterns", &parametric_patterns);
    context.insert("parametric_lang_patterns", &parametric_lang_patterns);
    context.insert("parametric_ct_patterns", &parametric_ct_patterns);

    let generated_code = tera.render("client_routes", &context)?;

    // Write generated routes
    let out_dir = env::var("OUT_DIR")?;
    let routes_file = Path::new(&out_dir).join(OUTPUT);
    fs::write(routes_file, generated_code)?;

    Ok(OUTPUT)
}

/// Process UnoCSS using rustelo_tools SmartCache
fn process_unocss_smart(
    cache: &rustelo_tools::SmartCache,
) -> Result<(), Box<dyn std::error::Error>> {
    let resolver = rustelo_utils::ManifestResolver::load()?;
    let workspace_root = resolver.root();

    // Check if UnoCSS is configured
    let unocss_config = workspace_root.join("uno.config.ts");
    let package_json = workspace_root.join("package.json");

    if !unocss_config.exists() || !package_json.exists() {
        return Err("UnoCSS not configured".into());
    }

    // Scan for CSS classes in Rust files
    let class_usage = scan_for_css_classes(workspace_root)?;

    // Create dependencies list for cache validation
    let dependencies = vec![
        unocss_config.to_string_lossy().to_string(),
        package_json.to_string_lossy().to_string(),
    ];

    // Calculate hash for CSS classes
    let source_hash = rustelo_tools::hash_route_config(&class_usage, &dependencies);
    let filename = "styles.css";

    // Debug cache status
    println!(
        "cargo:warning=SmartCache debug: source_hash={}, filename={}",
        source_hash, filename
    );

    // Check cache status
    match cache.check_file_cache(filename, &source_hash)? {
        rustelo_tools::CacheStatus::Fresh(_) => {
            // Use cached CSS - fast path!
            let out_dir = std::env::var("OUT_DIR")?;
            let out_path = std::path::Path::new(&out_dir);
            cache.copy_from_cache(filename, out_path)?;

            // Generate styles.rs from cached CSS
            let cached_css = std::fs::read_to_string(out_path.join(filename))?;
            let css_file = out_path.join("styles.rs");
            std::fs::write(
                css_file,
                format!(
                    "pub const STYLES: &str = r#\"{}\"#;",
                    cached_css.replace('\"', "\\\"")
                ),
            )?;

            println!("cargo:warning=✅ Using cached UnoCSS output (CACHE HIT)");
        }
        rustelo_tools::CacheStatus::Stale(_) => {
            // Generate new CSS - slow path
            println!("cargo:warning=⚠️ Generating UnoCSS styles (CACHE STALE)");
        }
        rustelo_tools::CacheStatus::Missing(_) => {
            // Generate new CSS - slow path
            println!("cargo:warning=🔄 Generating UnoCSS styles (CACHE MISS)");

            // Write detected classes to temp file for UnoCSS processing
            let temp_classes_file = workspace_root.join("works-pv").join("temp_classes.txt");
            std::fs::write(&temp_classes_file, &class_usage)?;

            // Run UnoCSS
            let output = std::process::Command::new("npm")
                .args(["run", "css:build"])
                .current_dir(workspace_root)
                .output()?;

            if !output.status.success() {
                return Err(format!(
                    "UnoCSS build failed: {}",
                    String::from_utf8_lossy(&output.stderr)
                )
                .into());
            }

            // Read generated CSS
            let css_output = workspace_root.join("dist").join("styles.css");
            if css_output.exists() {
                let css_content = std::fs::read_to_string(&css_output)?;

                // Update cache with new content
                let out_dir = std::env::var("OUT_DIR")?;
                let out_path = std::path::Path::new(&out_dir);
                cache.update_cache(filename, &css_content, &source_hash, out_path, dependencies)?;

                // Generate styles.rs
                let css_file = out_path.join("styles.rs");
                std::fs::write(
                    css_file,
                    format!(
                        "pub const STYLES: &str = r#\"{}\"#;",
                        css_content.replace('\"', "\\\"")
                    ),
                )?;

                // Clean up
                let _ = std::fs::remove_file(&temp_classes_file);

                println!("cargo:warning=Generated and cached new UnoCSS styles");
            } else {
                return Err("UnoCSS output not found".into());
            }
        }
    }

    Ok(())
}

/// Generate client documentation using rustelo_tools comprehensive_analysis
fn generate_client_documentation(
    manifest: &rustelo_utils::ManifestResolver,
) -> Result<(), Box<dyn std::error::Error>> {
    let project_root = manifest.root();
    let info_path = project_root.join("site").join("info");

    // Create info directory if it doesn't exist
    std::fs::create_dir_all(&info_path)?;

    // Generate client documentation
    rustelo_tools::build::generate_enhanced_client_documentation(
        &project_root.to_string_lossy(),
        &info_path.to_string_lossy(),
    )?;

    println!("cargo:warning=Generated client documentation in site/info");

    Ok(())
}

/// Generate theme configuration constants for WASM client
fn generate_theme_constants(
    manifest_resolver: &rustelo_utils::ManifestResolver,
) -> Result<&'static str, Box<dyn std::error::Error>> {
    const OUTPUT: &str = "theme_constants.rs";
    // Single source of truth: site/config/index.ncl logo section
    let manifest_dir = manifest_resolver.root().to_path_buf();
    let site_cfg = build_config::site_config::load_unified_site_config(&manifest_dir)?;
    let logo = &site_cfg.logo;

    let width_const = match &logo.width {
        Some(w) => format!("Some(\"{}\")", w),
        None => "None".to_string(),
    };
    let height_const = match &logo.height {
        Some(h) => format!("Some(\"{}\")", h),
        None => "None".to_string(),
    };

    let theme_constants = format!(
        r#"/// Theme configuration constants embedded for WASM client
/// Generated at build time from site/config/index.ncl [logo]
#[allow(clippy::module_inception)]
pub mod theme {{
    pub const DEFAULT_THEME_NAME: &str = "default";

    /// Pre-formatted TOML for populating THEME_REGISTRY at WASM startup.
    /// Avoids hardcoding site-specific values in lib.rs.
    pub const THEME_TOML: &str = "[assets]\\nlogo_light = \\\"{}\\\"\\nlogo_dark = \\\"{}\\\"\\nlogo_alt = \\\"{}\\\"\\n";

    /// Logo configuration — single source of truth: site/config/index.ncl
    pub mod logo {{
        pub const LIGHT_SRC: &str = "{}";
        pub const DARK_SRC: &str = "{}";
        pub const ALT: &str = "{}";
        pub const SHOW_IN_NAV: bool = {};
        pub const SHOW_IN_FOOTER: bool = {};
        pub const WIDTH: Option<&str> = {};
        pub const HEIGHT: Option<&str> = {};
    }}
}}
"#,
        logo.light_src,
        logo.dark_src,
        logo.alt,
        logo.light_src,
        logo.dark_src,
        logo.alt,
        logo.show_in_nav,
        logo.show_in_footer,
        width_const,
        height_const
    );

    let out_dir = env::var("OUT_DIR")?;
    let theme_file = Path::new(&out_dir).join(OUTPUT);
    fs::write(theme_file, theme_constants)?;

    println!("cargo:warning=Generated theme constants from site/config/index.ncl");
    Ok(OUTPUT)
}

/// Generate PageProvider implementation using generated page components
fn generate_page_provider_impl(
    manifest_resolver: &rustelo_utils::ManifestResolver,
) -> Result<&'static str, Box<dyn std::error::Error>> {
    const OUTPUT: &str = "page_provider_impl.rs";
    let manifest = manifest_resolver.manifest();
    let cache_build_path = &manifest.build.cache_build_path;
    let manifest_dir = manifest_resolver.root();

    // Read generated page files from cache_build_path
    let pages_cache_dir = manifest_dir
        .join("target")
        .join(cache_build_path)
        .join("pages");

    if !pages_cache_dir.exists() {
        println!(
            "cargo:warning=Pages cache directory not found: {}",
            pages_cache_dir.display()
        );
        return generate_fallback_page_provider_impl();
    }

    // Find all page_*.rs files
    let mut page_files = Vec::new();
    for entry in fs::read_dir(&pages_cache_dir)? {
        let entry = entry?;
        let path = entry.path();
        if let Some(name) = path.file_name() {
            if let Some(name_str) = name.to_str() {
                if name_str.starts_with("page_") && name_str.ends_with(".rs") {
                    // Extract component name from filename (page_NAME.rs -> NAME)
                    if let Some(component_name) = name_str
                        .strip_prefix("page_")
                        .and_then(|s| s.strip_suffix(".rs"))
                    {
                        page_files.push((component_name.to_string(), path.clone()));
                    }
                }
            }
        }
    }

    if page_files.is_empty() {
        println!("cargo:warning=No page files found in cache, generating fallback");
        return generate_fallback_page_provider_impl();
    }

    // Log discovered components for debugging
    println!(
        "cargo:warning=Dynamically discovered {} components in cache:",
        page_files.len()
    );
    for (component_name, _) in &page_files {
        println!("cargo:warning=  - {}", component_name);
    }

    // Generate the PageProvider implementation
    let mut provider_impl = String::from(
        r#"// Auto-generated PageProvider implementation
// Dynamically generated from ALL components found in cache_build_path

#[allow(unused_imports)] // False positive: AnyView and leptos components are used but not always detected by static analysis
use leptos::prelude::*;
use rustelo_core_lib::{PageProvider, ComponentResult};
use std::collections::HashMap;

// Include all generated page wrapper components
"#,
    );

    // Add include statements for ALL page files found in cache (truly dynamic)
    for (component_name, _) in &page_files {
        // Include ALL components found in cache, not just those in current routes
        // This makes the system adapt automatically to new/changed components
        provider_impl.push_str(&format!(
            "include!(concat!(env!(\"CARGO_MANIFEST_DIR\"), \"/../../target/{}/pages/page_{}.rs\"));\n",
            cache_build_path, component_name
        ));
    }

    provider_impl.push_str(r#"
/// Website-specific PageProvider implementation
pub struct WebsitePageProvider;

impl PageProvider for WebsitePageProvider {
    type Component = String;
    type View = AnyView;
    type Props = HashMap<String, String>;

    fn get_component(&self, component_id: &str) -> ComponentResult<Self::Component> {
        Ok(component_id.to_string())
    }

    fn render_component(&self, component: &Self::Component, _props: Self::Props, language: &str) -> ComponentResult<Self::View> {
        let lang = language.to_string();

        let view = match component.as_str() {
"#);

    // Add match arms for ALL components found in cache (fully dynamic) with proper props
    for (component_name, _) in &page_files {
        // Generate component calls with required props (PAP-compliant with safe defaults)
        let component_call = match component_name.as_str() {
            "ContentCategory" => format!("            \"{}\" => {{ let content_type = _props.get(\"content_type\").cloned().unwrap_or_else(|| \"blog\".to_string()); view! {{ <{}Page _language=lang.clone() category=\"default\".to_string() content_type=content_type /> }}.into_any() }},\n", component_name, component_name),
            "ContentIndex" => format!("            \"{}\" => {{ let content_type = _props.get(\"content_type\").cloned().unwrap_or_else(|| \"blog\".to_string()); view! {{ <{}Page _language=lang.clone() content_type=content_type /> }}.into_any() }},\n", component_name, component_name),
            "PostViewer" => format!("            \"{}\" => {{ let content_type = _props.get(\"content_type\").cloned().unwrap_or_else(|| \"blog\".to_string()); let slug = _props.get(\"slug\").cloned().unwrap_or_default(); view! {{ <{}Page _language=lang.clone() slug=slug content_type=content_type /> }}.into_any() }},\n", component_name, component_name),
            _ => format!("            \"{}\" => view! {{ <{}Page _language=lang.clone() /> }}.into_any(),\n", component_name, component_name),
        };

        provider_impl.push_str(&component_call);
    }

    let not_found_in_cache = page_files.iter().any(|(n, _)| n == "NotFound");
    let wildcard_arm = if not_found_in_cache {
        "            _ => {\n                view! { <NotFoundPage _language=lang /> }.into_any()\n            }\n"
    } else {
        "            _ => {\n                let _lang = lang;\n                view! { <div class=\"p-8\"><h1>\"404\"</h1><p>\"Page not found\"</p></div> }.into_any()\n            }\n"
    };
    provider_impl.push_str(wildcard_arm);
    provider_impl.push_str(
        r#"        };

        Ok(view)
    }

    fn list_components(&self) -> Vec<String> {
        vec![
"#,
    );

    // Add ALL components found in cache to list (fully dynamic)
    for (component_name, _) in &page_files {
        provider_impl.push_str(&format!(
            "            \"{}\".to_string(),\n",
            component_name
        ));
    }

    provider_impl.push_str(
        r#"        ]
    }
}
"#,
    );

    // Write to OUT_DIR
    let out_dir = env::var("OUT_DIR")?;
    let provider_file = Path::new(&out_dir).join(OUTPUT);
    fs::write(provider_file, provider_impl)?;

    println!(
        "cargo:warning=Generated PageProvider implementation with {} components",
        page_files.len()
    );
    Ok(OUTPUT)
}

/// Generate fallback PageProvider when no pages are found - still dynamic!
fn generate_fallback_page_provider_impl() -> Result<&'static str, Box<dyn std::error::Error>> {
    const OUTPUT: &str = "page_provider_impl.rs";
    // Even fallback should try to discover from rustelo_pages_leptos dynamically
    let fallback_impl = r#"// Dynamic PageProvider implementation
// Uses rustelo_pages_leptos components discovered at build time

#[allow(unused_imports)] // False positive: view! macro and AnyView are used but not detected by static analysis
use leptos::prelude::*;
use rustelo_core_lib::{PageProvider, ComponentResult};
use rustelo_pages_leptos::*;
use std::collections::HashMap;

/// Dynamic PageProvider that uses available rustelo_pages_leptos components
pub struct WebsitePageProvider;

impl PageProvider for WebsitePageProvider {
    type Component = String;
    type View = AnyView;
    type Props = HashMap<String, String>;

    fn get_component(&self, component_id: &str) -> ComponentResult<Self::Component> {
        Ok(component_id.to_string())
    }

    fn render_component(&self, component: &Self::Component, _props: Self::Props, language: &str) -> ComponentResult<Self::View> {
        let lang = language.to_string();

        // Try to render using available components, with NotFound as fallback
        let view = match component.as_str() {
            _ => {
                // Log unknown component for debugging
                #[cfg(target_arch = "wasm32")]
                web_sys::console::log_1(&format!("Unknown component '{}', using NotFound", component).into());

                view! { <NotFoundPage _language=lang /> }.into_any()
            }
        };

        Ok(view)
    }

    fn list_components(&self) -> Vec<String> {
        // Return empty list - components will be discovered dynamically
        vec![]
    }
}
"#;

    let out_dir = env::var("OUT_DIR")?;
    let provider_file = Path::new(&out_dir).join(OUTPUT);
    fs::write(provider_file, fallback_impl)?;

    println!("cargo:warning=Generated minimal PageProvider (no cache found)");
    Ok(OUTPUT)
}

/// Generates `menu_registry_fn.rs` in OUT_DIR from site/config/index.ncl — strict, no fallback.
fn generate_menu_registry_entries(
    manifest_resolver: &rustelo_utils::ManifestResolver,
) -> Result<&'static str, Box<dyn std::error::Error>> {
    const OUTPUT: &str = "menu_registry_fn.rs";
    let manifest_dir = manifest_resolver.root();

    let site_cfg = build_config::site_config::load_unified_site_config(manifest_dir)
        .unwrap_or_else(|e| {
            panic!(
                "Cannot load site/config/index.ncl for WASM menu registry: {e}\n\
             Install Nickel: cargo install nickel-lang-cli"
            )
        });

    let mut code = String::from(
        "// Auto-generated WASM menu registry population function\n\
         // Source: site/config/index.ncl — do not edit\n\n\
         fn populate_wasm_menu_registry() {\n\
             if let Ok(mut registry) = rustelo_core_lib::registration::MENU_REGISTRY.write() {\n",
    );

    // Insert per-language header-zone entries keyed by language code.
    // auth_required is always false here — runtime RBAC policy is delivered via WebSocket.
    // No compile-time coupling to site/rbac.ncl.
    for lang in site_cfg.languages() {
        let menu_toml = site_cfg.menu_toml_for_lang("header", lang);
        let len = menu_toml.len();
        let escaped = menu_toml.replace("\"#", "\"\\#");
        code.push_str(&format!(
            "        registry.insert(\"{lang}\".to_string(), r#\"{escaped}\"#.to_string());\n"
        ));
        println!(
            "cargo:warning=  ✓ Embedded menu for '{}' ({} bytes, from site/config/index.ncl)",
            lang, len
        );
    }

    code.push_str("    }\n}\n");

    let out_dir = env::var("OUT_DIR")?;
    let entries_file = Path::new(&out_dir).join(OUTPUT);
    fs::write(entries_file, code)?;

    Ok(OUTPUT)
}

/// Generates `footer_registry_fn.rs` in OUT_DIR from site/config/index.ncl — strict, no fallback.
fn generate_footer_registry_entries(
    manifest_resolver: &rustelo_utils::ManifestResolver,
) -> Result<&'static str, Box<dyn std::error::Error>> {
    const OUTPUT: &str = "footer_registry_fn.rs";
    use serde::Serialize;
    use std::collections::HashMap;

    // Mirror structs matching the exact TOML shape expected by FooterConfig + SimpleMenuItem.
    // Field names and renames MUST match rustelo_core_lib::FooterConfig's serde attributes.
    #[derive(Serialize)]
    struct FooterToml {
        title: String,
        #[serde(rename = "company-desc")]
        company_desc: String,
        copyright_text: String,
        #[serde(skip_serializing_if = "Vec::is_empty")]
        social_links: Vec<SocialLinkToml>,
        #[serde(skip_serializing_if = "Vec::is_empty")]
        menu: Vec<MenuItemToml>,
    }

    #[derive(Serialize)]
    struct SocialLinkToml {
        name: String,
        url: String,
        icon_class: String,
    }

    #[derive(Serialize)]
    struct MenuItemToml {
        route: String,
        // Translations' GenericTwoFields variant deserializes a flat { lang -> text } map.
        label: HashMap<String, String>,
    }

    let manifest_dir = manifest_resolver.root();

    let site_cfg = build_config::site_config::load_unified_site_config(manifest_dir)
        .unwrap_or_else(|e| {
            panic!(
                "Cannot load site/config/index.ncl for WASM footer registry: {e}\n\
             Install Nickel: cargo install nickel-lang-cli"
            )
        });

    let footer = &site_cfg.footer;

    let mut code = String::from(
        "// Auto-generated WASM footer registry population function\n\
         // Source: site/config/index.ncl — do not edit\n\n\
         fn populate_wasm_footer_registry() {\n\
             if let Ok(mut registry) = rustelo_core_lib::registration::FOOTER_REGISTRY.write() {\n",
    );

    for lang in site_cfg.languages() {
        let company_desc = footer
            .company_desc
            .get(lang.as_str())
            .cloned()
            .unwrap_or_default();
        let copyright_text = footer
            .copyright_text
            .get(lang.as_str())
            .cloned()
            .unwrap_or_default();
        let footer_menu_items = site_cfg.menu_items_for_zone("footer", lang);

        let footer_data = FooterToml {
            title: footer.title.clone(),
            company_desc,
            copyright_text,
            social_links: footer
                .social_links
                .iter()
                .map(|l| SocialLinkToml {
                    name: l.name.clone(),
                    url: l.url.clone(),
                    icon_class: l.icon.clone(),
                })
                .collect(),
            menu: footer_menu_items
                .iter()
                .map(|item| MenuItemToml {
                    route: item.route.clone(),
                    label: {
                        let mut m = HashMap::new();
                        m.insert(lang.to_string(), item.label.clone());
                        m
                    },
                })
                .collect(),
        };

        let toml_str = toml::to_string(&footer_data)?;
        let len = toml_str.len();
        let escaped = toml_str.replace("\"#", "\"\\#");
        code.push_str(&format!(
            "        registry.insert(\"{lang}\".to_string(), r#\"{escaped}\"#.to_string());\n"
        ));
        println!("cargo:warning=  ✓ Embedded footer for '{lang}' ({len} bytes, from site/config/index.ncl)");
    }

    code.push_str("    }\n}\n");

    let out_dir = env::var("OUT_DIR")?;
    let entries_file = Path::new(&out_dir).join(OUTPUT);
    fs::write(entries_file, code)?;

    Ok(OUTPUT)
}

/// Generates `ftl_registry_fn.rs` in OUT_DIR by walking `site/i18n/locales/` and
/// embedding every `*.ftl` file with `include_str!`.  The generated
/// `populate_wasm_ftl_registry()` function populates `FTL_REGISTRY` at WASM
/// startup so that `get_parsed_ftl_for_language(lang)` returns full translations
/// for every supported language — enabling correct language switching in WASM.
fn generate_ftl_registry_entries(
    manifest_resolver: &rustelo_utils::ManifestResolver,
) -> Result<&'static str, Box<dyn std::error::Error>> {
    const OUTPUT: &str = "ftl_registry_fn.rs";
    let manifest_dir = manifest_resolver.root();
    let locales_dir = manifest_dir.join("site").join("i18n").join("locales");

    let mut code = String::from(
        "// Auto-generated WASM FTL registry population function\n\
         // Source: site/i18n/locales/ — do not edit\n\n\
         fn populate_wasm_ftl_registry() {\n\
             use rustelo_core_lib::registration::FTL_REGISTRY;\n\
             if let Ok(mut registry) = FTL_REGISTRY.write() {\n\
                 if !registry.is_empty() { return; }\n",
    );

    let mut count = 0usize;

    if locales_dir.exists() {
        for lang_entry in fs::read_dir(&locales_dir)?.filter_map(|e| e.ok()) {
            let lang_path = lang_entry.path();
            if !lang_path.is_dir() {
                continue;
            }
            let lang = match lang_path.file_name().and_then(|n| n.to_str()) {
                Some(l) => l.to_string(),
                None => continue,
            };

            // Walk all *.ftl files under this language directory.
            for ftl_entry in WalkDir::new(&lang_path)
                .into_iter()
                .filter_map(|e| e.ok())
                .filter(|e| e.path().extension().is_some_and(|x| x == "ftl"))
            {
                let ftl_path = ftl_entry.path();
                let abs_path = ftl_path.canonicalize()?;

                // Build registry key: lang + "_" + relative path components joined with "_".
                // e.g. site/i18n/locales/en/pages/home.ftl → "en_pages_home"
                let rel = ftl_path.strip_prefix(&lang_path).unwrap_or(ftl_path);
                let key_suffix = rel
                    .with_extension("")
                    .components()
                    .filter_map(|c| {
                        if let std::path::Component::Normal(n) = c {
                            n.to_str().map(|s| s.replace(['-', ' '], "_"))
                        } else {
                            None
                        }
                    })
                    .collect::<Vec<_>>()
                    .join("_");
                let registry_key = format!("{}_{}", lang, key_suffix);
                let abs_str = abs_path.to_str().unwrap_or("").replace('\\', "/");

                code.push_str(&format!(
                    "        registry.insert(\"{registry_key}\".to_string(), \
                     include_str!(\"{abs_str}\").to_string());\n"
                ));

                let display_path = abs_path
                    .strip_prefix(manifest_dir)
                    .unwrap_or(&abs_path)
                    .display()
                    .to_string();
                println!(
                    "cargo:warning=  ✓ Embedded FTL: {} from {}",
                    registry_key, display_path
                );
                println!("cargo:rerun-if-changed={abs_str}");
                count += 1;
            }
        }
    } else {
        println!(
            "cargo:warning=  ⚠️  FTL locales dir not found at {}, skipping FTL embedding",
            locales_dir.display()
        );
    }

    code.push_str("    }\n}\n");

    let out_dir = env::var("OUT_DIR")?;
    let entries_file = Path::new(&out_dir).join(OUTPUT);
    fs::write(entries_file, &code)?;

    println!("cargo:warning=  ✓ Generated ftl_registry_fn.rs with {count} FTL files");
    Ok(OUTPUT)
}

/// Converts a URL path segment like `/provisioning-systems` into a title label
/// like `Provisioning Systems`. Single-segment paths like `/vapora` become `Vapora`.
fn path_to_title_label(path: &str) -> String {
    path.trim_start_matches('/')
        .split('-')
        .map(|word| {
            let mut chars = word.chars();
            match chars.next() {
                None => String::new(),
                Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
            }
        })
        .collect::<Vec<_>>()
        .join(" ")
}

/// Generates `palette_app_pages_fn.rs` — a static slice of `(label, route)` pairs for
/// application pages that have no nav-menu entry. These are injected into the command
/// palette via `CommandPaletteExtraItems` context so users can navigate to project pages
/// (Provisioning Systems, Vapora, etc.) by typing in the palette.
fn generate_palette_app_pages(
    manifest_resolver: &rustelo_utils::ManifestResolver,
) -> Result<&'static str, Box<dyn std::error::Error>> {
    const OUTPUT: &str = "palette_app_pages_fn.rs";
    let manifest_dir = manifest_resolver.root();

    let site_cfg = build_config::site_config::load_unified_site_config(manifest_dir)
        .unwrap_or_else(|e| {
            panic!(
                "Cannot load site/config/index.ncl for palette app pages: {e}\n\
                 Install Nickel: cargo install nickel-lang-cli"
            )
        });

    // Generic component names that are NOT standalone pages
    const SKIP_COMPONENTS: &[&str] = &[
        "ContentIndex",
        "PostViewer",
        "ExternalLink",
        "NotFound",
        "HomePage",
    ];

    let mut entries: Vec<(String, String)> = site_cfg
        .routes
        .iter()
        .filter(|r| {
            !r.menu.enabled
                && r.component.ends_with("Page")
                && !SKIP_COMPONENTS.iter().any(|s| r.component.contains(s))
        })
        .filter_map(|r| {
            // Prefer the English path; fall back to any available path
            let path = r.paths.get("en").or_else(|| r.paths.values().next())?;
            // Skip parametric routes like /blog/{category}/{slug}
            if path.contains('{') {
                return None;
            }
            let label = path_to_title_label(path);
            Some((label, path.clone()))
        })
        .collect();

    entries.sort_by(|a, b| a.0.cmp(&b.0));
    // Remove duplicate routes (keep first by label order)
    let mut seen_routes = std::collections::HashSet::new();
    entries.retain(|(_, route)| seen_routes.insert(route.clone()));

    let items_literal: String = entries
        .iter()
        .map(|(label, route)| format!("    (\"{label}\", \"{route}\"),\n"))
        .collect();

    let code = format!(
        "// Auto-generated palette application pages — do not edit\n\
         // Source: site/config/routes.ncl — regenerated on every build\n\n\
         pub fn get_palette_app_pages() -> &'static [(&'static str, &'static str)] {{\n\
             &[\n\
         {items_literal}\
             ]\n\
         }}\n"
    );

    let out_dir = env::var("OUT_DIR")?;
    fs::write(Path::new(&out_dir).join(OUTPUT), &code)?;

    println!(
        "cargo:warning=  ✓ Embedded {} palette app page entries (palette_app_pages_fn.rs)",
        entries.len()
    );
    Ok(OUTPUT)
}
