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

mod bundle;

use std::{
    io::Write,
    os::unix::process::CommandExt,
    process::{Command, ExitCode},
};

use nix::errno::Errno;
use pty_process::Pty;
use tokio::{io::AsyncWriteExt, net::unix::pipe, sync::oneshot};
use tokio_stream::StreamExt;
use tracing::debug;
use zbus::Connection;

use crate::{
    daemon::BrewInterfaceProxy,
    error::{Error, Result},
    io_handles::IoHandles,
};
use brew_proxy_utils::{
    BREW_EXECUTABLE, BREW_HOME, BREW_PROXY_HOMEPAGE, BREW_PROXY_ISSUES_PAGE, BrewCommand, BusType,
    InputSource, OutputDestination, call_with_brew_command_idents, env::ENV_VAR_NO_PROXY,
};

pub fn print_usage(mut writer: impl Write) -> Result<()> {
    let usage = r#"brew (via brew-proxy)

Example usage:
  brew search TEXT|/REGEX/
  brew info [FORMULA|CASK...]
  brew install FORMULA|CASK...
  brew update
  brew upgrade [FORMULA|CASK...]
  brew uninstall FORMULA|CASK...
  brew list [FORMULA|CASK...]

Troubleshooting:
  brew config
  brew doctor
  brew install --verbose --debug FORMULA|CASK

Further help:
  brew commands
  brew help [COMMAND]
  man brew
  https://docs.brew.sh

Note: brew-proxy does not support Homebrew developer commands.
"#;
    write!(writer, "{}", usage)?;
    Ok(())
}

pub fn print_brew_proxy_info(cmd: BrewCommand, mut writer: impl Write) -> Result<()> {
    match cmd {
        BrewCommand::Config => {
            writeln!(writer, "brew-proxy version: {}", env!("CARGO_PKG_VERSION"))?;
        }
        BrewCommand::Doctor => {
            writeln!(
                writer,
                r#"Note: You are running brew via brew-proxy. For more info, see:
  {BREW_PROXY_HOMEPAGE}
If you are experiencing issues with Homebrew, please ensure the issue still
occurs with a standard Homebrew installation before reporting it to Homebrew.
If the issue is specific to brew-proxy, you can report it here:
  {BREW_PROXY_ISSUES_PAGE}
"#
            )?;
        }
        BrewCommand::Services => {
            writeln!(
                writer,
                r#"Note: brew-proxy has limited support for brew services.

Systemd service units installed to ~/.config/systemd/user in this way may
require manual modification; in particular, user services cannot write logs to
locations within {BREW_HOME}.
"#
            )?;
        }
        BrewCommand::Version => {
            writeln!(writer, "brew-proxy {}", env!("CARGO_PKG_VERSION"))?;
        }
        _ => {}
    }
    Ok(())
}

/// Run a brew command as the current user, not as the `linuxbrew` user.
fn run_as_user(cmd: BrewCommand, args: &[String]) -> Error {
    Command::new(BREW_EXECUTABLE)
        .arg(cmd.command())
        .args(args)
        .env(ENV_VAR_NO_PROXY, "1")
        .exec()
        .into()
}

macro_rules! match_brew_cmd_to_method {
    ($($cmd:ident)*) => {
        async move |proxy: &BrewInterfaceProxy<'_>,
                    cmd: BrewCommand,
                    args: Vec<String>,
                    piped: bool,
                    interactive_auth: bool| -> Result<(IoHandles, u32)> {
            let (io_handles, pid) = match cmd {
                $(
                    BrewCommand::$cmd => proxy.$cmd(args, piped, interactive_auth).await?,
                )*
            };
            Ok((io_handles.into(), pid))
        }
    };
}

/// Utility function for handling the result of reading from a PTY. An EIO error is expected when
/// the subsidiary end of the PTY closes, so we want to ignore that error while propagating others.
fn ignore_eio<T>(result: std::io::Result<T>) -> std::io::Result<()> {
    match result {
        Ok(_) => Ok(()),
        Err(err) if err.raw_os_error() == Some(Errno::EIO as _) => Ok(()),
        Err(err) => Err(err),
    }
}

