//! What CLI - Development server and build tool
//!
//! Created by Tedigo

mod bundle;
mod deploy;
mod prerender;
mod release;
mod wizard;

use anyhow::Result;
use clap::{Parser, Subcommand};
use notify::{RecommendedWatcher, RecursiveMode, Watcher};
use std::path::PathBuf;
use std::time::Duration;
use tracing::{info, warn};
use tracing_subscriber::EnvFilter;

use wwwhat_core::{Config, server};

#[derive(Parser)]
#[command(name = "run-what")]
#[command(author = "Tedigo")]
#[command(version = env!("CARGO_PKG_VERSION"))]
#[command(about = "What - Build modern web apps with just HTML", long_about = None)]
struct Cli {
    #[command(subcommand)]
    command: Commands,
}

#[derive(Subcommand)]
enum Commands {
    /// Start the development server
    Dev {
        /// Path to project directory
        #[arg(short, long, default_value = ".")]
        path: PathBuf,

        /// Port to listen on (overrides wwwhat.toml)
        #[arg(short = 'P', long)]
        port: Option<u16>,

        /// Host to bind to (overrides wwwhat.toml)
        #[arg(short = 'H', long)]
        host: Option<String>,

        /// Enable file watching for auto-reload
        #[arg(short, long, default_value = "true")]
        watch: bool,

        /// Enable verbose (DEBUG) logging (shorthand for --log-level debug)
        #[arg(short, long)]
        verbose: bool,

        /// Set log level: error, warn, info, debug, trace
        #[arg(long, default_value = "info")]
        log_level: String,

        /// Strict mode: warn about unresolved template variables
        #[arg(long)]
        strict: bool,

        /// Open browser after server starts
        #[arg(short, long)]
        open: bool,

        /// Production mode: disables live reload, debug meta tags, and source viewer
        #[arg(long)]
        production: bool,
    },

    /// Build the project for production
    Build {
        /// Path to project directory
        #[arg(short, long, default_value = ".")]
        path: PathBuf,

        /// Output directory
        #[arg(short, long, default_value = "dist")]
        output: PathBuf,

        /// Disable HTML/CSS/JS minification
        #[arg(long)]
        no_minify: bool,

        /// Hash static asset filenames for cache busting (e.g., what.js → what.a3f2b1c4.js)
        #[arg(long)]
        hash_assets: bool,

        /// Enable verbose (DEBUG) logging (shorthand for --log-level debug)
        #[arg(short, long)]
        verbose: bool,

        /// Set log level: error, warn, info, debug, trace
        #[arg(long, default_value = "info")]
        log_level: String,
    },

    /// Deploy the project to production
    Deploy {
        /// Path to project directory
        #[arg(short, long, default_value = ".")]
        path: PathBuf,

        /// Deploy target: ssh, docker, or static
        #[arg(short, long)]
        target: Option<String>,

        /// SSH host
        #[arg(long)]
        host: Option<String>,

        /// SSH user
        #[arg(long)]
        user: Option<String>,

        /// Remote directory
        #[arg(long)]
        remote_dir: Option<String>,

        /// Docker image name
        #[arg(long)]
        image: Option<String>,

        /// Docker registry URL
        #[arg(long)]
        registry: Option<String>,

        /// Output directory (for static target)
        #[arg(short, long, default_value = "dist")]
        output: PathBuf,

        /// Skip confirmation prompts
        #[arg(long)]
        yes: bool,

        /// Print commands without executing
        #[arg(long)]
        dry_run: bool,
    },

    /// Create a new What project
    New {
        /// Project name
        name: String,

        /// Template to use (minimum or tutorial)
        #[arg(short, long)]
        template: Option<String>,

        /// Skip interactive prompts (defaults to the minimum template)
        #[arg(long)]
        no_interactive: bool,
    },

    /// Generate an XML sitemap from discovered routes
    Sitemap {
        /// Path to project directory
        #[arg(short, long, default_value = ".")]
        path: PathBuf,

        /// Base URL for the sitemap (e.g., "https://example.com")
        #[arg(long)]
        host: Option<String>,

        /// Output file path
        #[arg(short, long, default_value = "sitemap.xml")]
        output: String,
    },

    /// Run template tests
    Test {
        /// Path to project directory
        #[arg(short, long, default_value = ".")]
        path: PathBuf,

        /// Test directory (relative to project root)
        #[arg(long, default_value = "tests")]
        test_dir: String,
    },

    /// Check project health
    Doctor {
        /// Path to project directory
        #[arg(short, long, default_value = ".")]
        path: PathBuf,
    },

    /// Package app as a standalone executable
    Bundle {
        /// Path to project directory
        #[arg(short, long, default_value = ".")]
        path: PathBuf,

        /// Output directory for the bundle
        #[arg(short, long, default_value = "bundle")]
        output: PathBuf,

        /// Also compile the bundle to a binary
        #[arg(long)]
        compile: bool,
    },

    /// Bump the framework version and sync release-facing files
    /// (maintainer-only; hidden from --help)
    #[command(hide = true)]
    Release {
        /// Path inside the workspace
        #[arg(short, long, default_value = ".")]
        path: PathBuf,

        /// Version segment to increment
        #[arg(value_enum)]
        bump: release::VersionBump,
    },

    /// Generate components
    #[command(subcommand)]
    Generate(GenerateCommands),
}

#[derive(Subcommand)]
enum GenerateCommands {
    /// Generate a new page
    Page {
        /// Page name (e.g., "about" creates site/about.html)
        name: String,
    },

    /// Generate a new component
    Component {
        /// Component name (e.g., "card" creates components/card.html, used as <what-card>)
        name: String,

        /// Props for the component
        #[arg(short, long)]
        props: Option<String>,
    },
}

