//
// pandora: Syd's Dump Inspector & Profile Writer
// tests/tests.rs: Integration tests (replaces pandora.pl)
//
// Copyright (c) 2026 Ali Polatel <alip@chesswob.org>
//
// SPDX-License-Identifier: GPL-3.0

use std::{
    env, fs,
    os::{
        fd::{AsRawFd, BorrowedFd, IntoRawFd},
        unix::process::CommandExt,
    },
    path::{Path, PathBuf},
    process::{Command, Stdio},
};

use nix::{
    fcntl::{fcntl, FcntlArg, FdFlag},
    sys::stat::{umask, Mode},
};
use regex::Regex;

/// Locate the pandora binary built by cargo.
fn pandora_exe() -> PathBuf {
    PathBuf::from(env!("CARGO_BIN_EXE_pandora"))
}

/// Source tree root (where pandora.rs, t/, target/ live).
fn manifest_dir() -> PathBuf {
    PathBuf::from(env!("CARGO_MANIFEST_DIR"))
}

/// Derive the target directory from the pandora binary location.
fn target_dir() -> PathBuf {
    pandora_exe()
        .parent()
        .expect("pandora binary has a parent directory")
        .to_path_buf()
}

/// Compile a C test source using the system C compiler.
fn compile_test_bin(base: &str) -> Option<PathBuf> {
    let root = manifest_dir();
    let src = root.join("t").join(format!("{base}.c"));
    let target_dir = target_dir();
    let bin = target_dir.join(base);

    if src.exists() {
        // Set a sane umask(2) to avoid TPE.
        let mask = umask(Mode::from_bits_truncate(0o077));
        fs::create_dir_all(&target_dir).ok();
        let status = Command::new("cc")
            .arg("-std=c99")
            .arg("-pie")
            .arg("-fPIE")
            .arg("-D_GNU_SOURCE")
            .arg("-D_XOPEN_SOURCE=700")
            .arg("-o")
            .arg(&bin)
            .arg(&src)
            .status()
            .expect("invoke cc");
        assert!(status.success(), "compile {}.c failed: {:?}", base, status);
        umask(mask); // restore.
        Some(bin)
    } else {
        None
    }
}

/// Find the test binary: either a compiled C binary or a shell script.
fn find_test_bin(base: &str) -> PathBuf {
    let root = manifest_dir();
    if let Some(bin) = compile_test_bin(base) {
        return bin;
    }
    let target_bin = target_dir().join(base);
    if target_bin.exists() {
        return target_bin;
    }
    for ext in &["sh", "bash", "pl", "py"] {
        let script = root.join("t").join(format!("{base}.{ext}"));
        if script.exists() {
            return script;
        }
    }
    panic!("{}: no binary or script found", base);
}

/// A compiled pattern: either a single regex or a lookahead
/// decomposition (base regex + required submatches).
enum CompiledPattern {
    Simple(Regex),
    Lookahead { base: Regex, lookaheads: Vec<Regex> },
}

impl CompiledPattern {
    fn is_match(&self, text: &str) -> bool {
        match self {
            CompiledPattern::Simple(re) => re.is_match(text),
            CompiledPattern::Lookahead { base, lookaheads } => {
                base.is_match(text) && lookaheads.iter().all(|la| la.is_match(text))
            }
        }
    }
}

