/// Main integration test library support.
///
use std::{
    io::Write,
    os::unix::fs::PermissionsExt,
    path::Path,
    process::{Command, Stdio},
    sync::Arc,
};

// This module pulls in the actual integration tests, while this file serves as
// the single binary that will be linked to run all the ingegration tests.
mod integration_tests;

use anyhow::Context;
use insta_cmd::assert_cmd_snapshot;
use rand::{SeedableRng, prelude::Distribution};
use tempfile::{NamedTempFile, TempDir, TempPath};

pub struct CommandOutput {
    pub status: std::process::ExitStatus,
    pub stdout: String,
    pub stderr: String,
}

#[derive(Default)]
pub struct GpuTracePerfRun {
    args: Vec<String>,
    envs: Vec<(String, String)>,
    pub tempfiles: Vec<TempPath>,
    pub tempdirs: Vec<Arc<TempDir>>,
}

impl GpuTracePerfRun {
    pub fn new() -> Self {
        GpuTracePerfRun {
            args: vec![],
            envs: vec![],
            tempfiles: vec![],
            tempdirs: vec![],
        }
    }

    pub fn arg(&mut self, arg: &str) {
        self.args.push(arg.to_string());
    }

    pub fn path_arg(&mut self, path: &Path) {
        self.arg(path.to_str().unwrap())
    }

    // Set up a gpu-trace-perf run --samples <samplecount> run for integration testing.
    pub fn setup_run(&mut self, samples: usize) {
        self.arg("run");
        self.arg("--samples");
        self.arg(&format! {"{samples}"})
    }

    pub fn tempdir(&mut self) -> Arc<TempDir> {
        let tmpdir = env!("CARGO_TARGET_TMPDIR");
        let tempdir = Arc::new(
            TempDir::new_in(tmpdir)
                .context("creating temp dir for output")
                .unwrap(),
        );
        self.tempdirs.push(tempdir.clone());
        tempdir
    }

    pub fn setup_snapshot(&mut self) -> Arc<TempDir> {
        let output = self.tempdir();
        self.arg("snapshot");
        self.arg("--output");
        self.path_arg(output.path());
        output
    }

    pub fn setup_ci_replay(
        &mut self,
        config: &Path,
        device: &str,
        cache_dir: Option<Arc<TempDir>>,
    ) -> Arc<TempDir> {
        let output = self.tempdir();
        self.arg("replay");
        self.arg("--config");
        self.path_arg(config);
        self.arg("--device");
        self.arg(device);
        self.arg("--output");
        self.path_arg(output.path());
        self.arg("--cache-dir");
        let cache_dir = cache_dir.unwrap_or_else(|| self.tempdir());
        self.path_arg(cache_dir.path());
        output
    }

    pub fn add_trace(&mut self, trace: &str) {
        self.arg("-t");
        self.arg(&format!("src/test_data/{trace}"));
    }

    pub fn env(&mut self, env: &str, value: &str) {
        self.envs.push((env.to_string(), value.to_string()));
    }

    pub fn command(&self) -> Command {
        // Get the location of our gpu-trace-perf binary from rustc
        let gtp = env!("CARGO_BIN_EXE_gpu-trace-perf");

        let mut command = Command::new(gtp);
        command.stdout(Stdio::piped()).stderr(Stdio::piped());

        // Run integration tests from the base of the source tree, so that the
        // traces can be relative paths (meaning that they're named reproducibly
        // in test output)
        let manifest_path = env!("CARGO_MANIFEST_PATH");
        let basedir = Path::new(manifest_path).parent().unwrap();
        command.current_dir(basedir);

        // Get reproducible log output for snapshot testing.
        command.arg("--no-log-time");

        for arg in &self.args {
            command.arg(arg);
        }

        for (env, value) in &self.envs {
            command.env(env, value);
        }

        command
    }

    pub fn run(self) -> CommandOutput {
        let mut child = self.command();

        let output = child
            .spawn()
            .with_context(|| format!("Spawning {:?}", &child))
            .unwrap()
            .wait_with_output()
            .context("waiting for gpu-trace-perf")
            .unwrap();

        let mut output = CommandOutput {
            status: output.status,
            stdout: String::from_utf8_lossy(&output.stdout).to_string(),
            stderr: String::from_utf8_lossy(&output.stderr).to_string(),
        };

        for (i, tempfile) in self.tempfiles.iter().enumerate() {
            let path = tempfile.display().to_string();
            let replacement = format!("TEMPFILE{i}");

            output.stdout = output.stdout.replace(&path, &replacement);
            output.stderr = output.stderr.replace(&path, &replacement);
        }

        output
    }

