// SPDX-FileCopyrightText: 2026 Contributors to brew-proxy <https://codeberg.org/HastD/brew-proxy>
//
// SPDX-License-Identifier: Apache-2.0 OR MIT

use std::{
    ffi::OsStr,
    io::Write,
    process::{Output, Stdio},
    time::Duration,
};

use anyhow::Result;
use tempfile::NamedTempFile;
use tokio::{
    io::AsyncWriteExt,
    process::{Child, Command},
    sync::OnceCell,
    time::sleep,
};
use zbus::{Connection, fdo::IntrospectableProxy};
use zbus_xml::{ArgDirection, Node};

use brew_proxy_utils::{
    DBUS_DAEMON_INTERFACE, DBUS_DAEMON_NAME,
    env::{ENV_VAR_NONINTERACTIVE, ENV_VAR_TEST_PIPED, ENV_VAR_TEST_STDIN},
};

static DAEMON: OnceCell<Child> = OnceCell::const_new();

async fn init_daemon() {
    DAEMON
        .get_or_init(async || {
            tracing_subscriber::fmt::init();
            let test_daemon_path =
                std::env::var_os("CARGO_BIN_EXE_brew-proxy-test-daemon").unwrap();
            let child = Command::new(test_daemon_path)
                .arg("1000")
                .kill_on_drop(true)
                .spawn()
                .unwrap();
            sleep(Duration::from_millis(200)).await;
            child
        })
        .await;
}

fn make_tempfile() -> Result<NamedTempFile> {
    Ok(tempfile::Builder::new()
        .prefix("brew-proxy-test-")
        .rand_bytes(10)
        .tempfile()?)
}

fn test_client_command<S: AsRef<OsStr>>(args: &[S]) -> Command {
    let test_client_path = std::env::var_os("CARGO_BIN_EXE_brew-proxy-test-client").unwrap();
    let mut cmd = Command::new(test_client_path);
    cmd.args(args)
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .kill_on_drop(true);
    cmd
}

async fn test_client<S: AsRef<OsStr>>(args: &[S]) -> Result<Output> {
    Ok(test_client_command(args)
        .spawn()?
        .wait_with_output()
        .await?)
}

async fn test_client_stderr<S: AsRef<OsStr>>(args: &[S]) -> Result<Output> {
    Ok(test_client_command(args).output().await?)
}

async fn test_client_piped<S: AsRef<OsStr>>(args: &[S]) -> Result<Output> {
    Ok(test_client_command(args)
        .env(ENV_VAR_TEST_PIPED, "1")
        .spawn()?
        .wait_with_output()
        .await?)
}

async fn test_client_stdin<S: AsRef<OsStr>>(args: &[S], input: &[u8]) -> Result<Output> {
    let mut child = test_client_command(args)
        .env(ENV_VAR_TEST_PIPED, "1")
        .env(ENV_VAR_TEST_STDIN, "1")
        .spawn()?;
    let mut stdin = child.stdin.take().unwrap();
    stdin.write_all(input).await?;
    stdin.shutdown().await?;
    drop(stdin);
    Ok(child.wait_with_output().await?)
}

async fn test_client_noninteractive<S: AsRef<OsStr>>(args: &[S]) -> Result<Output> {
    Ok(test_client_command(args)
        .env(ENV_VAR_NONINTERACTIVE, "1")
        .spawn()?
        .wait_with_output()
        .await?)
}

