//! Pre-rendering engine for static site generation.
//!
//! Walks the content directory (site/ or pages/), renders each page through the
//! template engine, and writes the output to a target directory.
//! Shared by `build` and `deploy --target static`.

use anyhow::Result;
use std::path::{Path, PathBuf};

use wwwhat_core::{Config, server};

/// Configuration for pre-rendering
pub struct PreRenderConfig {
    pub project_path: PathBuf,
    pub output_path: PathBuf,
    pub minify: bool,
}

/// Result of a pre-render operation
pub struct PreRenderResult {
    pub pages_rendered: usize,
    pub pages_skipped: Vec<String>,
    pub total_bytes: usize,
}

/// Pre-render all pages in the project to static HTML files.
pub async fn prerender(config: PreRenderConfig) -> Result<PreRenderResult> {
    let project_path = std::fs::canonicalize(&config.project_path)?;

    // Load project config
    let config_path = project_path.join("wwwhat.toml");
    let app_config = if config_path.exists() {
        Config::load(&config_path)?
    } else {
        Config::default()
    };

    // Create AppState (non-dev mode, no live reload)
    let state = server::AppState::new(app_config, project_path.clone())?;

    // Discover all routes
    let routes = server::discover_routes(&project_path);

    if routes.is_empty() {
        anyhow::bail!(
            "No pages found in {}/{}/",
            project_path.display(),
            server::content_dir_name(&project_path)
        );
    }

    // Create output directory
    std::fs::create_dir_all(&config.output_path)?;

    let mut result = PreRenderResult {
        pages_rendered: 0,
        pages_skipped: Vec::new(),
        total_bytes: 0,
    };

    // Render each route
    for (url_path, is_dynamic) in &routes {
        if *is_dynamic {
            result
                .pages_skipped
                .push(format!("{} (dynamic route)", url_path));
            continue;
        }

        match server::render_page_to_html(&state, url_path).await {
            Ok(Some(html)) => {
                let final_html = if config.minify {
                    minify_html(&html)
                } else {
                    html
                };

                // Write output file
                let output_file = url_to_file_path(&config.output_path, url_path);
                if let Some(parent) = output_file.parent() {
                    std::fs::create_dir_all(parent)?;
                }
                std::fs::write(&output_file, &final_html)?;

                result.total_bytes += final_html.len();
                result.pages_rendered += 1;
                tracing::info!(
                    "  {} -> {} ({} bytes)",
                    url_path,
                    output_file.display(),
                    final_html.len()
                );
            }
            Ok(None) => {
                result
                    .pages_skipped
                    .push(format!("{} (auth required or excluded)", url_path));
            }
            Err(e) => {
                tracing::warn!("  Failed to render {}: {}", url_path, e);
                result
                    .pages_skipped
                    .push(format!("{} (error: {})", url_path, e));
            }
        }
    }

    // Copy static assets
    copy_static_assets(&project_path, &config.output_path, config.minify, state.css_mode)?;

    Ok(result)
}

/// Convert a URL path to an output file path.
/// `/` -> `output/index.html`
/// `/about` -> `output/about/index.html`
/// `/demo/ui/buttons` -> `output/demo/ui/buttons/index.html`
fn url_to_file_path(output_dir: &Path, url_path: &str) -> PathBuf {
    let clean = url_path.trim_start_matches('/');
    if clean.is_empty() {
        output_dir.join("index.html")
    } else {
        output_dir.join(clean).join("index.html")
    }
}

/// Minify HTML using minify-html crate
fn minify_html(html: &str) -> String {
    let cfg = minify_html::Cfg {
        minify_js: true,
        minify_css: true,
        ..minify_html::Cfg::default()
    };
    let minified = minify_html::minify(html.as_bytes(), &cfg);
    String::from_utf8(minified).unwrap_or_else(|_| html.to_string())
}

