mod duration;
mod format;
mod matching;
mod parse;
mod run;
mod update;

use std::fs;
use std::io::{Write, stderr, stdout};
use std::path::{Path, PathBuf};
use std::process;
use std::time::Instant;

use format::{TestResult, evaluate_file, format_failure, format_summary, print_dot};
use parse::{Command, Directive, ExitCode, ShoutFile};
use run::{CommandResult, RunOptions, cleanup_tmp_dir, command_passes, run_file};
use update::rewrite_file;

const VERSION: &str = env!("CARGO_PKG_VERSION");

// ANSI
const RED: &str = "\x1b[31m";
const YELLOW: &str = "\x1b[33m";
const DIM: &str = "\x1b[2m";
const RESET: &str = "\x1b[0m";

struct TestOpts {
    files: Vec<String>,
    update: bool,
    keep: bool,
    clean_env: bool,
    path_dirs: Vec<String>,
    timeout: String,
    verbose: bool,
    port_from: u16,
    filter: Option<String>,
    parallel: bool,
}

fn print_usage() {
    eprintln!("Usage: shout [options] [command]");
    eprintln!();
    eprintln!("$ shell output tester");
    eprintln!();
    eprintln!("Options:");
    eprintln!("  -V, --version              output the version number");
    eprintln!("  -h, --help                 display help for command");
    eprintln!();
    eprintln!("Commands:");
    eprintln!("  test [options] [files...]  Run .shout test files");
    eprintln!("  version                    Print the version");
    eprintln!("  example                    Print an example .shout file");
    eprintln!("  help [command]             display help for command");
}

fn print_test_help() {
    eprintln!("Usage: shout test [options] [files...]");
    eprintln!();
    eprintln!("Run .shout test files");
    eprintln!();
    eprintln!("Arguments:");
    eprintln!("  files                      Files or directories to test");
    eprintln!();
    eprintln!("Options:");
    eprintln!("  -u, --update               Rewrite expected output in-place with actual output");
    eprintln!("  -k, --keep                 Keep temp directories after run");
    eprintln!("  --clean-env                Start with empty environment");
    eprintln!("  --path <path>              Prepend <path> to PATH (repeatable)");
    eprintln!("  --timeout <dur>            Per-command timeout (default: 10s)");
    eprintln!("  -v, --verbose              Print each command as it runs");
    eprintln!("  --port-from <n>            Auto-assign $PORT starting from <n> (default: 5400)");
    eprintln!("  -t, --filter <pattern>     Only run files matching <pattern> (substring match)");
    eprintln!("  --parallel                 Run files in parallel");
    eprintln!("  -h, --help                 display help for command");
}

fn parse_args() -> Option<(&'static str, TestOpts)> {
    let args: Vec<String> = std::env::args().skip(1).collect();

    if args.is_empty() || args[0] == "-h" || args[0] == "--help" || args[0] == "help" {
        print_usage();
        process::exit(if args.is_empty() { 1 } else { 0 });
    }

    let subcommand = args[0].as_str();

    match subcommand {
        "-V" | "--version" | "version" => {
            println!("{VERSION}");
            process::exit(0);
        }
        "example" => {
            print_example();
            process::exit(0);
        }
        "test" => {}
        other => {
            eprintln!("Unknown command: {other}");
            eprintln!("Run 'shout --help' for usage");
            process::exit(1);
        }
    }

    let mut opts = TestOpts {
        files: vec![],
        update: false,
        keep: false,
        clean_env: false,
        path_dirs: vec![],
        timeout: "10s".to_string(),
        verbose: false,
        port_from: 5400,
        filter: None,
        parallel: false,
    };

    let mut i = 1;
    while i < args.len() {
        match args[i].as_str() {
            "-h" | "--help" => {
                print_test_help();
                process::exit(0);
            }
            "-u" | "--update" => opts.update = true,
            "-k" | "--keep" => opts.keep = true,
            "--clean-env" => opts.clean_env = true,
            "-v" | "--verbose" => opts.verbose = true,
            "--parallel" => opts.parallel = true,
            "--path" => {
                i += 1;
                if i >= args.len() {
                    eprintln!("--path requires a value");
                    process::exit(1);
                }
                opts.path_dirs.push(args[i].clone());
            }
            "--timeout" => {
                i += 1;
                if i >= args.len() {
                    eprintln!("--timeout requires a value");
                    process::exit(1);
                }
                opts.timeout = args[i].clone();
            }
            "--port-from" => {
                i += 1;
                if i >= args.len() {
                    eprintln!("--port-from requires a value");
                    process::exit(1);
                }
                opts.port_from = match args[i].parse() {
                    Ok(n) => n,
                    Err(_) => {
                        eprintln!("--port-from must be an integer");
                        process::exit(1);
                    }
                };
            }
            "-t" | "--filter" => {
                i += 1;
                if i >= args.len() {
                    eprintln!("--filter requires a value");
                    process::exit(1);
                }
                opts.filter = Some(args[i].clone());
            }
            arg if arg.starts_with('-') => {
                eprintln!("Unknown option: {arg}");
                process::exit(1);
            }
            _ => opts.files.push(args[i].clone()),
        }
        i += 1;
    }

    Some(("test", opts))
}