/// Verify that the DBus interface has the expected number of methods with the expected signatures.
#[tokio::test]
async fn introspection() -> Result<()> {
    init_daemon().await;

    let conn = Connection::session().await?;
    let introspector = IntrospectableProxy::new(&conn, DBUS_DAEMON_NAME).await?;
    let xml_data = introspector.introspect().await?;

    let root = Node::from_reader(xml_data.as_bytes())?;
    let mut node = &root;
    for name in [None, Some("sh"), Some("brew")] {
        assert_eq!(node.name(), name);
        assert_eq!(node.nodes().len(), 1);
        node = &node.nodes()[0];
    }
    assert_eq!(node.name(), Some("BrewProxy"));
    let brew_iface = node
        .interfaces()
        .iter()
        .find(|iface| iface.name() == DBUS_DAEMON_INTERFACE)
        .unwrap();
    assert_eq!(brew_iface.methods().len(), 66);
    for method in brew_iface.methods() {
        let args = method.args();
        assert_eq!(args.len(), 5);
        assert_eq!(args[0].ty().to_string(), "as");
        assert!(matches!(args[0].direction(), Some(ArgDirection::In)));
        assert!(args[0].annotations().is_empty());

        let arg_piped = &args[1];
        assert_eq!(arg_piped.ty().to_string(), "b");
        assert!(matches!(arg_piped.direction(), Some(ArgDirection::In)));
        assert!(arg_piped.annotations().is_empty());

        let arg_interactive = &args[2];
        assert_eq!(arg_interactive.ty().to_string(), "b");
        assert!(matches!(
            arg_interactive.direction(),
            Some(ArgDirection::In)
        ));
        assert!(arg_interactive.annotations().is_empty());

        let arg_pty = &args[args.len() - 2];
        assert_eq!(arg_pty.name(), Some("io"));
        assert_eq!(arg_pty.ty().to_string(), "(uah)");
        assert!(matches!(arg_pty.direction(), Some(ArgDirection::Out)));
        assert!(arg_pty.annotations().is_empty());

        let arg_pid = &args[args.len() - 1];
        assert_eq!(arg_pid.name(), Some("pid"));
        assert_eq!(arg_pid.ty().to_string(), "u");
        assert!(matches!(arg_pid.direction(), Some(ArgDirection::Out)));
        assert!(arg_pid.annotations().is_empty());
    }
    Ok(())
}

#[tokio::test]
async fn version_command() -> Result<()> {
    init_daemon().await;
    let output = test_client(&["--version"]).await?;
    assert_eq!(output.status.code(), Some(0));
    assert_eq!(
        String::from_utf8(output.stdout)?.trim_end(),
        "mock command: brew --version"
    );
    Ok(())
}

#[tokio::test]
async fn version_command_piped() -> Result<()> {
    init_daemon().await;
    let output = test_client_piped(&["--version"]).await?;
    assert_eq!(output.status.code(), Some(0));
    assert_eq!(
        String::from_utf8(output.stdout)?,
        "mock command: brew --version\n"
    );
    Ok(())
}

#[tokio::test]
async fn command_with_stdin() -> Result<()> {
    init_daemon().await;
    let output = test_client_stdin(&["--version"], b"test input string").await?;
    assert_eq!(output.status.code(), Some(0));
    assert_eq!(
        String::from_utf8(output.stdout)?,
        "mock command: brew --version\nstdin: test input string\n"
    );
    Ok(())
}

#[tokio::test]
async fn command_with_nonzero_exit() -> Result<()> {
    init_daemon().await;
    let output = test_client(&["--version", "--status=42"]).await?;
    assert_eq!(output.status.code(), Some(42));
    assert_eq!(
        String::from_utf8(output.stdout)?.trim_end(),
        "mock command: brew --version"
    );
    Ok(())
}

#[tokio::test]
async fn command_with_arg() -> Result<()> {
    init_daemon().await;
    let output = test_client(&["help", "search"]).await?;
    assert_eq!(output.status.code(), Some(0));
    assert_eq!(
        String::from_utf8(output.stdout)?.trim_end(),
        "mock command: brew help search"
    );
    Ok(())
}

#[tokio::test]
async fn failed_auth() -> Result<()> {
    init_daemon().await;
    let output = test_client(&["update"]).await?;
    assert_eq!(output.status.code(), Some(1));
    assert_eq!(
        String::from_utf8(output.stdout)?.trim_end(),
        "brew-proxy: org.freedesktop.DBus.Error.AccessDenied: mock auth prompt"
    );
    Ok(())
}

#[tokio::test]
async fn failed_auth_noninteractive() -> Result<()> {
    init_daemon().await;
    let output = test_client_noninteractive(&["update"]).await?;
    assert_eq!(output.status.code(), Some(1));
    assert_eq!(
        String::from_utf8(output.stdout)?.trim_end(),
        "brew-proxy: org.freedesktop.DBus.Error.AccessDenied: mock auth denial (noninteractive)"
    );
    Ok(())
}

#[tokio::test]
async fn no_arguments() -> Result<()> {
    init_daemon().await;
    let output = test_client_stderr::<&str>(&[]).await?;
    assert_eq!(output.status.code(), Some(1));
    let stderr = String::from_utf8(output.stderr)?;
    assert!(stderr.contains("Example usage:"));
    Ok(())
}