/// Copy static assets (CSS, JS, uploads) to output directory
fn copy_static_assets(
    project_path: &Path,
    output_path: &Path,
    minify: bool,
    css_mode: wwwhat_core::server::CssMode,
) -> Result<()> {
    let static_dest = output_path.join("static");
    std::fs::create_dir_all(&static_dest)?;

    // Write embedded framework assets (what.css, what.js).
    // The stylesheet honors [server] css — "minimal" ships without component styles;
    // "none" still writes the full file so it stays available for manual linking.
    std::fs::write(
        static_dest.join("what.css"),
        wwwhat_core::server::embedded_what_css_for_mode(css_mode),
    )?;
    std::fs::write(
        static_dest.join("what.js"),
        include_str!("../assets/client/what.js"),
    )?;

    // Copy user's static/ directory (may override framework assets)
    let static_src = project_path.join("static");
    if static_src.exists() {
        copy_dir_all(&static_src, &static_dest)?;
    }

    // Minify CSS files in static output
    if minify {
        minify_css_files(&static_dest)?;
        minify_js_files(&static_dest)?;
    }

    tracing::info!("  Copied static assets");

    // Copy uploads/ directory if it exists
    let uploads_src = project_path.join("uploads");
    if uploads_src.exists() {
        let uploads_dest = output_path.join("uploads");
        copy_dir_all(&uploads_src, &uploads_dest)?;
        tracing::info!("  Copied uploads");
    }

    Ok(())
}

/// Minify all .css files in a directory recursively
fn minify_css_files(dir: &Path) -> Result<()> {
    for entry in walkdir::WalkDir::new(dir)
        .into_iter()
        .filter_map(|e| e.ok())
    {
        if entry.path().extension().map_or(false, |e| e == "css") {
            if let Ok(css) = std::fs::read_to_string(entry.path()) {
                let cfg = minify_html::Cfg {
                    minify_css: true,
                    ..minify_html::Cfg::default()
                };
                // Wrap in style tag so minify-html processes CSS content
                let wrapped = format!("<style>{}</style>", css);
                let minified = minify_html::minify(wrapped.as_bytes(), &cfg);
                if let Ok(result) = String::from_utf8(minified) {
                    // Extract CSS from style tags
                    let css_only = result
                        .strip_prefix("<style>")
                        .and_then(|s| s.strip_suffix("</style>"))
                        .unwrap_or(&result);
                    std::fs::write(entry.path(), css_only)?;
                }
            }
        }
    }
    Ok(())
}

/// Minify all .js files in a directory recursively
fn minify_js_files(dir: &Path) -> Result<()> {
    for entry in walkdir::WalkDir::new(dir)
        .into_iter()
        .filter_map(|e| e.ok())
    {
        if entry.path().extension().map_or(false, |e| e == "js") {
            if let Ok(js) = std::fs::read_to_string(entry.path()) {
                let cfg = minify_html::Cfg {
                    minify_js: true,
                    ..minify_html::Cfg::default()
                };
                // Wrap in script tag so minify-html processes JS content
                let wrapped = format!("<script>{}</script>", js);
                let minified = minify_html::minify(wrapped.as_bytes(), &cfg);
                if let Ok(result) = String::from_utf8(minified) {
                    let js_only = result
                        .strip_prefix("<script>")
                        .and_then(|s| s.strip_suffix("</script>"))
                        .unwrap_or(&result);
                    std::fs::write(entry.path(), js_only)?;
                }
            }
        }
    }
    Ok(())
}

/// Copy a directory recursively
fn copy_dir_all(src: &Path, dst: &Path) -> Result<()> {
    std::fs::create_dir_all(dst)?;
    for entry in std::fs::read_dir(src)? {
        let entry = entry?;
        let ty = entry.file_type()?;
        if ty.is_dir() {
            copy_dir_all(&entry.path(), &dst.join(entry.file_name()))?;
        } else {
            std::fs::copy(entry.path(), dst.join(entry.file_name()))?;
        }
    }
    Ok(())
}

/// Convert days since Unix epoch to (year, month, day)
fn epoch_days_to_date(mut days: i64) -> (i64, u32, u32) {
    // Civil days algorithm (Howard Hinnant)
    days += 719468;
    let era = if days >= 0 { days } else { days - 146096 } / 146097;
    let doe = (days - era * 146097) as u32;
    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
    let y = yoe as i64 + era * 400;
    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
    let mp = (5 * doy + 2) / 153;
    let d = doy - (153 * mp + 2) / 5 + 1;
    let m = if mp < 10 { mp + 3 } else { mp - 9 };
    let y = if m <= 2 { y + 1 } else { y };
    (y, m, d)
}