    pub fn snapshot_output(&self, snapshot_name: &str) {
        let filters = self.insta_filters();
        let filters_ref: Vec<(&str, &str)> = filters
            .iter()
            .map(|(a, b)| (a.as_str(), b.as_str()))
            .collect();
        insta::with_settings!({
            filters => filters_ref,
        }, {
            assert_cmd_snapshot!(snapshot_name, self.command());
        });
    }

    pub fn env_passthrough(&mut self, name: &str) {
        self.env_script(name, "\"$@\"");
    }

    pub fn env_reproducible_outputs(&mut self, name: &str, mean: f64, delta: f64) {
        let mut rng = rand_chacha::ChaCha12Rng::seed_from_u64(1);
        let dist = rand::distr::Uniform::new(mean - delta, mean + delta).unwrap();

        let mut tempfile1 = NamedTempFile::new().unwrap();
        let tempfile2 = NamedTempFile::new().unwrap();

        for _ in 0..100 {
            let sample = dist.sample(&mut rng);
            tempfile1
                .write_all(format!("{sample}\n").as_bytes())
                .unwrap();
        }

        let samples = tempfile1.path().display().to_string();
        let samples_tmp = tempfile2.path().display().to_string();

        let env_script = format!(
            r#"
set -e
set -o pipefail
sample=`tail -n 1 {samples}`
invsample=`echo "scale=10;1/$sample" | bc`
lines=`cat {samples} | wc -l`
echo "sample $sample lines $lines" 1>&2
head -n $((lines - 1)) {samples} > {samples_tmp}
mv {samples_tmp} {samples}
"$@" \
  | sed "s|Measured FPS:.*fps|Measured FPS: $sample fps|g" \
  | sed "s|Replay FPS:.*fps|Replay FPS: $sample fps|g" \
  | sed 's|EID [0-9]*: .*||2g' \
  | sed "s|EID [0-9]*: .*|EID 1: $invsample|" \
"#
        );
        self.env_script(name, &env_script);
        self.tempfiles.push(tempfile1.into_temp_path());
        self.tempfiles.push(tempfile2.into_temp_path());
    }

    pub fn env_script(&mut self, name: &str, contents: &str) {
        let mut tempfile = NamedTempFile::new().unwrap();

        tempfile
            .write_all(format!("#!/bin/bash\n{contents}\n").as_bytes())
            .unwrap();

        let path = tempfile.path();
        self.arg("--env");
        self.arg(name);
        self.arg(&format!("{}", path.display()));

        let exec = 0o755;
        let mut permissions = tempfile.as_file().metadata().unwrap().permissions();
        permissions.set_mode(exec);

        std::fs::set_permissions(path, permissions).unwrap();

        self.tempfiles.push(tempfile.into_temp_path());
    }

    pub fn insta_filters(&self) -> Vec<(String, String)> {
        let mut filters: Vec<_> = self
            .tempfiles
            .iter()
            .enumerate()
            .map(|(i, file)| (file.display().to_string(), format!("TEMPFILE{i}")))
            .collect();
        filters.append(
            &mut self
                .tempdirs
                .iter()
                .enumerate()
                .map(|(i, dir)| (dir.path().display().to_string(), format!("TEMPDIR{i}")))
                .collect(),
        );
        filters.push(("line [0-9]+".to_string(), "line LINENO".to_string()));
        filters.push((
            ": .*: Assertion `.*' failed".to_string(),
            ": ASSERTION_FAILURE".to_string(),
        ));
        filters.push((
            ": .*: Assertion `.*' failed".to_string(),
            ": ASSERTION_FAILURE".to_string(),
        ));
        filters.push((
            r"peak [^:]*: (.*)".to_string(),
            r"peak TYPE: AMOUNT".to_string(),
        ));

        // Temporary server URLs will have random ports.
        filters.push(("https?://[^ /]+".to_string(), "http://HOST/".to_string()));
        filters
    }
}