#[tokio::main]
async fn main() -> Result<()> {
    let cli = Cli::parse();

    // Resolve log level: WHAT_LOG env var > --verbose > --log-level > default (info)
    let log_level = resolve_log_level(&cli.command);
    let log_format = std::env::var("WHAT_LOG_FORMAT").unwrap_or_default();

    if log_format.eq_ignore_ascii_case("json") {
        // Structured JSON logging for production (log aggregators: Datadog, Loki, etc.)
        tracing_subscriber::fmt()
            .with_env_filter(EnvFilter::new(&log_level))
            .with_target(false)
            .with_thread_ids(false)
            .json()
            .init();
    } else {
        // Compact human-readable logging for development
        tracing_subscriber::fmt()
            .with_env_filter(EnvFilter::new(&log_level))
            .with_target(false)
            .with_thread_ids(false)
            .compact()
            .init();
    }

    match cli.command {
        Commands::Dev {
            path,
            port,
            host,
            watch,
            strict,
            open,
            production,
            ..
        } => {
            run_dev_server(path, port, host, watch, strict, open, production).await?;
        }
        Commands::Build {
            path,
            output,
            no_minify,
            hash_assets,
            ..
        } => {
            run_build(path, output, !no_minify, hash_assets).await?;
        }
        Commands::Deploy {
            path,
            target,
            host,
            user,
            remote_dir,
            image,
            registry,
            output,
            yes,
            dry_run,
        } => {
            run_deploy(
                path, target, host, user, remote_dir, image, registry, output, yes, dry_run,
            )
            .await?;
        }
        Commands::New {
            name,
            template,
            no_interactive,
        } => {
            create_project(&name, template.as_deref(), no_interactive)?;
        }
        Commands::Sitemap { path, host, output } => {
            run_sitemap(path, host, &output)?;
        }
        Commands::Test { path, test_dir } => {
            let code = run_tests(path, &test_dir).await?;
            if code != 0 {
                std::process::exit(code);
            }
        }
        Commands::Doctor { path } => {
            run_doctor(path)?;
        }
        Commands::Bundle {
            path,
            output,
            compile,
        } => {
            bundle::create_bundle(&path, &output, compile)?;
        }
        Commands::Release { path, bump } => {
            let next = release::bump_repo_version(&path, bump)?;
            println!();
            println!("  Release version updated to {}", next);
            println!("  Website badges remain on the experimental channel");
            println!();
        }
        Commands::Generate(cmd) => match cmd {
            GenerateCommands::Page { name } => {
                generate_page(&name)?;
            }
            GenerateCommands::Component { name, props } => {
                generate_component(&name, props.as_deref())?;
            }
        },
    }

    Ok(())
}

/// Resolve log level from WHAT_LOG env var, --verbose flag, or --log-level flag.
/// Precedence: WHAT_LOG env var > --verbose > --log-level > default (info)
fn resolve_log_level(command: &Commands) -> String {
    // WHAT_LOG env var takes highest precedence
    if let Ok(env_level) = std::env::var("WHAT_LOG") {
        let level = env_level.to_lowercase();
        if matches!(
            level.as_str(),
            "error" | "warn" | "info" | "debug" | "trace"
        ) {
            return level;
        }
        eprintln!(
            "Warning: invalid WHAT_LOG value '{}', using default",
            env_level
        );
    }

    // Extract verbose and log_level from the command
    match command {
        Commands::Dev {
            verbose, log_level, ..
        }
        | Commands::Build {
            verbose, log_level, ..
        } => {
            if *verbose {
                "debug".to_string()
            } else {
                log_level.to_lowercase()
            }
        }
        _ => "info".to_string(),
    }
}

/// Run the development server
async fn run_dev_server(
    path: PathBuf,
    port: Option<u16>,
    host: Option<String>,
    watch: bool,
    strict: bool,
    open: bool,
    production: bool,
) -> Result<()> {
    let project_path = std::fs::canonicalize(&path)?;

    info!("Starting What development server...");
    info!("Project: {}", project_path.display());

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

    // Override with CLI args (only if explicitly provided)
    if let Some(p) = port {
        config.server.port = p;
    }
    if let Some(h) = host {
        config.server.host = h;
    }
    if strict {
        config.strict = true;
    }

    // Create app state (dev mode unless --production)
    let dev_mode = !production;
    let state = server::AppState::with_dev_mode(config, project_path.clone(), dev_mode)?;

    // Initialize async datasources (D1 system tables, etc.)
    if let Err(e) = state.init_datasources().await {
        eprintln!("Failed to initialize datasources: {}", e);
        std::process::exit(1);
    }

    // Start file watcher in background if enabled (skip in production mode)
    if watch && dev_mode {
        let watch_path = project_path.clone();
        let state_clone = state.clone();
        tokio::spawn(async move {
            if let Err(e) = watch_files(watch_path, state_clone).await {
                warn!("File watcher error: {}", e);
            }
        });
    }

    // Bind BEFORE printing the banner, so a port conflict surfaces as a
    // clear error instead of a success message followed by a raw OS error.
    let addr = format!("{}:{}", state.config.server.host, state.config.server.port);
    let listener = match tokio::net::TcpListener::bind(&addr).await {
        Ok(listener) => listener,
        Err(e) if e.kind() == std::io::ErrorKind::AddrInUse => {
            anyhow::bail!(
                "Port {} is already in use — is another server running?\n  Try: run-what dev --port {}",
                state.config.server.port,
                state.config.server.port + 1
            );
        }
        Err(e) => return Err(e.into()),
    };

    let content_dir = wwwhat_core::server::content_dir_name(&project_path);
    let routes = server::discover_routes(&project_path);

    println!();
    println!("  What server running!");
    println!();
    println!(
        "  Local:   http://{}:{}",
        state.config.server.host, state.config.server.port
    );
    println!("  Pages:   {} ({}/)", routes.len(), content_dir);
    println!();
    if routes.is_empty() {
        println!("  \x1b[33mWarning: no pages found in {}/\x1b[0m", content_dir);
        println!("  Pages are HTML files in {}/ — every route will 404 until one exists.", content_dir);
        println!("  Not a What project yet? Create one with: run-what new my-site");
        println!();
    }
    if watch && dev_mode {
        println!("  Live reload enabled - changes will auto-refresh browsers");
    }
    if production {
        println!("  Production mode - live reload and debug tools disabled");
    }
    println!("  Press Ctrl+C to stop");
    println!();

    if open {
        let url = format!(
            "http://{}:{}",
            state.config.server.host, state.config.server.port
        );
        tokio::spawn(async move {
            tokio::time::sleep(Duration::from_millis(500)).await;
            if let Err(e) = open::that(&url) {
                warn!("Could not open browser: {}", e);
            }
        });
    }

    // Start the server with graceful shutdown
    server::serve_on_listener(state, listener, shutdown_signal()).await?;

    info!("Server shut down gracefully");
    Ok(())
}

