use anyhow::{Context, Result};
use std::fs;
use std::path::Path;
use walkdir::WalkDir;

use crate::llms::{self, PageEntry};
use crate::minify;
use crate::render;
use crate::types::{Page, SiteConfig};

pub fn run(dir: &Path, out: &Path, release: bool, allow_orphans: bool) -> Result<()> {
    let config = load_config(dir)?;
    fs::create_dir_all(out)?;

    // Canonicalize the output dir so we can reliably skip walking into it
    // when it lives inside the source dir (e.g. docs/_site under docs/).
    let out_canonical = out.canonicalize().unwrap_or_else(|_| out.to_path_buf());

    let mut pages = 0;
    let mut assets = 0;
    let mut entries: Vec<PageEntry> = Vec::new();
    // Collect stale-review pages so we can print a summary at the end
    // of the build. Staleness is evaluated against `KAZAM_TODAY` or the
    // system clock (see `freshness::today_iso`).
    let today = crate::freshness::today_iso();
    let mut stale_pages: Vec<StaleEntry> = Vec::new();
    // Per-page href inventory so we can run the link-graph analysis after
    // the walk. Populated alongside each page render.
    let mut page_links: Vec<crate::links::PageLinks> = Vec::new();

    for entry in WalkDir::new(dir)
        .follow_links(true)
        .into_iter()
        .filter_entry(|e| {
            // Skip the configured output directory (e.g. _site/ nested in
            // source dir), AND any `_site` folder anywhere in the tree —
            // otherwise running kazam from a parent directory that contains
            // previously-built sub-sites would recursively ingest all those
            // `_site/` outputs as if they were source.
            if e.path()
                .canonicalize()
                .map(|p| p.starts_with(&out_canonical))
                .unwrap_or(false)
            {
                return false;
            }
            if e.depth() > 0 && e.file_name() == "_site" && e.file_type().is_dir() {
                return false;
            }
            // Skip hidden entries (.git, .DS_Store, .vscode, etc.) at any depth
            // except the source dir itself, which is often passed as "." and
            // would be filtered by a naive starts-with check.
            if e.depth() > 0 {
                if let Some(name) = e.file_name().to_str() {
                    if name.starts_with('.') {
                        return false;
                    }
                }
            }
            true
        })
    {
        let entry = entry?;
        let path = entry.path();
        if !entry.file_type().is_file() {
            continue;
        }

        let fname = path.file_name().unwrap_or_default();
        if fname == "kazam.yaml" {
            continue;
        }

        let rel = path.strip_prefix(dir)?;
        let is_yaml = path.extension().map(|e| e == "yaml").unwrap_or(false);

        if is_yaml {
            let content =
                fs::read_to_string(path).with_context(|| format!("reading {:?}", path))?;
            let page: Page =
                serde_yaml::from_str(&content).with_context(|| format!("parsing {:?}", path))?;

            let base = base_path_for(rel);
            let source_filename = rel
                .file_name()
                .map(|f| f.to_string_lossy().into_owned())
                .unwrap_or_default();
            let source_stem = rel
                .file_stem()
                .map(|f| f.to_string_lossy().into_owned())
                .unwrap_or_default();

            // The "View source" pill + rendered source-view page are opt-in
            // via `view_source: true` in kazam.yaml. Most sites don't need it;
            // docs/examples sites do.
            let source_view_href = if config.view_source {
                format!("{}.source.html", source_stem)
            } else {
                String::new()
            };

            // URL-shaped relative path for canonical / og:url meta. Always
            // forward-slash separated, `.html` extension.
            let html_rel = rel
                .with_extension("html")
                .to_string_lossy()
                .replace('\\', "/");
            let source_rel = format!("{}.source.html", source_stem);

            let mut html = render::render_page(&page, &config, &base, &source_view_href, &html_rel);
            if release {
                html = minify::minify_html(&html);
            }

            let out_path = out.join(rel).with_extension("html");
            if let Some(parent) = out_path.parent() {
                fs::create_dir_all(parent)?;
            }
            fs::write(&out_path, html)?;

            if config.view_source {
                let mut source_view = render::render_source_view(
                    &page,
                    &config,
                    &content,
                    &base,
                    &source_filename,
                    &source_rel,
                );
                if release {
                    source_view = minify::minify_html(&source_view);
                }
                let source_view_path =
                    out_path.with_file_name(format!("{}.source.html", source_stem));
                fs::write(&source_view_path, source_view)?;
            }

            // Always copy the raw YAML — llms.txt points at it and it's
            // useful for `curl` / programmatic access even without view_source.
            let yaml_out = out.join(rel);
            fs::copy(path, &yaml_out)?;

            // Collect metadata for llms.txt (unless marked unlisted)
            if !page.unlisted {
                let html_path = rel.with_extension("html").to_string_lossy().to_string();
                let yaml_path = rel.to_string_lossy().to_string();
                entries.push(PageEntry {
                    title: page.title.clone(),
                    subtitle: page.subtitle.clone(),
                    html_path,
                    yaml_path,
                });
            }

            // Collect internal hrefs for the link-graph pass.
            let html_rel_for_links = rel
                .with_extension("html")
                .to_string_lossy()
                .replace('\\', "/");
            page_links.push(crate::links::collect_page_links(&html_rel_for_links, &page));

            // Track freshness status for the end-of-build summary. Uses
            // the same computation as the banner inject so the report and
            // the rendered output never disagree. Fresh pages are dropped;
            // DueSoon and Overdue both feed the summary (grouped).
            if let Some(info) = crate::freshness::info_for(page.freshness.as_ref(), &today) {
                let status = info.status();
                if !matches!(status, crate::freshness::FreshnessStatus::Fresh) {
                    stale_pages.push(StaleEntry {
                        html_path: rel.with_extension("html").to_string_lossy().to_string(),
                        title: page.title.clone(),
                        owner: page.freshness.as_ref().and_then(|f| f.owner.clone()),
                        status,
                        cadence: page
                            .freshness
                            .as_ref()
                            .and_then(|f| f.review_every.clone())
                            .unwrap_or_default(),
                    });
                }
            }

            println!("  {}", out_path.display());
            pages += 1;
        } else {
            // Static asset — copy verbatim
            let out_path = out.join(rel);
            if let Some(parent) = out_path.parent() {
                fs::create_dir_all(parent)?;
            }
            fs::copy(path, &out_path)?;
            assets += 1;
        }
    }

    // Emit llms.txt
    if !entries.is_empty() {
        llms::write(out, &config, &entries)?;
    }

    // Emit sitemap.xml + robots.txt when a canonical URL is configured.
    // Without a URL they'd emit bogus/relative paths, so skip silently.
    if let Some(site_url) = config.url.as_deref() {
        write_sitemap(out, site_url, &entries)?;
        write_robots(out, site_url)?;
    }

    if assets > 0 {
        println!(
            "\n✓ {} page(s), {} asset(s) → {}{}",
            pages,
            assets,
            out.display(),
            if release { " (minified)" } else { "" }
        );
    } else {
        println!(
            "\n✓ {} page(s) → {}{}",
            pages,
            out.display(),
            if release { " (minified)" } else { "" }
        );
    }

    print_freshness_report(&stale_pages);
    write_freshness_report_md(out, &stale_pages, &today)?;

    // Link-graph analysis runs after every build. Orphans can be silenced
    // for draft workflows (dev mode, `--allow-orphans`) but broken links
    // always surface — there's no legitimate reason to tolerate those.
    let mut report = crate::links::analyze(&page_links, config.nav.as_deref());
    if allow_orphans {
        report.orphans.clear();
    }
    crate::links::print_report(&report);
    crate::links::write_report_md(out, &report)?;

    Ok(())
}