/// Extract all non-anchor links from HTML, including href/src attributes
/// and log file URLs embedded in showLogModal() JavaScript calls.
/// Returns both relative paths and absolute http(s) URLs.
fn extract_links(html: &str) -> Vec<String> {
    let document = scraper::Html::parse_document(html);
    let selector = scraper::Selector::parse("[href],[src]").unwrap();
    let mut links = std::collections::HashSet::new();

    for elem in document.select(&selector) {
        for attr in ["href", "src"] {
            if let Some(url) = elem.attr(attr) {
                if !url.starts_with('#') && !url.is_empty() {
                    links.insert(url.to_string());
                }
            }
        }
    }

    // Extract log file URLs from showLogModal('url', ...) onclick handlers,
    // which aren't reachable via standard href/src parsing.
    // Use scraper's DOM access for onclick to get HTML-entity-decoded values.
    let onclick_selector = scraper::Selector::parse("[onclick]").unwrap();
    for elem in document.select(&onclick_selector) {
        if let Some(onclick) = elem.attr("onclick") {
            if let Some(pos) = onclick.find("showLogModal('") {
                let after = &onclick[pos + "showLogModal('".len()..];
                if let Some(end) = after.find('\'') {
                    let url = &after[..end];
                    if !url.starts_with('#') && !url.is_empty() {
                        links.insert(url.to_string());
                    }
                }
            }
        }
    }

    links.into_iter().collect()
}

/// Serve `output_dir` over HTTP and verify that all links reachable from
/// `index.html` (and transitively from any linked HTML pages) return 200.
/// Relative links are resolved against the local server; absolute http(s)
/// links are fetched as-is.
/// Panics if any link is broken or if `index.html` contains no links.
pub fn check_html_links(output_dir: &std::path::Path) {
    let rt = tokio::runtime::Builder::new_current_thread()
        .enable_all()
        .build()
        .unwrap();
    rt.block_on(async {
        let base_url = http_serve_dir(output_dir).await;
        let client = reqwest::Client::new();

        let mut visited = std::collections::HashSet::new();
        let mut broken = Vec::new();

        // Fetch index.html and seed the worklist.
        let index_html = client
            .get(format!("{base_url}/index.html"))
            .send()
            .await
            .expect("fetching index.html")
            .text()
            .await
            .unwrap();
        visited.insert("index.html".to_string());

        let initial_links = extract_links(&index_html);
        assert!(!initial_links.is_empty(), "No links found in index.html");

        let mut worklist: Vec<String> = initial_links;

        while let Some(link) = worklist.pop() {
            if !visited.insert(link.clone()) {
                continue;
            }

            // Absolute URLs are fetched directly; relative paths are resolved
            // against the local file server.
            let url = if link.starts_with("http") {
                link.clone()
            } else {
                format!("{base_url}/{link}")
            };
            let resp = client
                .get(&url)
                .send()
                .await
                .unwrap_or_else(|e| panic!("requesting {url}: {e}"));

            if !resp.status().is_success() {
                broken.push(format!("{link}: {}", resp.status()));
                continue;
            }

            // Follow links from any linked HTML pages.
            if link.ends_with(".html") {
                let html = resp.text().await.unwrap();
                for l in extract_links(&html) {
                    if !visited.contains(&l) {
                        worklist.push(l);
                    }
                }
            }
        }

        assert!(broken.is_empty(), "Broken links:\n{}", broken.join("\n"));
    });
}

/// Simple HTTP server for unit tests.  Returns the base URL to access it.
pub async fn http_serve_dir(root: &std::path::Path) -> String {
    use hyper::service::service_fn;
    use hyper_staticfile::Static;
    use hyper_util::rt::{TokioExecutor, TokioIo};
    use hyper_util::server::conn::auto::Builder;
    use tokio::net::TcpListener;

    let static_ = Static::new(root);
    let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
    let port = listener.local_addr().unwrap().port();

    tokio::spawn(async move {
        loop {
            let (stream, _) = listener.accept().await.unwrap();
            let static_ = static_.clone();
            tokio::spawn(async move {
                Builder::new(TokioExecutor::new())
                    .serve_connection(
                        TokioIo::new(stream),
                        service_fn(move |req| {
                            let static_ = static_.clone();
                            async move { static_.serve(req).await }
                        }),
                    )
                    .await
                    .ok();
            });
        }
    });

    format!("http://127.0.0.1:{port}")
}

/// Sanity check that our link checker does something.
#[should_panic(expected = "busted.html")]
#[test]
fn check_html_links_test() {
    let dir = TempDir::new().unwrap();

    std::fs::write(
        dir.path().join("index.html"),
        r#"
<html>
<body>
<a href="file2.html">text</a>
</body>
</html>
    "#
        .as_bytes(),
    )
    .unwrap();

    std::fs::write(
        dir.path().join("file2.html"),
        r#"
<html>
<body>
<a href="busted.html">text2</a>
</body>
</html>
    "#
        .as_bytes(),
    )
    .unwrap();

    check_html_links(dir.path());
}