#[tokio::test]
async fn nonexistent_command() -> Result<()> {
    init_daemon().await;
    let output = test_client_stderr(&["no-such-command"]).await?;
    assert_eq!(output.status.code(), Some(1));
    let stderr = String::from_utf8(output.stderr)?;
    assert!(stderr.contains("Example usage:"));
    assert!(
        stderr
            .trim_end()
            .ends_with("brew-proxy: Unknown brew command: no-such-command")
    );
    Ok(())
}

#[tokio::test]
async fn dev_command() -> Result<()> {
    init_daemon().await;
    let output = test_client_stderr(&["sh"]).await?;
    assert_eq!(output.status.code(), Some(1));
    let stderr = String::from_utf8(output.stderr)?;
    assert!(stderr.contains("Example usage:"));
    assert!(
        stderr
            .trim_end()
            .ends_with("brew-proxy: brew developer commands are unsupported: sh")
    );
    Ok(())
}

#[tokio::test]
async fn concurrent_requests() -> Result<()> {
    init_daemon().await;
    let join_handles = (0..10)
        .map(|i| {
            tokio::spawn(async move {
                let args = ["list".to_owned(), i.to_string()];
                test_client(&args).await
            })
        })
        .collect::<Vec<_>>();
    for (i, join) in join_handles.into_iter().enumerate() {
        let output = join.await??;
        assert_eq!(output.status.code(), Some(0));
        assert_eq!(
            String::from_utf8(output.stdout)?.trim_end(),
            format!("mock command: brew list {i}")
        );
    }
    Ok(())
}

#[tokio::test]
async fn bundle_dump_to_file() -> Result<()> {
    init_daemon().await;
    let file = make_tempfile()?;
    let output = test_client(&[
        "bundle".to_owned(),
        "dump".to_owned(),
        format!("--file={}", file.path().display()),
    ])
    .await?;
    assert_eq!(output.status.code(), Some(0));
    assert!(output.stdout.is_empty());
    assert_eq!(
        std::fs::read_to_string(file.path())?,
        "mock command: brew bundle --file=- dump\n"
    );
    Ok(())
}

#[tokio::test]
async fn bundle_dump_to_stdout() -> Result<()> {
    init_daemon().await;
    let output = test_client(&["bundle", "dump", "--file=-"]).await?;
    assert_eq!(output.status.code(), Some(0));
    assert_eq!(
        String::from_utf8(output.stdout)?,
        "mock command: brew bundle --file=- dump\n"
    );
    Ok(())
}

#[tokio::test]
async fn bundle_read_from_file() -> Result<()> {
    init_daemon().await;
    let mut file = make_tempfile()?;
    file.write_all(b"brewfile test contents")?;
    let output = test_client(&[
        "bundle".to_owned(),
        format!("--file={}", file.path().display()),
    ])
    .await?;
    assert_eq!(output.status.code(), Some(0));
    assert_eq!(
        String::from_utf8_lossy(&output.stdout),
        "mock command: brew bundle --file=- install\nstdin: brewfile test contents\n",
    );
    Ok(())
}

#[tokio::test]
async fn bundle_read_from_stdin() -> Result<()> {
    init_daemon().await;
    let output = test_client_stdin(&["bundle", "--file=-"], b"brewfile test contents\n").await?;
    assert_eq!(output.status.code(), Some(0));
    assert_eq!(
        String::from_utf8_lossy(&output.stdout),
        "mock command: brew bundle --file=- install\nstdin: brewfile test contents\n",
    );
    Ok(())
}

#[tokio::test]
async fn bundle_add() -> Result<()> {
    init_daemon().await;
    let mut file = make_tempfile()?;
    file.write_all(b"brewfile test contents")?;
    let output = test_client(&[
        "bundle".to_owned(),
        "add".to_owned(),
        format!("--file={}", file.path().display()),
        "hello".to_owned(),
    ])
    .await?;
    assert_eq!(output.status.code(), Some(0));
    assert!(output.stdout.is_empty());
    assert_eq!(
        std::fs::read_to_string(file.path())?,
        "mock command: brew bundle --file=- add hello\nstdin: brewfile test contents\n"
    );
    Ok(())
}