/// One stale-review page collected during the build walk. Separate from
/// `PageEntry` because it needs cadence/owner info and we want to sort
/// it by overdue-ness, not by llms.txt order.
struct StaleEntry {
    html_path: String,
    #[allow(dead_code)]
    title: String,
    owner: Option<String>,
    status: crate::freshness::FreshnessStatus,
    cadence: String,
}

/// Write the stale-page report to `<out>/stale.md` whenever any page is
/// not Fresh. Markdown so agents can read it straight, humans can too.
/// Silent (no file written) when nothing is stale — matches the console
/// behavior, keeps the output dir clean on healthy builds.
fn write_freshness_report_md(out: &Path, stale: &[StaleEntry], today: &str) -> std::io::Result<()> {
    use crate::freshness::FreshnessStatus;

    if stale.is_empty() {
        // Don't leave a stale file behind from a previous (dirtier) build.
        let p = out.join("stale.md");
        if p.exists() {
            fs::remove_file(p)?;
        }
        return Ok(());
    }

    let mut overdue: Vec<&StaleEntry> = stale
        .iter()
        .filter(|e| matches!(e.status, FreshnessStatus::Overdue { .. }))
        .collect();
    let mut due_soon: Vec<&StaleEntry> = stale
        .iter()
        .filter(|e| matches!(e.status, FreshnessStatus::DueSoon { .. }))
        .collect();
    overdue.sort_by_key(|e| match e.status {
        FreshnessStatus::Overdue { days_overdue } => -days_overdue,
        _ => 0,
    });
    due_soon.sort_by_key(|e| match e.status {
        FreshnessStatus::DueSoon { days_until_due } => days_until_due,
        _ => 0,
    });

    let mut md = String::new();
    md.push_str(&format!(
        "# Stale page report\n\n_Generated {} by `kazam build`. Point an agent at this file and ask it to refresh the listed pages — they're in the source tree as `.yaml`, each with its own `freshness.sources_of_truth`._\n\n",
        today
    ));

    if !overdue.is_empty() {
        md.push_str(&format!("## Overdue ({})\n\n", overdue.len()));
        for e in &overdue {
            let days = match e.status {
                FreshnessStatus::Overdue { days_overdue } => days_overdue,
                _ => 0,
            };
            let owner = e
                .owner
                .as_deref()
                .map(|o| format!(" — owner: {}", o))
                .unwrap_or_default();
            md.push_str(&format!(
                "- **`{}`** — {} day(s) overdue (cadence: every {}){}\n",
                e.html_path, days, e.cadence, owner
            ));
        }
        md.push('\n');
    }

    if !due_soon.is_empty() {
        md.push_str(&format!("## Due soon ({})\n\n", due_soon.len()));
        for e in &due_soon {
            let days = match e.status {
                FreshnessStatus::DueSoon { days_until_due } => days_until_due,
                _ => 0,
            };
            let owner = e
                .owner
                .as_deref()
                .map(|o| format!(" — owner: {}", o))
                .unwrap_or_default();
            md.push_str(&format!(
                "- **`{}`** — due in {} day(s) (cadence: every {}){}\n",
                e.html_path, days, e.cadence, owner
            ));
        }
        md.push('\n');
    }

    fs::write(out.join("stale.md"), md)
}