/// Compile a pattern, decomposing Perl-style lookaheads (?=...) that
/// the regex crate does not support.
fn compile_pattern(pat: &str) -> CompiledPattern {
    if !pat.contains("(?=") {
        return CompiledPattern::Simple(Regex::new(pat).unwrap());
    }
    // Extract all (?=...) groups and the remaining base pattern.
    let mut lookaheads = Vec::new();
    let mut base = String::new();
    let mut chars = pat.chars().peekable();
    while let Some(c) = chars.next() {
        if c == '(' && chars.peek() == Some(&'?') {
            let mut rest = String::new();
            rest.push(chars.next().unwrap()); // '?'
            if chars.peek() == Some(&'=') {
                rest.push(chars.next().unwrap()); // '='

                // Collect until matching ')'.
                let mut depth = 1;
                let mut inner = String::new();
                for ch in chars.by_ref() {
                    if ch == '(' {
                        depth += 1;
                        inner.push(ch);
                    } else if ch == ')' {
                        depth -= 1;
                        if depth == 0 {
                            break;
                        }
                        inner.push(ch);
                    } else {
                        inner.push(ch);
                    }
                }
                lookaheads.push(Regex::new(&inner).unwrap());
            } else {
                base.push('(');
                base.push_str(&rest);
            }
        } else {
            base.push(c);
        }
    }
    if base.is_empty() {
        base = ".".into();
    }
    CompiledPattern::Lookahead {
        base: Regex::new(&base).unwrap(),
        lookaheads,
    }
}

/// Parsed test pattern.
struct Pattern {
    must: bool,
    compiled: CompiledPattern,
    raw: String,
}

/// Parse a .out file into patterns.
fn parse_out_file(path: &Path) -> Vec<Pattern> {
    let content =
        fs::read_to_string(path).unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
    let mut patterns = Vec::new();
    for line in content.lines() {
        let line = line.trim();
        if line.is_empty() || line.starts_with('#') {
            continue;
        }
        if let Some(re_str) = line.strip_prefix("1 ") {
            patterns.push(Pattern {
                must: true,
                compiled: compile_pattern(re_str),
                raw: re_str.to_string(),
            });
        } else if let Some(re_str) = line.strip_prefix("0 ") {
            patterns.push(Pattern {
                must: false,
                compiled: compile_pattern(re_str),
                raw: re_str.to_string(),
            });
        } else {
            panic!("bad pattern in {}: {line}", path.display());
        }
    }
    patterns
}

/// Parse optional .env file.
fn parse_env_file(path: &Path) -> Vec<(String, String)> {
    if !path.exists() {
        return Vec::new();
    }
    let content = fs::read_to_string(path).expect("read .env file");
    let mut vars = Vec::new();
    for line in content.lines() {
        let line = line.trim();
        if line.is_empty() || line.starts_with('#') {
            continue;
        }
        if let Some((key, val)) = line.split_once('=') {
            vars.push((key.to_string(), val.to_string()));
        }
    }
    vars
}

/// Check patterns against a profile file.
fn check_profile(tag: &str, profile_path: &Path, patterns: &[Pattern]) {
    assert!(
        profile_path.exists(),
        "{tag}: profile not found: {}",
        profile_path.display()
    );
    let content =
        fs::read_to_string(profile_path).unwrap_or_else(|e| panic!("{}: read profile: {}", tag, e));
    let lines: Vec<&str> = content.lines().collect();

    for pat in patterns {
        if pat.must {
            let found = lines.iter().any(|l| pat.compiled.is_match(l));
            assert!(
                found,
                "{tag}: expected match for /{}/\nProfile: {}",
                pat.raw,
                profile_path.display()
            );
        } else {
            let hit = lines.iter().find(|l| pat.compiled.is_match(l));
            assert!(
                hit.is_none(),
                "{tag}: unexpected match for /{}/: {:?}\nProfile: {}",
                pat.raw,
                hit,
                profile_path.display()
            );
        }
    }
}