fn find_shout_files_recursive(dir: &Path, out: &mut Vec<PathBuf>) {
    if let Ok(entries) = fs::read_dir(dir) {
        for entry in entries.flatten() {
            let path = entry.path();
            if path.is_dir() {
                find_shout_files_recursive(&path, out);
            } else if path.extension().is_some_and(|ext| ext == "shout") {
                out.push(path);
            }
        }
    }
}

fn filter_gitignored(files: Vec<PathBuf>) -> Vec<PathBuf> {
    if files.is_empty() {
        return files;
    }

    let input: String = files.iter().map(|f| f.to_string_lossy().to_string()).collect::<Vec<_>>().join("\n");

    let result = process::Command::new("git")
        .args(["check-ignore", "--stdin"])
        .stdin(process::Stdio::piped())
        .stdout(process::Stdio::piped())
        .stderr(process::Stdio::null())
        .spawn();

    let Ok(mut child) = result else {
        return files;
    };

    if let Some(mut stdin) = child.stdin.take() {
        let _ = stdin.write_all(input.as_bytes());
    }

    let output = child.wait_with_output();
    let Ok(output) = output else {
        return files;
    };

    let ignored: std::collections::HashSet<String> = String::from_utf8_lossy(&output.stdout)
        .lines()
        .filter(|l| !l.is_empty())
        .map(|l| l.to_string())
        .collect();

    files
        .into_iter()
        .filter(|f| !ignored.contains(&f.to_string_lossy().to_string()))
        .collect()
}

fn find_shout_files(paths: &[String]) -> Vec<PathBuf> {
    let mut explicit = Vec::new();
    let mut discovered = Vec::new();

    for p in paths {
        let abs = fs::canonicalize(p).unwrap_or_else(|_| PathBuf::from(p));
        if abs.is_file() && abs.extension().is_some_and(|e| e == "shout") {
            explicit.push(abs);
        } else if abs.is_dir() {
            find_shout_files_recursive(&abs, &mut discovered);
        } else if abs.extension().is_some_and(|e| e == "shout") {
            explicit.push(abs);
        }
    }

    let filtered = filter_gitignored(discovered);
    let mut all: Vec<PathBuf> = explicit.into_iter().chain(filtered).collect();
    all.sort();
    all
}