/// Wait for a shutdown signal (Ctrl+C)
async fn shutdown_signal() {
    tokio::signal::ctrl_c()
        .await
        .expect("failed to install Ctrl+C handler");
    println!();
    println!("  Shutting down gracefully...");
}

/// Watch files for changes and trigger live reload
async fn watch_files(path: PathBuf, state: server::AppState) -> Result<()> {
    let (async_tx, mut async_rx) = tokio::sync::mpsc::unbounded_channel::<Vec<PathBuf>>();

    let watch_path = path.clone();
    // Run notify watcher on a dedicated blocking thread (not a tokio worker)
    tokio::task::spawn_blocking(move || {
        let (tx, rx) = std::sync::mpsc::channel();
        let mut watcher: RecommendedWatcher =
            Watcher::new(tx, notify::Config::default()).expect("Failed to create file watcher");
        watcher
            .watch(&watch_path, RecursiveMode::Recursive)
            .expect("Failed to watch directory");

        loop {
            match rx.recv() {
                Ok(Ok(event)) => {
                    let relevant: Vec<PathBuf> = event
                        .paths
                        .into_iter()
                        .filter(|p| {
                            let path_str = p.to_string_lossy();
                            if path_str.contains("/.") || path_str.contains("\\.") {
                                return false;
                            }
                            if path_str.ends_with(".db") || path_str.ends_with(".db-journal") {
                                return false;
                            }
                            let ext = p.extension().and_then(|e| e.to_str()).unwrap_or("");
                            matches!(ext, "html" | "css" | "js" | "json" | "toml" | "what")
                        })
                        .collect();

                    if !relevant.is_empty() {
                        let _ = async_tx.send(relevant);
                    }
                }
                Ok(Err(e)) => warn!("Watch error: {}", e),
                Err(_) => break,
            }
        }
    });

    // Async receiver: trailing-edge debounce, clear cache, then broadcast
    // Waits for 300ms of silence after the last file change before reloading,
    // ensuring the browser always gets the latest content.
    let debounce = Duration::from_millis(300);
    let mut pending = false;

    loop {
        if pending {
            // We have a pending change — wait for more events or timeout
            match tokio::time::timeout(debounce, async_rx.recv()).await {
                Ok(Some(paths)) => {
                    // Another change arrived — reset the timer
                    info!("File changed: {:?}", paths);
                    continue;
                }
                Ok(None) => break, // Channel closed
                Err(_) => {
                    // Timeout: 300ms of silence — fire the reload
                    pending = false;
                    state.trigger_reload_async().await;
                }
            }
        } else {
            // No pending change — wait for the next event
            match async_rx.recv().await {
                Some(paths) => {
                    info!("File changed: {:?}", paths);
                    pending = true;
                }
                None => break, // Channel closed
            }
        }
    }

    Ok(())
}

/// Build the project for production
async fn run_build(path: PathBuf, output: PathBuf, minify: bool, hash_assets: bool) -> Result<()> {
    println!();
    println!("  Building project for production...");
    println!("  Source: {}", path.display());
    println!("  Output: {}", output.display());
    if minify {
        println!("  Minification: enabled");
    }
    if hash_assets {
        println!("  Asset hashing: enabled");
    }
    println!();

    let result = prerender::prerender(prerender::PreRenderConfig {
        project_path: path,
        output_path: output.clone(),
        minify,
    })
    .await?;

    // Hash static assets after build if requested
    if hash_assets {
        let hashed = prerender::hash_static_assets(&output)?;
        if !hashed.is_empty() {
            println!("  Hashed {} static assets", hashed.len());
        }
    }

    println!();
    println!("  Build complete!");
    println!("  Pages rendered: {}", result.pages_rendered);
    println!("  Total size: {}", format_bytes(result.total_bytes));

    if !result.pages_skipped.is_empty() {
        println!("  Skipped:");
        for page in &result.pages_skipped {
            println!("    - {}", page);
        }
    }
    println!();

    Ok(())
}

pub(crate) fn format_bytes(bytes: usize) -> String {
    if bytes >= 1_048_576 {
        format!("{:.1} MB", bytes as f64 / 1_048_576.0)
    } else if bytes >= 1024 {
        format!("{:.1} KB", bytes as f64 / 1024.0)
    } else {
        format!("{} B", bytes)
    }
}