async fn handle_io(
    io_handles: IoHandles,
    pid: u32,
    input_src: InputSource,
    output_dest: OutputDestination,
) -> Result<()> {
    match io_handles {
        IoHandles::Pty(pty_fd) => {
            debug!("Copying pty output for PID {pid}");
            // SAFETY: `pty_fd` is a valid file descriptor belonging to a pty created by
            // brew-proxy-daemon in nonblocking mode.
            let mut pty = unsafe { Pty::from_fd(pty_fd)? };
            let mut output_handle = output_dest.open().await?;
            ignore_eio(tokio::io::copy(&mut pty, &mut output_handle).await)?;
        }
        IoHandles::Stdio {
            stdin: stdin_fd,
            stdout: stdout_fd,
            stderr: stderr_fd,
        } => {
            if let Some(mut input_handle) = input_src.open().await? {
                debug!("Writing to standard input for PID {pid}");
                let mut cmd_stdin = pipe::Sender::from_owned_fd(stdin_fd)?;
                tokio::io::copy(&mut input_handle, &mut cmd_stdin).await?;
                cmd_stdin.shutdown().await?;
            }

            debug!("Piping output from PID {pid}");
            let mut output_handle = output_dest.open().await?;
            let mut stderr = tokio::io::stderr();
            let mut cmd_stdout = pipe::Receiver::from_owned_fd(stdout_fd)?;
            let mut cmd_stderr = pipe::Receiver::from_owned_fd(stderr_fd)?;
            let (stdout_result, stderr_result) = tokio::join!(
                tokio::io::copy(&mut cmd_stdout, &mut output_handle),
                tokio::io::copy(&mut cmd_stderr, &mut stderr),
            );
            stdout_result?;
            stderr_result?;
            output_handle.shutdown().await?;
        }
    }
    Ok(())
}

#[derive(Clone, Copy, Debug)]
pub struct ClientOptions {
    pub bus_type: BusType,
    pub piped: bool,
    pub send_input: bool,
    pub interactive_auth: bool,
}

pub async fn run(cmd: BrewCommand, args: Vec<String>, opts: ClientOptions) -> Result<ExitCode> {
    if cmd.runs_as_user() {
        return Err(run_as_user(cmd, &args));
    }

    let conn = match opts.bus_type {
        BusType::System => Connection::system().await?,
        BusType::Session => Connection::session().await?,
    };
    let proxy = BrewInterfaceProxy::new(&conn).await?;
    let all_exit_status_stream = proxy.receive_exit_status().await?;

    debug!(
        "Requesting brew command '{}' with args {:?}",
        cmd.command(),
        args
    );
    let (io_handles, pid, input_src, output_dest) = if matches!(cmd, BrewCommand::Bundle) {
        // `brew bundle` has special handling so it can do I/O with a Brewfile
        bundle::run_brew_bundle(&proxy, args, opts.interactive_auth).await?
    } else {
        let (io_handles, pid) = call_with_brew_command_idents!(match_brew_cmd_to_method)(
            &proxy,
            cmd,
            args,
            opts.piped,
            opts.interactive_auth,
        )
        .await?;
        let input_src = if opts.send_input {
            InputSource::Stdin
        } else {
            InputSource::Null
        };
        (io_handles, pid, input_src, OutputDestination::Stdout)
    };

    if pid == 0 {
        return Err(Error::Other(
            "received invalid PID for child process".into(),
        ));
    }

    let mut exit_status_stream = all_exit_status_stream.filter_map(move |msg| {
        if let Ok(msg_args) = msg.args()
            && msg_args.pid == pid
        {
            let status = msg_args.status;
            debug!("Received exit status {status} from PID {pid}");
            Some(msg_args.status)
        } else {
            None
        }
    });

    // Start polling for the exit status in the background
    let (status_tx, status_rx) = oneshot::channel();
    tokio::spawn(async move {
        debug!("Waiting for exit status from PID {pid}");
        let status = exit_status_stream.next().await;
        if status_tx.send(status).is_err() {
            debug!("Status channel receiver somehow dropped");
        }
    });

    handle_io(io_handles, pid, input_src, output_dest).await?;

    let exit_status = status_rx.await.unwrap_or_else(|_| {
        debug!("Status channel sender somehow dropped without sending");
        None
    });
    let exit_code = exit_status
        .map(|status| ExitCode::from(u8::try_from(status).unwrap_or(u8::MAX)))
        .unwrap_or(ExitCode::FAILURE);
    Ok(exit_code)
}