fn run_one(
    file_path: &Path,
    port: u16,
    opts: &TestOpts,
    timeout_ms: u64,
    cwd: &Path,
) -> TestResult {
    let content = match fs::read_to_string(file_path) {
        Ok(c) => c,
        Err(e) => {
            return evaluate_file(
                &rel_path(cwd, file_path),
                &[],
                Some(&format!("Failed to read file: {e}")),
            );
        }
    };

    let rel = rel_path(cwd, file_path);
    let parsed = match parse::parse(&rel, &content) {
        Ok(p) => p,
        Err(e) => {
            return evaluate_file(&rel, &[], Some(&e.0));
        }
    };

    // Resolve directives
    let mut setup_env: Vec<(String, String)> = vec![];
    let mut user_env: Vec<(String, String)> = vec![];
    let mut setup_commands: Vec<Command> = vec![];
    let mut teardown_commands: Vec<Command> = parsed.teardown_commands.clone();

    for d in &parsed.directives {
        match d {
            Directive::Setup { path: setup_path, .. } => {
                let full_path = file_path.parent().unwrap().join(setup_path);
                let setup_content = match fs::read_to_string(&full_path) {
                    Ok(c) => c,
                    Err(e) => {
                        return evaluate_file(
                            &rel,
                            &[],
                            Some(&format!("Failed to read setup file {setup_path}: {e}")),
                        );
                    }
                };
                let setup_rel = rel_path(cwd, &full_path);
                let setup_parsed = match parse::parse_setup(&setup_rel, &setup_content) {
                    Ok(p) => p,
                    Err(e) => {
                        return evaluate_file(&rel, &[], Some(&e.0));
                    }
                };
                for sd in &setup_parsed.directives {
                    if let Directive::Env { key, value, .. } = sd {
                        setup_env.push((key.clone(), value.clone()));
                    }
                }
                setup_commands.extend(setup_parsed.commands);
                teardown_commands.extend(setup_parsed.teardown_commands);
            }
            Directive::Env { key, value, .. } => {
                user_env.push((key.clone(), value.clone()));
            }
        }
    }

    // Merge env: setup first, then user overrides
    let mut env_vars: Vec<(String, String)> = vec![];
    env_vars.extend(setup_env.clone());
    // Remove setup keys that user overrides
    let user_keys: std::collections::HashSet<&str> = user_env.iter().map(|(k, _)| k.as_str()).collect();
    env_vars.retain(|(k, _)| !user_keys.contains(k.as_str()));
    env_vars.extend(user_env.clone());

    // Assign PORT if not set
    let has_port = env_vars.iter().any(|(k, _)| k == "PORT");
    if !has_port {
        env_vars.push(("PORT".to_string(), port.to_string()));
    }

    // Merge commands: setup + user + teardown
    let mut merged_commands = Vec::new();
    merged_commands.extend(setup_commands.clone());
    merged_commands.extend(parsed.commands.clone());
    merged_commands.extend(teardown_commands.clone());

    let merged = ShoutFile {
        path: parsed.path.clone(),
        commands: merged_commands,
        directives: vec![],
        teardown_commands: vec![],
    };

    let setup_len = setup_commands.len();
    let user_len = parsed.commands.len();

    let run_opts = RunOptions {
        clean_env: opts.clean_env,
        path_dirs: opts.path_dirs.clone(),
        env_vars,
        source_dir: file_path.parent().map(|p| p.to_string_lossy().to_string()),
        project_dir: Some(cwd.to_string_lossy().to_string()),
        timeout_ms,
        verbose: opts.verbose,
    };

    let on_cmd: Option<Box<dyn Fn(&parse::Command)>> = if opts.verbose {
        Some(Box::new(|cmd: &parse::Command| {
            let _ = write!(stderr(), "{DIM}  $ {}{RESET}\n", cmd.command);
        }))
    } else {
        None
    };

    let on_result: Box<dyn Fn(usize, &CommandResult)> = Box::new(move |index: usize, result: &CommandResult| {
        if index >= setup_len && index < setup_len + user_len {
            print_dot(command_passes(result));
        }
    });

    let file_result = run_file(
        &merged,
        &run_opts,
        on_cmd.as_deref(),
        Some(&*on_result),
    );

    // Check setup commands for failures
    for i in 0..setup_commands.len() {
        if let Some(r) = file_result.results.get(i) {
            let expected = &setup_commands[i].exit_code;
            let ok = match expected {
                ExitCode::Default => r.exit_code == 0,
                ExitCode::Any => r.exit_code != 0,
                ExitCode::Code(c) => r.exit_code == *c,
            };
            if !ok {
                if opts.keep {
                    let _ = writeln!(stderr(), "{}", file_result.tmp_dir);
                } else {
                    cleanup_tmp_dir(&file_result.tmp_dir);
                }
                return evaluate_file(
                    &parsed.path,
                    &[],
                    Some(&format!(
                        "setup command failed (exit {}): $ {}",
                        r.exit_code, setup_commands[i].command
                    )),
                );
            }
        }
    }

    // Extract user command results
    let file_own_results: Vec<CommandResult> = file_result
        .results
        .iter()
        .skip(setup_len)
        .take(user_len)
        .cloned()
        .collect();

    // Warn on teardown failures
    let teardown_results: Vec<&CommandResult> = file_result
        .results
        .iter()
        .skip(setup_len + user_len)
        .collect();
    for (i, r) in teardown_results.iter().enumerate() {
        if r.exit_code != 0 {
            if let Some(td) = teardown_commands.get(i) {
                let _ = write!(
                    stderr(),
                    "{YELLOW}warning: teardown command failed (exit {}): $ {}{RESET}\n",
                    r.exit_code, td.command
                );
            }
        }
    }

    let test_result = evaluate_file(&parsed.path, &file_own_results, file_result.error.as_deref());

    // Update mode
    if opts.update && !file_own_results.is_empty() {
        let updated = rewrite_file(&parsed, &file_own_results, &content);
        if updated != content {
            let _ = fs::write(file_path, &updated);
        }
    }

    if opts.keep {
        let _ = writeln!(stderr(), "{}", file_result.tmp_dir);
    } else {
        cleanup_tmp_dir(&file_result.tmp_dir);
    }

    test_result
}