async fn run_deploy(
    path: PathBuf,
    target: Option<String>,
    host: Option<String>,
    user: Option<String>,
    remote_dir: Option<String>,
    image: Option<String>,
    registry: Option<String>,
    output: PathBuf,
    yes: bool,
    dry_run: bool,
) -> Result<()> {
    let project_path = std::fs::canonicalize(&path)?;

    // Resolve target: CLI arg or interactive prompt
    let target = match target {
        Some(t) => t,
        None => deploy::choose_deploy_target()?,
    };

    match target.as_str() {
        "ssh" => {
            deploy::deploy_ssh(
                &project_path,
                host.as_deref(),
                user.as_deref(),
                remote_dir.as_deref(),
                dry_run,
                yes,
            )
            .await?;
        }
        "docker" => {
            deploy::deploy_docker(
                &project_path,
                image.as_deref(),
                registry.as_deref(),
                dry_run,
                yes,
            )
            .await?;
        }
        "static" => {
            deploy::deploy_static(&project_path, &output, dry_run).await?;
        }
        _ => {
            anyhow::bail!(
                "Unknown deploy target: '{}'. Use ssh, docker, or static.",
                target
            );
        }
    }

    Ok(())
}

/// Generate an XML sitemap from discovered routes
fn run_sitemap(path: PathBuf, host: Option<String>, output: &str) -> Result<()> {
    let project_path = std::fs::canonicalize(&path)?;
    let base_url = host.unwrap_or_else(|| "https://example.com".to_string());
    let base_url = base_url.trim_end_matches('/');

    let sitemap = prerender::generate_sitemap(&project_path, base_url);

    let output_path = project_path.join(output);
    std::fs::write(&output_path, &sitemap)?;

    println!();
    println!("  Sitemap generated: {}", output_path.display());
    println!("  Base URL: {}", base_url);
    println!();

    Ok(())
}

/// Create a new What project
fn create_project(name: &str, template: Option<&str>, no_interactive: bool) -> Result<()> {
    info!("Creating new What project: {}", name);

    let project_dir = PathBuf::from(name);

    // Check if directory exists
    if project_dir.exists() {
        if no_interactive {
            anyhow::bail!("Directory '{}' already exists", name);
        }

        if !wizard::confirm_overwrite(name)? {
            anyhow::bail!("Aborted: directory already exists");
        }

        std::fs::remove_dir_all(&project_dir)?;
    }

    // Determine template choice
    let choice = match template {
        Some("tutorial") | Some("example") | Some("starter") | Some("demo") => {
            wizard::TemplateChoice::Tutorial
        }
        Some("minimum") | Some("blank") | Some("basic") => wizard::TemplateChoice::Minimum,
        Some(other) => {
            anyhow::bail!("Unknown template: '{}'. Use 'minimum' or 'tutorial'", other)
        }
        None if no_interactive => wizard::TemplateChoice::Minimum,
        None => wizard::choose_template()?,
    };

    std::fs::create_dir_all(&project_dir)?;

    match choice {
        wizard::TemplateChoice::Minimum => create_blank_project(&project_dir)?,
        wizard::TemplateChoice::Tutorial => create_example_project(&project_dir)?,
    }

    println!();
    println!("  \x1b[32mProject created: {}\x1b[0m", name);
    println!();
    println!("  To get started:");
    println!("    cd {}", name);
    println!("    run-what dev");
    println!();
    println!("  Then open \x1b[36mhttp://127.0.0.1:8085\x1b[0m");
    println!();
    println!("  Try these:");
    println!("    run-what generate page contact    \x1b[2m# create a new page\x1b[0m");
    println!("    run-what generate component card  \x1b[2m# create a component\x1b[0m");
    println!("    run-what test                     \x1b[2m# run template tests\x1b[0m");
    println!();

    Ok(())
}

/// Create a skeleton project with a layout and landing page
fn create_blank_project(project_dir: &PathBuf) -> Result<()> {
    let dirs = ["site", "components", "static", "data"];
    for dir in dirs {
        std::fs::create_dir_all(project_dir.join(dir))?;
    }

    let config = r#"# What Project Configuration

[server]
port = 8085
host = "127.0.0.1"

[session]
enabled = true
cookie_name = "w_session"
max_age = 604800
database = "data/sessions.db"
"#;
    std::fs::write(project_dir.join("wwwhat.toml"), config)?;

    let app_what = r#"layout = "components/layout.html"
"#;
    std::fs::write(
        project_dir.join("site").join("application.what"),
        app_what,
    )?;

    let layout = r##"<what>
layout: none
</what>
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <link rel="stylesheet" href="/static/styles.css">
  <title>#title|default:"What App"#</title>
</head>
<body>
  <slot/>
</body>
</html>
"##;
    std::fs::write(
        project_dir.join("components").join("layout.html"),
        layout,
    )?;

    let index = r##"<what>
title: "What?"
</what>

<section class="jumbo jumbo-lg">
  <div class="jumbo-content">
    <h1 class="jumbo-title">What?</h1>
    <p class="jumbo-subtitle">Build modern web apps with just HTML.</p>
  </div>
</section>
"##;
    std::fs::write(project_dir.join("site").join("index.html"), index)?;

    let styles_css = r#"/*
 * styles.css — Your custom styles
 *
 * The framework provides what.css with utility classes (layout, typography,
 * colors, spacing), components (buttons, cards, forms, modals, tables),
 * and dark mode support — all loaded automatically.
 *
 * Use this file for styles that go beyond the framework.
 * See available classes: https://www.getwhatnow.com/docs/css
 */
"#;
    std::fs::write(project_dir.join("static").join("styles.css"), styles_css)?;

    let app_js = r#"/*
 * app.js — Your custom JavaScript
 *
 * wwwhat handles most interactivity through HTML attributes:
 *   w-set    — update server state on click
 *   w-get    — fetch and swap content without page reload
 *   w-post   — submit data to the server
 *   w-validate — client-side form validation
 *
 * You probably don't need this file. Use it only for things HTML
 * attributes can't do: third-party libraries, canvas, maps, etc.
 */
"#;
    std::fs::write(project_dir.join("static").join("app.js"), app_js)?;

    Ok(())
}