/// Run a single .out test: 3-pass protocol (profile, inspect, enforce).
fn run_out_test(out_file: &str) {
    let root = manifest_dir();
    let out_path = root.join(out_file);
    let base = Path::new(out_file)
        .file_stem()
        .unwrap()
        .to_str()
        .unwrap()
        .to_string();

    let patterns = parse_out_file(&out_path);
    let bin = find_test_bin(&base);
    let bin_str = bin.to_str().unwrap();

    let target_dir = target_dir();
    fs::create_dir_all(&target_dir).ok();
    let args: Vec<&str> = vec![target_dir.to_str().unwrap()];

    let env_path = out_path.with_extension("env");
    let env_vars = parse_env_file(&env_path);

    let syd_file = out_path.with_extension("syd-3");
    let syd_conf_opt = if syd_file.exists() {
        format!("-P{}", syd_file.display())
    } else {
        String::new()
    };

    let pandora = pandora_exe();
    let pandora_str = pandora.to_str().unwrap();

    let prof = target_dir.join(format!("{base}.syd-3"));
    let log = target_dir.join(format!("{base}.log"));
    let tmp = target_dir.join(format!("{base}-tmp.syd-3"));

    let _ = fs::remove_file(&prof);
    let _ = fs::remove_file(&log);
    let _ = fs::remove_file(&tmp);

    // Pass 1: Pandora profile
    let mut cmd = Command::new(pandora_str);
    cmd.current_dir(&root)
        .stdin(Stdio::null())
        .stdout(Stdio::null())
        .env("PANDORA_OUT", &prof)
        .env("PANDORA_QUIET", "1")
        .arg("profile");
    if syd_file.exists() {
        cmd.arg(format!("-sP{}", syd_file.display()));
    }
    cmd.arg("--").arg(bin_str);
    for arg in &args {
        cmd.arg(arg);
    }
    for (key, val) in &env_vars {
        cmd.env(key, val);
    }
    let exit_code = cmd
        .status()
        .expect("run pandora profile")
        .code()
        .unwrap_or(-1);
    check_profile("profile", &prof, &patterns);

    // Pass 2: Syd + Pandora inspect
    let log_file = fs::File::create(&log).expect("create log file");
    let log_raw_fd = log_file.as_raw_fd();
    let mut syd_cmd = Command::new("syd");
    syd_cmd
        .current_dir(&root)
        .stdin(Stdio::null())
        .stdout(Stdio::null())
        .env("SYD_LOG_FD", "3")
        .env("SYD_QUIET_TTY", "1")
        .arg("-x");
    if !syd_conf_opt.is_empty() {
        syd_cmd.arg(&syd_conf_opt);
    }
    syd_cmd.arg("--").arg(bin_str);
    for arg in &args {
        syd_cmd.arg(arg);
    }
    for (key, val) in &env_vars {
        syd_cmd.env(key, val);
    }
    unsafe {
        syd_cmd.pre_exec(move || {
            if log_raw_fd != 3 {
                let src_fd = BorrowedFd::borrow_raw(log_raw_fd);
                let owned = nix::unistd::dup2_raw(src_fd, 3).map_err(std::io::Error::from)?;
                let _ = owned.into_raw_fd();
            }
            let fd3 = BorrowedFd::borrow_raw(3);
            fcntl(fd3, FcntlArg::F_SETFD(FdFlag::empty())).map_err(std::io::Error::from)?;
            Ok(())
        });
    }
    let _ = syd_cmd.status().expect("run syd");
    drop(log_file);

    let mut inspect_cmd = Command::new(pandora_str);
    inspect_cmd
        .current_dir(&root)
        .env("PANDORA_OUT", &tmp)
        .env("PANDORA_QUIET", "1")
        .arg("inspect")
        .arg(format!("-i{}", log.display()));
    for (key, val) in &env_vars {
        inspect_cmd.env(key, val);
    }
    let _ = inspect_cmd.status().expect("run pandora inspect");
    check_profile("inspect", &tmp, &patterns);

    // Pass 3: Syd enforce
    let mut enforce_cmd = Command::new("syd");
    enforce_cmd
        .current_dir(&root)
        .stdin(Stdio::null())
        .stdout(Stdio::null())
        .env("SYD_QUIET_TTY", "1")
        .env("IN_SYD", "1")
        .arg(format!("-P{}", prof.display()))
        .arg("--")
        .arg(bin_str);
    for arg in &args {
        enforce_cmd.arg(arg);
    }
    for (key, val) in &env_vars {
        enforce_cmd.env(key, val);
    }
    let enforce_exit = enforce_cmd
        .status()
        .expect("run Syd enforce")
        .code()
        .unwrap_or(-1);
    assert_eq!(
        enforce_exit, exit_code,
        "enforce exit={}, expected {}",
        enforce_exit, exit_code
    );
}