/// Generate an XML sitemap from discovered routes.
/// Excludes dynamic routes (with `:params`) and partials.
pub fn generate_sitemap(project_path: &Path, base_url: &str) -> String {
    let routes = server::discover_routes(project_path);
    let base_url = base_url.trim_end_matches('/');

    let mut xml = String::from(r#"<?xml version="1.0" encoding="UTF-8"?>"#);
    xml.push('\n');
    xml.push_str(r#"<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">"#);
    xml.push('\n');

    let pages_dir = server::content_dir(project_path);

    for (url_path, is_dynamic) in &routes {
        // Skip dynamic routes
        if *is_dynamic {
            continue;
        }

        // Skip partials
        if url_path.starts_with("/partials") {
            continue;
        }

        // Get lastmod from file modification time
        let file_path = if *url_path == "/" {
            pages_dir.join("index.html")
        } else {
            let clean = url_path.trim_start_matches('/');
            let direct = pages_dir.join(format!("{}.html", clean));
            if direct.exists() {
                direct
            } else {
                pages_dir.join(clean).join("index.html")
            }
        };

        let lastmod = file_path
            .metadata()
            .ok()
            .and_then(|m| m.modified().ok())
            .map(|t| {
                let duration = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
                let secs = duration.as_secs() as i64;
                // Simple date calculation from epoch seconds
                let days = secs / 86400;
                let (year, month, day) = epoch_days_to_date(days);
                format!("{:04}-{:02}-{:02}", year, month, day)
            });

        xml.push_str("  <url>\n");
        xml.push_str(&format!("    <loc>{}{}</loc>\n", base_url, url_path));
        if let Some(date) = lastmod {
            xml.push_str(&format!("    <lastmod>{}</lastmod>\n", date));
        }
        xml.push_str("  </url>\n");
    }

    xml.push_str("</urlset>\n");
    xml
}

/// Hash static asset files for cache busting.
///
/// Renames files in `output/static/` with content hashes (e.g., `what.js` → `what.a3f2b1c4.js`)
/// and rewrites all HTML files in output to reference the new filenames.
///
/// Returns a list of (original_name, hashed_name) pairs.
pub fn hash_static_assets(output_dir: &Path) -> Result<Vec<(String, String)>> {
    use std::collections::HashMap;
    use std::io::Read;

    let static_dir = output_dir.join("static");
    if !static_dir.exists() {
        return Ok(Vec::new());
    }

    let mut renames: HashMap<String, String> = HashMap::new();
    let mut renamed_files = Vec::new();

    // Collect static files and compute hashes
    let entries: Vec<_> = std::fs::read_dir(&static_dir)?
        .filter_map(|e| e.ok())
        .filter(|e| e.path().is_file())
        .collect();

    for entry in &entries {
        let path = entry.path();
        let filename = path.file_name().unwrap().to_string_lossy().to_string();

        // Read file content and hash it
        let mut file = std::fs::File::open(&path)?;
        let mut content = Vec::new();
        file.read_to_end(&mut content)?;

        // Simple FNV-1a hash for short content hashes
        let hash = fnv1a_hash(&content);
        let hash_hex = format!("{:08x}", hash);

        // Build hashed filename: name.HASH.ext
        let hashed_name = if let Some(dot_pos) = filename.rfind('.') {
            format!(
                "{}.{}{}",
                &filename[..dot_pos],
                hash_hex,
                &filename[dot_pos..]
            )
        } else {
            format!("{}.{}", filename, hash_hex)
        };

        // Rename the file
        let new_path = static_dir.join(&hashed_name);
        std::fs::rename(&path, &new_path)?;

        renames.insert(filename.clone(), hashed_name.clone());
        renamed_files.push((filename, hashed_name));
    }

    if renames.is_empty() {
        return Ok(renamed_files);
    }

    // Rewrite HTML files to reference hashed asset names
    rewrite_html_references(output_dir, &renames)?;

    Ok(renamed_files)
}

/// Rewrite asset references in all HTML files under the output directory.
fn rewrite_html_references(
    dir: &Path,
    renames: &std::collections::HashMap<String, String>,
) -> Result<()> {
    for entry in std::fs::read_dir(dir)?.filter_map(|e| e.ok()) {
        let path = entry.path();
        if path.is_dir() {
            rewrite_html_references(&path, renames)?;
        } else if path.extension().map_or(false, |ext| ext == "html") {
            let mut content = std::fs::read_to_string(&path)?;
            let mut changed = false;
            for (original, hashed) in renames {
                let old_ref = format!("/static/{}", original);
                let new_ref = format!("/static/{}", hashed);
                if content.contains(&old_ref) {
                    content = content.replace(&old_ref, &new_ref);
                    changed = true;
                }
            }
            if changed {
                std::fs::write(&path, content)?;
            }
        }
    }
    Ok(())
}

/// FNV-1a hash for content hashing (fast, good distribution for cache busting).
fn fnv1a_hash(data: &[u8]) -> u32 {
    let mut hash: u32 = 0x811c9dc5;
    for &byte in data {
        hash ^= byte as u32;
        hash = hash.wrapping_mul(0x01000193);
    }
    hash
}

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

    // The CLI vendors its own copy of the client assets (crates/wwwhat-cli/assets/client/)
    // alongside the copy embedded in wwwhat-core (crates/wwwhat-core/assets/client/).
    // These drifted historically and shipped stale JS to production. Compile-time
    // byte-equality of the embedded copies guarantees it can never happen again.
    #[test]
    fn cli_what_css_matches_core_embedded_copy() {
        assert!(
            include_str!("../assets/client/what.css") == wwwhat_core::server::embedded_what_css(),
            "crates/wwwhat-cli/assets/client/what.css has drifted from \
             crates/wwwhat-core/assets/client/what.css — sync by copying the core file \
             over the CLI copy (core is the source of truth)"
        );
    }

    #[test]
    fn cli_what_js_matches_core_embedded_copy() {
        assert!(
            include_str!("../assets/client/what.js") == wwwhat_core::server::embedded_what_js(),
            "crates/wwwhat-cli/assets/client/what.js has drifted from \
             crates/wwwhat-core/assets/client/what.js — sync by copying the core file \
             over the CLI copy (core is the source of truth)"
        );
    }

    #[test]
    fn test_url_to_file_path() {
        let out = PathBuf::from("/tmp/dist");
        assert_eq!(
            url_to_file_path(&out, "/"),
            PathBuf::from("/tmp/dist/index.html")
        );
        assert_eq!(
            url_to_file_path(&out, "/about"),
            PathBuf::from("/tmp/dist/about/index.html")
        );
        assert_eq!(
            url_to_file_path(&out, "/demo/ui/buttons"),
            PathBuf::from("/tmp/dist/demo/ui/buttons/index.html")
        );
    }

    #[test]
    fn test_minify_html_basic() {
        let html = "<html>  <body>  <h1>Hello</h1>  </body>  </html>";
        let minified = minify_html(html);
        assert!(minified.len() <= html.len());
        assert!(minified.contains("Hello"));
    }

    #[test]
    fn test_sitemap_basic_content() {
        let temp = tempfile::TempDir::new().unwrap();
        let root = temp.path();
        let cd = server::content_dir_name(root);
        std::fs::create_dir_all(root.join(cd)).unwrap();
        std::fs::write(root.join(cd).join("index.html"), "<h1>Home</h1>").unwrap();
        std::fs::write(root.join(cd).join("about.html"), "<h1>About</h1>").unwrap();

        let sitemap = generate_sitemap(root, "https://example.com");
        assert!(sitemap.contains(r#"<?xml version="1.0" encoding="UTF-8"?>"#));
        assert!(sitemap.contains("<urlset"));
        assert!(sitemap.contains("<loc>https://example.com/</loc>"));
        assert!(sitemap.contains("<loc>https://example.com/about</loc>"));
        assert!(sitemap.contains("<lastmod>"));
    }

    #[test]
    fn test_sitemap_excludes_dynamic_and_partials() {
        let temp = tempfile::TempDir::new().unwrap();
        let root = temp.path();
        let cd = server::content_dir_name(root);
        std::fs::create_dir_all(root.join(cd).join("blog")).unwrap();
        std::fs::create_dir_all(root.join(cd).join("partials")).unwrap();
        std::fs::write(root.join(cd).join("index.html"), "<h1>Home</h1>").unwrap();
        std::fs::write(root.join(cd).join("blog/[id].html"), "<h1>Post</h1>").unwrap();
        std::fs::write(
            root.join(cd).join("partials/header.html"),
            "<header>Nav</header>",
        )
        .unwrap();

        let sitemap = generate_sitemap(root, "https://example.com");
        assert!(sitemap.contains("<loc>https://example.com/</loc>"));
        assert!(
            !sitemap.contains("[id]"),
            "Dynamic routes should be excluded"
        );
        assert!(
            !sitemap.contains("/partials"),
            "Partials should be excluded"
        );
    }

    #[test]
    fn test_epoch_days_to_date_known() {
        // 2024-01-01 = day 19723 from epoch
        let (y, m, d) = epoch_days_to_date(19723);
        assert_eq!((y, m, d), (2024, 1, 1));
    }

    #[test]
    fn test_fnv1a_hash_deterministic() {
        let hash1 = fnv1a_hash(b"hello world");
        let hash2 = fnv1a_hash(b"hello world");
        assert_eq!(hash1, hash2);
    }

    #[test]
    fn test_fnv1a_hash_different_inputs() {
        let hash1 = fnv1a_hash(b"what.js");
        let hash2 = fnv1a_hash(b"what.css");
        assert_ne!(hash1, hash2);
    }

    #[test]
    fn test_hash_static_assets_basic() {
        let temp = tempfile::TempDir::new().unwrap();
        let output = temp.path();

        // Create static dir with files
        let static_dir = output.join("static");
        std::fs::create_dir_all(&static_dir).unwrap();
        std::fs::write(static_dir.join("app.js"), "console.log('hello')").unwrap();
        std::fs::write(static_dir.join("style.css"), "body { color: red }").unwrap();

        // Create an HTML file that references the assets
        std::fs::write(
            output.join("index.html"),
            r#"<link href="/static/style.css"><script src="/static/app.js"></script>"#,
        )
        .unwrap();

        let result = hash_static_assets(output).unwrap();
        assert_eq!(result.len(), 2);

        // Original files should not exist
        assert!(!static_dir.join("app.js").exists());
        assert!(!static_dir.join("style.css").exists());

        // Hashed files should exist
        for (_, hashed_name) in &result {
            assert!(
                static_dir.join(hashed_name).exists(),
                "Expected {} to exist",
                hashed_name
            );
        }

        // HTML should reference hashed names
        let html = std::fs::read_to_string(output.join("index.html")).unwrap();
        assert!(
            !html.contains("/static/app.js"),
            "Should not contain original reference"
        );
        assert!(
            !html.contains("/static/style.css"),
            "Should not contain original reference"
        );
        for (_, hashed_name) in &result {
            assert!(
                html.contains(hashed_name),
                "HTML should reference {}",
                hashed_name
            );
        }
    }

    #[test]
    fn test_minify_js_files() {
        let temp = tempfile::TempDir::new().unwrap();
        let dir = temp.path();
        let js = "function hello(name) {\n  console.log('Hello, ' + name);\n}\n";
        std::fs::write(dir.join("app.js"), js).unwrap();

        minify_js_files(dir).unwrap();

        let result = std::fs::read_to_string(dir.join("app.js")).unwrap();
        assert!(result.len() < js.len(), "Minified JS should be smaller");
        assert!(result.contains("hello"), "Should preserve function name");
    }

    #[test]
    fn test_hash_static_assets_no_static_dir() {
        let temp = tempfile::TempDir::new().unwrap();
        let result = hash_static_assets(temp.path()).unwrap();
        assert!(result.is_empty());
    }
}