/// Create the 10-step tutorial project
fn create_example_project(project_dir: &PathBuf) -> Result<()> {
    let dirs = ["site", "site/tutorial", "components", "static", "data"];
    for dir in dirs {
        std::fs::create_dir_all(project_dir.join(dir))?;
    }

    // Config
    std::fs::write(
        project_dir.join("wwwhat.toml"),
        include_str!("../assets/example/wwwhat.toml"),
    )?;

    // Layout and components
    std::fs::write(
        project_dir.join("components").join("tutorial-layout.html"),
        include_str!("../assets/example/components/tutorial-layout.html"),
    )?;
    std::fs::write(
        project_dir.join("components").join("info-box.html"),
        include_str!("../assets/example/components/info-box.html"),
    )?;
    std::fs::write(
        project_dir.join("components").join("stat-card.html"),
        include_str!("../assets/example/components/stat-card.html"),
    )?;

    // Static assets
    std::fs::write(
        project_dir.join("static").join("styles.css"),
        include_str!("../assets/example/static/styles.css"),
    )?;
    std::fs::write(
        project_dir.join("static").join("app.js"),
        include_str!("../assets/example/static/app.js"),
    )?;

    // Application config (root — empty, no layout at root level)
    std::fs::write(
        project_dir.join("site").join("application.what"),
        include_str!("../assets/example/site/application.what"),
    )?;

    // Tutorial application config (layout for tutorial pages)
    std::fs::write(
        project_dir.join("site/tutorial").join("application.what"),
        include_str!("../assets/example/site/tutorial/application.what"),
    )?;

    // Welcome page (standalone, no tutorial layout)
    std::fs::write(
        project_dir.join("site").join("index.html"),
        include_str!("../assets/example/site/index.html"),
    )?;

    // 10 tutorial steps
    for i in 1..=10 {
        let filename = format!("{}.html", i);
        let content = match i {
            1 => include_str!("../assets/example/site/tutorial/1.html"),
            2 => include_str!("../assets/example/site/tutorial/2.html"),
            3 => include_str!("../assets/example/site/tutorial/3.html"),
            4 => include_str!("../assets/example/site/tutorial/4.html"),
            5 => include_str!("../assets/example/site/tutorial/5.html"),
            6 => include_str!("../assets/example/site/tutorial/6.html"),
            7 => include_str!("../assets/example/site/tutorial/7.html"),
            8 => include_str!("../assets/example/site/tutorial/8.html"),
            9 => include_str!("../assets/example/site/tutorial/9.html"),
            10 => include_str!("../assets/example/site/tutorial/10.html"),
            _ => unreachable!(),
        };
        std::fs::write(project_dir.join("site/tutorial").join(&filename), content)?;
    }

    Ok(())
}

/// Generate a new page
fn generate_page(name: &str) -> Result<()> {
    let cwd = std::env::current_dir()?;
    let content = wwwhat_core::server::content_dir_name(&cwd);
    let file_path = PathBuf::from(content).join(format!("{}.html", name));

    if file_path.exists() {
        anyhow::bail!("Page already exists: {}", file_path.display());
    }

    // Ensure parent directory exists
    if let Some(parent) = file_path.parent() {
        std::fs::create_dir_all(parent)?;
    }

    let title = name.replace('-', " ");
    let title = title
        .split_whitespace()
        .map(|word| {
            let mut chars = word.chars();
            match chars.next() {
                None => String::new(),
                Some(first) => first.to_uppercase().chain(chars).collect(),
            }
        })
        .collect::<Vec<_>>()
        .join(" ");

    std::fs::write(&file_path, generated_page_content(&title))?;
    println!("  \x1b[32mCreated\x1b[0m {}", file_path.display());

    Ok(())
}

/// Content emitted by `generate page`: bare content with a <what> title
/// block — the project layout wraps it. (A full-document component like
/// <what-page> would nest a second <!DOCTYPE html> inside the layout's
/// document.)
fn generated_page_content(title: &str) -> String {
    format!(
        r#"<what>
title: "{}"
</what>

<section class="container py-8">
  <h1>{}</h1>
  <p>Your content here...</p>
</section>
"#,
        title, title
    )
}

/// Generate a new component
fn generate_component(name: &str, props: Option<&str>) -> Result<()> {
    let file_path = PathBuf::from("components").join(format!("{}.html", name));

    if file_path.exists() {
        anyhow::bail!("Component already exists: {}", file_path.display());
    }

    // Ensure parent directory exists
    if let Some(parent) = file_path.parent() {
        std::fs::create_dir_all(parent)?;
    }

    let props_str = props.unwrap_or("");
    let content = if props_str.is_empty() {
        format!(
            r#"<div class="{}">
  <slot/>
</div>
"#,
            name
        )
    } else {
        format!(
            r#"<what>
props = "{}"
</what>
<div class="{}">
  <slot/>
</div>
"#,
            props_str, name
        )
    };

    std::fs::write(&file_path, content)?;
    println!(
        "  \x1b[32mCreated\x1b[0m {} \x1b[2m(use as <what-{}>)\x1b[0m",
        file_path.display(),
        name
    );

    Ok(())
}