macro_rules! out_test {
    ($name:ident, $file:expr) => {
        #[test]
        fn $name() {
            run_out_test($file);
        }
    };
}

out_test!(t001, "t/t001.out");
out_test!(t002, "t/t002.out");
out_test!(t003, "t/t003.out");
out_test!(t004, "t/t004.out");
out_test!(t005, "t/t005.out");
out_test!(t006, "t/t006.out");
out_test!(t007, "t/t007.out");
out_test!(t008, "t/t008.out");
out_test!(t009, "t/t009.out");
out_test!(t010, "t/t010.out");
out_test!(t011, "t/t011.out");
out_test!(t012, "t/t012.out");
out_test!(t013, "t/t013.out");
out_test!(t014, "t/t014.out");
out_test!(t015, "t/t015.out");
out_test!(t016, "t/t016.out");
out_test!(t017, "t/t017.out");
out_test!(t018, "t/t018.out");
out_test!(t019, "t/t019.out");
out_test!(t020, "t/t020.out");
out_test!(t021, "t/t021.out");
out_test!(t022, "t/t022.out");
out_test!(t023, "t/t023.out");
out_test!(t024, "t/t024.out");
out_test!(t025, "t/t025.out");
out_test!(t026, "t/t026.out");
out_test!(t027, "t/t027.out");
out_test!(t028, "t/t028.out");
out_test!(t029, "t/t029.out");
out_test!(t030, "t/t030.out");
out_test!(t031, "t/t031.out");
out_test!(t032, "t/t032.out");
out_test!(t033, "t/t033.out");
out_test!(t034, "t/t034.out");
out_test!(t035, "t/t035.out");
out_test!(t036, "t/t036.out");
out_test!(t037, "t/t037.out");
out_test!(t038, "t/t038.out");
out_test!(t039, "t/t039.out");
out_test!(t040, "t/t040.out");
out_test!(t041, "t/t041.out");
out_test!(t042, "t/t042.out");
out_test!(t043, "t/t043.out");
out_test!(t044, "t/t044.out");
out_test!(t045, "t/t045.out");
out_test!(t046, "t/t046.out");
out_test!(t047, "t/t047.out");
out_test!(t048, "t/t048.out");
out_test!(t049, "t/t049.out");
out_test!(t050, "t/t050.out");
out_test!(t051, "t/t051.out");
out_test!(t052, "t/t052.out");
out_test!(t053, "t/t053.out");
out_test!(t054, "t/t054.out");
out_test!(t055, "t/t055.out");
out_test!(t056, "t/t056.out");
out_test!(t057, "t/t057.out");
out_test!(t058, "t/t058.out");
out_test!(t059, "t/t059.out");
out_test!(t060, "t/t060.out");
out_test!(t061, "t/t061.out");
out_test!(t062, "t/t062.out");
out_test!(t063, "t/t063.out");
out_test!(t064, "t/t064.out");
out_test!(t065, "t/t065.out");
out_test!(t066, "t/t066.out");
out_test!(t067, "t/t067.out");
out_test!(t068, "t/t068.out");
out_test!(t069, "t/t069.out");
out_test!(t070, "t/t070.out");
out_test!(t071, "t/t071.out");
out_test!(t072, "t/t072.out");
out_test!(t073, "t/t073.out");
out_test!(t074, "t/t074.out");
out_test!(t075, "t/t075.out");
out_test!(t076, "t/t076.out");
out_test!(t077, "t/t077.out");
out_test!(t078, "t/t078.out");
out_test!(t079, "t/t079.out");
out_test!(t080, "t/t080.out");
out_test!(t081, "t/t081.out");
out_test!(t082, "t/t082.out");
out_test!(t083, "t/t083.out");
out_test!(t084, "t/t084.out");
out_test!(t085, "t/t085.out");
out_test!(t086, "t/t086.out");