/// Print a grouped summary of pages past (or nearly past) their review
/// window. Always runs after a build; silent when nothing is stale.
/// Overdue items sort first, most overdue at the top.
fn print_freshness_report(stale: &[StaleEntry]) {
    use crate::freshness::FreshnessStatus;

    let mut overdue: Vec<&StaleEntry> = stale
        .iter()
        .filter(|e| matches!(e.status, FreshnessStatus::Overdue { .. }))
        .collect();
    let mut due_soon: Vec<&StaleEntry> = stale
        .iter()
        .filter(|e| matches!(e.status, FreshnessStatus::DueSoon { .. }))
        .collect();

    if overdue.is_empty() && due_soon.is_empty() {
        return;
    }

    // Most urgent first.
    overdue.sort_by_key(|e| match e.status {
        FreshnessStatus::Overdue { days_overdue } => -days_overdue,
        _ => 0,
    });
    due_soon.sort_by_key(|e| match e.status {
        FreshnessStatus::DueSoon { days_until_due } => days_until_due,
        _ => 0,
    });

    println!();
    if !overdue.is_empty() {
        println!("⚠ {} overdue page(s):", overdue.len());
        for e in overdue {
            let days = match e.status {
                FreshnessStatus::Overdue { days_overdue } => days_overdue,
                _ => 0,
            };
            let owner = e
                .owner
                .as_deref()
                .map(|o| format!(" — owner {}", o))
                .unwrap_or_default();
            println!(
                "    {:<40}  {} day(s) overdue (cadence: every {}){}",
                e.html_path, days, e.cadence, owner
            );
        }
    }
    if !due_soon.is_empty() {
        if !stale.is_empty()
            && stale
                .iter()
                .any(|e| matches!(e.status, FreshnessStatus::Overdue { .. }))
        {
            println!();
        }
        println!("⏳ {} page(s) due for review soon:", due_soon.len());
        for e in due_soon {
            let days = match e.status {
                FreshnessStatus::DueSoon { days_until_due } => days_until_due,
                _ => 0,
            };
            let owner = e
                .owner
                .as_deref()
                .map(|o| format!(" — owner {}", o))
                .unwrap_or_default();
            println!(
                "    {:<40}  due in {} day(s) (cadence: every {}){}",
                e.html_path, days, e.cadence, owner
            );
        }
    }
}

fn write_sitemap(out: &Path, site_url: &str, entries: &[PageEntry]) -> Result<()> {
    let base = site_url.trim_end_matches('/');
    let mut xml = String::from(
        "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
         <urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n",
    );
    for e in entries {
        // html_path is forward-slash separated, `.html` extension — ready to
        // concatenate with the site base.
        xml.push_str(&format!(
            "  <url><loc>{}/{}</loc></url>\n",
            base,
            xml_escape(&e.html_path)
        ));
    }
    xml.push_str("</urlset>\n");
    fs::write(out.join("sitemap.xml"), xml)?;
    Ok(())
}

fn write_robots(out: &Path, site_url: &str) -> Result<()> {
    let base = site_url.trim_end_matches('/');
    let body = format!("User-agent: *\nAllow: /\nSitemap: {}/sitemap.xml\n", base);
    fs::write(out.join("robots.txt"), body)?;
    Ok(())
}

fn xml_escape(s: &str) -> String {
    s.replace('&', "&amp;")
        .replace('<', "&lt;")
        .replace('>', "&gt;")
}

fn base_path_for(rel: &Path) -> String {
    let depth = rel.parent().map(|p| p.components().count()).unwrap_or(0);
    "../".repeat(depth)
}

fn load_config(dir: &Path) -> Result<SiteConfig> {
    let config_path = dir.join("kazam.yaml");
    if config_path.exists() {
        let content = fs::read_to_string(&config_path)?;
        serde_yaml::from_str(&content).context("parsing kazam.yaml")
    } else {
        Ok(SiteConfig::default())
    }
}

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

    #[test]
    fn base_path_at_root_is_empty() {
        assert_eq!(base_path_for(Path::new("index.yaml")), "");
    }

    #[test]
    fn base_path_one_level_deep() {
        assert_eq!(base_path_for(Path::new("customers/acme.yaml")), "../");
    }

    #[test]
    fn base_path_two_levels_deep() {
        assert_eq!(base_path_for(Path::new("a/b/c.yaml")), "../../");
    }
}