/// Template test assertion parsed from a .what test file.
struct TestCase {
    name: String,
    page: String,
    status: Option<u16>,
    contains: Vec<String>,
    not_contains: Vec<String>,
    redirect: Option<String>,
}

/// Parse a .what test file into a TestCase.
fn parse_test_file(path: &std::path::Path) -> Result<TestCase> {
    let content = std::fs::read_to_string(path)?;
    let name = path
        .file_stem()
        .unwrap_or_default()
        .to_string_lossy()
        .to_string();

    let mut page = None;
    let mut status = None;
    let mut contains = Vec::new();
    let mut not_contains = Vec::new();
    let mut redirect = None;

    for line in content.lines() {
        let line = line.trim();
        if line.is_empty() || line.starts_with('#') {
            continue;
        }

        if let Some((key, value)) = line.split_once(':') {
            let key = key.trim();
            let value = value.trim().trim_matches('"');
            match key {
                "page" => page = Some(value.to_string()),
                "status" => {
                    status = Some(
                        value
                            .parse()
                            .map_err(|_| anyhow::anyhow!("Invalid status: {}", value))?,
                    )
                }
                "contains" => contains.push(value.to_string()),
                "not_contains" => not_contains.push(value.to_string()),
                "redirect" => redirect = Some(value.to_string()),
                _ => warn!("Unknown test directive '{}' in {}", key, path.display()),
            }
        }
    }

    let page = page
        .ok_or_else(|| anyhow::anyhow!("Test file {} missing 'page' directive", path.display()))?;

    Ok(TestCase {
        name,
        page,
        status,
        contains,
        not_contains,
        redirect,
    })
}

/// Run template tests from .what files.
/// Returns exit code: 0 = all passed, 1 = failures.
async fn run_tests(path: PathBuf, test_dir: &str) -> Result<i32> {
    let project_path = std::fs::canonicalize(&path)?;
    let tests_path = project_path.join(test_dir);

    if !tests_path.exists() {
        // A fresh project has no tests/ — that's a starting point, not an error.
        println!();
        println!("  No tests yet — create a {}/ directory with .what test files.", test_dir);
        println!();
        println!("  Example ({}/home.what):", test_dir);
        println!("    page: /");
        println!("    status: 200");
        println!("    contains: \"<h1>\"");
        println!();
        return Ok(0);
    }

    // Collect .what test files
    let mut test_files: Vec<_> = std::fs::read_dir(&tests_path)?
        .filter_map(|e| e.ok())
        .filter(|e| e.path().extension().map_or(false, |ext| ext == "what"))
        .map(|e| e.path())
        .collect();
    test_files.sort();

    if test_files.is_empty() {
        println!("  No test files found in {}", tests_path.display());
        return Ok(0);
    }

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

    let state = server::AppState::new(config, project_path)?;

    println!();
    println!("  Running {} tests...", test_files.len());
    println!();

    let mut passed = 0;
    let mut failed = 0;

    for test_path in &test_files {
        let test_case = parse_test_file(test_path)?;
        let mut errors: Vec<String> = Vec::new();

        // Render page through the engine instead of going through HTTP
        let html = server::render_page_to_html(&state, &test_case.page).await;

        // Determine status and body
        let (actual_status, body_str, redirect_location): (u16, String, Option<String>) = match html
        {
            Ok(Some(rendered)) => (200, rendered, None),
            Ok(None) => (404, String::new(), None),
            Err(e) => {
                errors.push(format!("render error: {}", e));
                (500, String::new(), None)
            }
        };

        // Check status
        if let Some(expected_status) = test_case.status {
            if actual_status != expected_status {
                errors.push(format!(
                    "status: expected {}, got {}",
                    expected_status, actual_status
                ));
            }
        }

        // Check redirect
        if let Some(ref expected_redirect) = test_case.redirect {
            match redirect_location {
                Some(ref loc) if loc == expected_redirect => {}
                Some(ref loc) => errors.push(format!(
                    "redirect: expected '{}', got '{}'",
                    expected_redirect, loc
                )),
                None => errors.push(format!(
                    "redirect: expected '{}', got no redirect",
                    expected_redirect
                )),
            }
        }

        // Content assertions
        if !test_case.contains.is_empty() || !test_case.not_contains.is_empty() {
            for expected in &test_case.contains {
                if !body_str.contains(expected.as_str()) {
                    errors.push(format!("contains: '{}' not found in response", expected));
                }
            }

            for unexpected in &test_case.not_contains {
                if body_str.contains(unexpected.as_str()) {
                    errors.push(format!("not_contains: '{}' found in response", unexpected));
                }
            }
        }

        if errors.is_empty() {
            println!("  \x1b[32mPASS\x1b[0m  {}", test_case.name);
            passed += 1;
        } else {
            println!("  \x1b[31mFAIL\x1b[0m  {}", test_case.name);
            for err in &errors {
                println!("        \x1b[2m- {}\x1b[0m", err);
            }
            failed += 1;
        }
    }

    println!();
    if failed == 0 {
        println!(
            "  \x1b[32mResults: {} passed, {} failed\x1b[0m",
            passed, failed
        );
    } else {
        println!(
            "  \x1b[31mResults: {} passed, {} failed\x1b[0m",
            passed, failed
        );
    }
    println!();

    Ok(if failed > 0 { 1 } else { 0 })
}