fn rel_path(base: &Path, path: &Path) -> String {
    pathdiff(path, base)
}

/// Simple relative path calculation.
fn pathdiff(path: &Path, base: &Path) -> String {
    if let Ok(stripped) = path.strip_prefix(base) {
        stripped.to_string_lossy().to_string()
    } else {
        path.to_string_lossy().to_string()
    }
}

fn print_example() {
    println!(
        r#"# Example .shout file
$ echo hello
hello

$ echo "one"; echo "two"; echo "three"
one
...
three

$ cat nonexistent
cat: nonexistent: ...
[1]

$ true
[0]"#
    );
}

fn main() {
    let (_, opts) = parse_args().unwrap();

    let timeout_ms = match duration::parse_duration(&opts.timeout) {
        Ok(ms) => ms,
        Err(e) => {
            eprintln!("{e}");
            process::exit(1);
        }
    };

    let paths = if opts.files.is_empty() {
        vec![".".to_string()]
    } else {
        opts.files.clone()
    };

    let cwd = std::env::current_dir().unwrap();
    let mut files = find_shout_files(&paths);

    if let Some(ref pattern) = opts.filter {
        files.retain(|f| rel_path(&cwd, f).contains(pattern));
    }

    if files.is_empty() {
        if opts.filter.is_some() {
            eprintln!("No .shout files matching \"{}\"", opts.filter.as_ref().unwrap());
        } else {
            eprintln!("No .shout files found");
        }
        process::exit(1);
    }

    let start = Instant::now();
    let mut results: Vec<TestResult> = Vec::new();
    let mut next_port = opts.port_from;

    let print_error_dot = |r: &TestResult| {
        if r.error.is_some() {
            let _ = stdout().write_all(format!("{RED}F{RESET}").as_bytes());
            let _ = stdout().flush();
        }
    };

    if opts.parallel {
        use rayon::prelude::*;

        // Pre-assign ports
        let file_ports: Vec<(PathBuf, u16)> = files
            .iter()
            .enumerate()
            .map(|(i, f)| (f.clone(), opts.port_from + i as u16))
            .collect();

        let par_results: Vec<TestResult> = file_ports
            .par_iter()
            .map(|(f, port)| {
                let r = run_one(f, *port, &opts, timeout_ms, &cwd);
                print_error_dot(&r);
                r
            })
            .collect();

        results.extend(par_results);
        let _ = writeln!(stdout());
    } else {
        for file_path in &files {
            let r = run_one(file_path, next_port, &opts, timeout_ms, &cwd);
            print_error_dot(&r);
            results.push(r);
            next_port += 1;
        }
        let _ = writeln!(stdout());
    }

    // Print failures
    let failures: Vec<&TestResult> = results.iter().filter(|r| !r.passed).collect();
    if !failures.is_empty() {
        println!();
        for f in &failures {
            println!("{}", format_failure(f));
            println!();
        }
    }

    let elapsed = start.elapsed().as_secs_f64() * 1000.0;
    let single_file = if files.len() == 1 {
        Some(rel_path(&cwd, &files[0]))
    } else {
        None
    };
    println!("{}", format_summary(&results, elapsed, single_file.as_deref()));

    process::exit(if failures.is_empty() { 0 } else { 1 });
}