/// Run project health checks
fn run_doctor(path: PathBuf) -> Result<()> {
    let project_path = std::fs::canonicalize(&path)?;
    let mut issues: Vec<String> = Vec::new();
    let mut ok: Vec<String> = Vec::new();

    println!();
    println!(
        "  \x1b[1mWhat Doctor\x1b[0m — checking project at {}",
        project_path.display()
    );
    println!();

    // 1. Check wwwhat.toml
    let config_path = project_path.join("wwwhat.toml");
    if config_path.exists() {
        match Config::load(&config_path) {
            Ok(_) => ok.push("wwwhat.toml is valid".to_string()),
            Err(e) => issues.push(format!("wwwhat.toml parse error: {}", e)),
        }
    } else {
        // Zero-config is a supported, first-class path — not an issue.
        ok.push("wwwhat.toml not found (zero-config defaults apply)".to_string());
    }

    // 2. Check content directory
    let content_dir = wwwhat_core::server::content_dir_name(&project_path);
    let content_path = project_path.join(content_dir);
    if content_path.is_dir() {
        let page_count = std::fs::read_dir(&content_path)
            .map(|rd| {
                rd.filter_map(|e| e.ok())
                    .filter(|e| e.path().extension().map_or(false, |ext| ext == "html"))
                    .count()
            })
            .unwrap_or(0);
        ok.push(format!(
            "{}/  directory found ({} pages)",
            content_dir, page_count
        ));
    } else {
        issues.push(format!(
            "{}/  directory missing — create it to add pages",
            content_dir
        ));
    }

    // Check for deprecated pages/ alongside site/
    if project_path.join("site").is_dir() && project_path.join("pages").is_dir() {
        issues.push("Both site/ and pages/ exist — remove pages/ to avoid confusion".to_string());
    }

    // 3. Check components directory
    let comp_path = project_path.join("components");
    if comp_path.is_dir() {
        let comp_count = std::fs::read_dir(&comp_path)
            .map(|rd| {
                rd.filter_map(|e| e.ok())
                    .filter(|e| e.path().extension().map_or(false, |ext| ext == "html"))
                    .count()
            })
            .unwrap_or(0);
        ok.push(format!(
            "components/ directory found ({} components)",
            comp_count
        ));
    } else {
        ok.push("components/ directory missing (optional)".to_string());
    }

    // 4. Check static assets
    let static_path = project_path.join("static");
    if static_path.is_dir() {
        ok.push("static/ directory present (for custom assets)".to_string());
    } else {
        ok.push(
            "static/ directory missing (optional — framework CSS/JS are auto-injected)".to_string(),
        );
    }

    // 5. Check data directory (only needed when sessions/data store are used;
    // a static site is fine without it)
    let data_path = project_path.join("data");
    if !data_path.is_dir() {
        ok.push(
            "data/ directory missing (optional — created automatically when sessions or the data store are used)"
                .to_string(),
        );
    } else {
        ok.push("data/ directory present".to_string());
    }

    // Print results
    for item in &ok {
        println!("  \x1b[32m✓\x1b[0m {}", item);
    }
    for item in &issues {
        println!("  \x1b[33m!\x1b[0m {}", item);
    }

    println!();
    if issues.is_empty() {
        println!("  \x1b[32mAll checks passed\x1b[0m");
    } else {
        println!("  {} ok, {} issues", ok.len(), issues.len());
    }
    println!();

    Ok(())
}

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

    /// Remove regions where literal template syntax is expected (doc/code
    /// examples) before scanning rendered output for leakage.
    fn strip_ignored_regions(html: &str) -> String {
        let re =
            regex::Regex::new(r"(?is)<(code|pre|script|style|textarea)\b.*?</(code|pre|script|style|textarea)>")
                .unwrap();
        re.replace_all(html, "").to_string()
    }

    /// Source-level scan of a generated project: scaffolds must never ship
    /// legacy `cond=` conditionals or the malformed trailing-else pattern
    /// (`</if><else/>` always renders — a real bug we shipped once).
    fn assert_no_legacy_template_patterns(project_dir: &std::path::Path, label: &str) {
        let legacy_cond = regex::Regex::new(r"<(?:if|elseif|unless)\b[^>]*\bcond\s*=").unwrap();
        let trailing_else = regex::Regex::new(r"</if>\s*<else\s*/?>").unwrap();
        for entry in walkdir::WalkDir::new(project_dir)
            .into_iter()
            .filter_map(|e| e.ok())
            .filter(|e| e.path().extension().is_some_and(|x| x == "html"))
        {
            let raw = fs::read_to_string(entry.path()).unwrap();
            assert!(
                !legacy_cond.is_match(&raw),
                "{}: {} ships legacy cond= syntax — scaffolds must use the simplified form",
                label,
                entry.path().display()
            );
            assert!(
                !trailing_else.is_match(&raw),
                "{}: {} ships a trailing <else/> after </if> (the else branch would always render)",
                label,
                entry.path().display()
            );
        }
    }

    /// Render every non-dynamic route of a generated project through the real
    /// engine and assert the output is clean: no render errors, no unresolved
    /// `#var#` leakage, no unprocessed template tags (outside code examples).
    async fn assert_all_pages_render_clean(project_dir: &std::path::Path, label: &str) {
        // macOS: /var -> /private/var symlink; canonicalize for path checks
        let project_dir = project_dir.canonicalize().unwrap();
        let config = Config::load(&project_dir.join("wwwhat.toml")).unwrap();
        let state = server::AppState::new(config, project_dir.clone()).unwrap();
        state.init_datasources().await.unwrap();

        let unresolved = regex::Regex::new(r"#([a-zA-Z_][\w.]*(?:\|[^#]+)?)#").unwrap();
        let unprocessed = regex::Regex::new(r"</?(?:if|unless|loop|include|elseif|else)\b").unwrap();

        let routes = server::discover_routes(&project_dir);
        assert!(!routes.is_empty(), "{}: no routes discovered", label);

        for (url_path, is_dynamic) in &routes {
            if *is_dynamic {
                continue;
            }
            let html = match server::render_page_to_html(&state, url_path).await {
                Ok(Some(html)) => html,
                Ok(None) => panic!(
                    "{}: route {} rendered to None (auth-protected or excluded) — scaffold pages must be public",
                    label, url_path
                ),
                Err(e) => panic!("{}: route {} failed to render: {}", label, url_path, e),
            };
            let visible = strip_ignored_regions(&html);

            for cap in unresolved.captures_iter(&visible) {
                let var = &cap[1];
                // Internal/system tokens are skipped, mirroring strict mode
                assert!(
                    var.starts_with('_'),
                    "{}: route {} leaked unresolved variable #{}#",
                    label,
                    url_path,
                    var
                );
            }
            if let Some(m) = unprocessed.find(&visible) {
                panic!(
                    "{}: route {} contains unprocessed template tag near: {}",
                    label,
                    url_path,
                    &visible[m.start()..(m.start() + 60).min(visible.len())]
                );
            }
            // Engine error markers (dev banner text / non-dev HTML comments).
            // Caught a real bug once: tutorial 6 shipped inline-JSON <loop>
            // data the engine can't resolve, rendering error banners.
            for marker in [
                "Loop: no data for",
                "<!-- loop: no data",
                "<!-- include error:",
                "<!-- include not found:",
            ] {
                assert!(
                    !html.contains(marker),
                    "{}: route {} contains engine error marker {:?}",
                    label,
                    url_path,
                    marker
                );
            }
        }
    }

    #[tokio::test]
    async fn blank_project_renders_clean() {
        let temp_dir = tempfile::tempdir().unwrap();
        let project_dir = temp_dir.path().join("blank-app");
        create_blank_project(&project_dir).unwrap();
        assert_no_legacy_template_patterns(&project_dir, "blank");
        assert_all_pages_render_clean(&project_dir, "blank").await;
    }

    #[tokio::test]
    async fn generated_page_renders_clean_in_blank_scaffold() {
        // Regression: `generate page` used to emit a full-document
        // <what-page> wrapper, which the blank scaffold's layout nested
        // inside its own document — doubled <!DOCTYPE html>.
        let temp_dir = tempfile::tempdir().unwrap();
        let project_dir = temp_dir.path().join("blank-app");
        create_blank_project(&project_dir).unwrap();
        fs::write(
            project_dir.join("site").join("contact.html"),
            generated_page_content("Contact"),
        )
        .unwrap();

        assert_all_pages_render_clean(&project_dir, "blank+generated").await;

        let project_dir = project_dir.canonicalize().unwrap();
        let config = Config::load(&project_dir.join("wwwhat.toml")).unwrap();
        let state = server::AppState::new(config, project_dir).unwrap();
        state.init_datasources().await.unwrap();
        let html = server::render_page_to_html(&state, "/contact")
            .await
            .unwrap()
            .unwrap();
        assert_eq!(
            html.matches("<!DOCTYPE").count(),
            1,
            "generated page must produce exactly one document, got: {}",
            &html[..200.min(html.len())]
        );
        assert!(html.contains("<h1>Contact</h1>"), "got: {}", &html[..200.min(html.len())]);
    }

    #[tokio::test]
    async fn example_project_renders_clean() {
        let temp_dir = tempfile::tempdir().unwrap();
        let project_dir = temp_dir.path().join("example-app");
        create_example_project(&project_dir).unwrap();
        assert_no_legacy_template_patterns(&project_dir, "tutorial");
        assert_all_pages_render_clean(&project_dir, "tutorial").await;
    }

    async fn assert_project_starts_and_builds(
        project_dir: &std::path::Path,
        expected_text: &str,
        minimum_pages: usize,
    ) {
        let config = Config::load(&project_dir.join("wwwhat.toml")).unwrap();
        let state =
            server::AppState::with_dev_mode(config, project_dir.to_path_buf(), true).unwrap();
        state.init_datasources().await.unwrap();

        let output_dir = project_dir.join("dist");
        let result = prerender::prerender(prerender::PreRenderConfig {
            project_path: project_dir.to_path_buf(),
            output_path: output_dir.clone(),
            minify: false,
        })
        .await
        .unwrap();

        assert!(
            result.pages_rendered >= minimum_pages,
            "expected at least {} rendered pages, got {}",
            minimum_pages,
            result.pages_rendered
        );

        let index = fs::read_to_string(output_dir.join("index.html")).unwrap();
        assert!(
            index.contains(expected_text),
            "index.html missing expected text"
        );
    }

    #[tokio::test]
    async fn blank_project_smoke_test() {
        let temp_dir = tempfile::tempdir().unwrap();
        let project_dir = temp_dir.path().join("blank-app");

        create_blank_project(&project_dir).unwrap();

        assert_project_starts_and_builds(&project_dir, "What?", 1).await;
        assert!(project_dir.join("site").join("index.html").exists());
        assert!(project_dir.join("components").join("layout.html").exists());
        assert!(project_dir.join("static").join("styles.css").exists());
    }

    #[tokio::test]
    async fn example_project_smoke_test() {
        let temp_dir = tempfile::tempdir().unwrap();
        let project_dir = temp_dir.path().join("example-app");

        create_example_project(&project_dir).unwrap();

        assert_project_starts_and_builds(&project_dir, "Welcome to wwwhat", 11).await;
        assert!(
            project_dir
                .join("site")
                .join("tutorial")
                .join("10.html")
                .exists()
        );
        assert!(
            project_dir
                .join("components")
                .join("tutorial-layout.html")
                .exists()
        );
    }
}
