// SPDX-License-Identifier: AGPL-3.0-or-later

use std::{
    env::temp_dir,
    path::{Path, PathBuf},
    str::FromStr,
    time::Duration,
};

use anyhow::Context;
use bpaf::{OptionParser, Parser, construct, long, positional, short};
use itertools::Itertools;
use tracing::Level;

use crate::cmd::Cmd;

#[derive(Debug, Clone)]
pub struct RuntimeDir {
    dir: PathBuf,
    socketfile: PathBuf,
    pidfile: PathBuf,
}
impl RuntimeDir {
    fn new(dir: PathBuf) -> Self {
        Self {
            socketfile: dir.join("procfarm.sock"),
            pidfile: dir.join("procfarm.pid"),
            dir,
        }
    }

    pub fn dir(&self) -> &Path {
        &self.dir
    }

    pub fn socketfile(&self) -> &Path {
        &self.socketfile
    }

    pub fn pidfile(&self) -> &Path {
        &self.pidfile
    }
}

#[derive(Debug, Clone)]
pub struct Args {
    pub runtime_dir: RuntimeDir,
    pub verbosity: Level,
    pub log: LogTarget,
    pub cmd: SubCommand,
}
impl Args {
    pub fn parse() -> Self {
        Self::parser().run()
    }

    pub fn socketfile(&self) -> &Path {
        self.runtime_dir.socketfile()
    }

    fn parser() -> OptionParser<Self> {
        let verbosity = short('v')
            .long("verbose")
            .help("Increase logging verbosity.\n Supply multiple times for more verbose output.")
            .switch()
            .many()
            .map(|v| v.iter().filter(|v| **v).count())
            .map(|count| match count {
                0 => Level::INFO,
                1 => Level::DEBUG,
                2 => Level::TRACE,
                _ => Level::TRACE,
            });

        let log = long("log")
            .short('l')
            .argument::<LogTarget>("TARGET")
            .help(
                "Log output target: one of 'stderr' or 'syslog'.\n \
                 [default: 'stderr']",
            )
            .optional()
            .map(|v| v.unwrap_or(LogTarget::Stderr));

        let cmd = SubCommand::parser().group_help("Available Commands");

        let runtime_dir = long("runtime-dir")
            .env("PROCFARM_RUNTIME_DIR")
            .help(
                "Runtime directory for pid and socket files.\n \
                 [default: $XDG_RUNTIME_DIR/procfarm]",
            )
            .argument("DIR")
            .fallback_with(get_runtime_dir)
            .map(RuntimeDir::new);

        let show_license = long("show-license")
            .help("Print license text and exit")
            .req_flag(())
            .map::<_, Self>(|_| {
                print!(include_str!("../LICENSE"));
                std::process::exit(0);
            });

        let parser = construct!(Self {
            log,
            verbosity,
            runtime_dir,
            cmd,
        });

        construct!([parser, show_license])
            .to_options()
            .version(env!("CARGO_PKG_VERSION"))
            .fallback_to_usage()
    }
}

#[derive(Debug, Copy, Clone)]
pub enum LogTarget {
    Stderr,
    Syslog,
}
impl FromStr for LogTarget {
    type Err = &'static str;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        match s {
            "stderr" => Ok(Self::Stderr),
            "syslog" => Ok(Self::Syslog),
            _ => Err("Invalid log value. Options are 'stderr' and 'syslog'"),
        }
    }
}

#[derive(Debug, Clone)]
pub enum SubCommand {
    Start {
        detach: bool,
        force: bool,
        cmd_timeout: Option<Duration>,
    },
    Run {
        cmd: Cmd,
        timeout: Option<Duration>,
    },
    Flush {
        timeout: Option<Duration>,
    },
    Stop {
        timeout: Option<Duration>,
    },
}
impl SubCommand {
    fn parser() -> impl Parser<Self> {
        let cmd = positional("TO_RUN")
            .strict()
            .some("command must be specified")
            .map(|cmd| {
                let mut cmd = cmd.into_iter();
                Cmd::new(cmd.next().expect("validated via some()"), cmd.collect())
            });

        let run_timeout = TimeoutArg::new()
            .short('t')
            .help(
                "Timeout for this command's process.\n \
                 Overrides any server default timeout.",
            )
            .build()
            .optional();

        let run = construct!(SubCommand::Run { timeout(run_timeout), cmd })
            .to_options()
            .descr("Send a command to be run in the background")
            .header(
                "If no procfarm server is running, one will be started in the\n \
                 background, the runtime directory provided to this command, or\n \
                 otherwise the default.",
            )
            .command("run");

        let detach = long("detach")
            .short('d')
            .flag(true, false)
            .help("Start the server in the background and exit");

        let force = long("force")
            .flag(true, false)
            .help("Kill any running servers before starting");

        let default_cmd_timeout = TimeoutArg::new()
            .long("proc-timeout")
            .short('t')
            .help(
                "\n \
                 Default process timeout. If not provided, there is\n \
                 no timeout, unless specified in the 'run' command.",
            )
            .build()
            .optional();

        let start = construct!(SubCommand::Start {
            detach,
            force,
            cmd_timeout(default_cmd_timeout),
        })
        .to_options()
        .descr("Run a procfarm server")
        .header(
            "Only one procfarm server may run with a given runtime directory at\n \
             a time. To run multiple servers, specify a different runtime directory.\n \
             To access an alternative server with 'run' or other commands, the same\n \
             runtime directory must be provided to those commands.",
        )
        .command("start");

        let flush_timeout = TimeoutArg::new()
            .short('t')
            .help("Seconds to wait for task flush before giving up")
            .build()
            .optional();

        let flush = construct!(SubCommand::Flush { timeout(flush_timeout) })
            .to_options()
            .descr("Wait for the server to finish any tasks in its queue.")
            .command("flush");

        let timeout = TimeoutArg::new()
            .short('t')
            .help("Seconds to wait for pending task flush")
            .build()
            .optional();

        let stop = construct!(SubCommand::Stop { timeout })
            .to_options()
            .descr("Shut down the server")
            .header("Waits for any in-progress tasks to complete before shutting down.")
            .command("stop");

        construct!([run, start, flush, stop])
    }
}

#[derive(Default, Clone, Copy)]
struct TimeoutArg {
    long: Option<&'static str>,
    short: Option<char>,
    meta: Option<&'static str>,
    help: Option<&'static str>,
}
impl TimeoutArg {
    fn new() -> Self {
        Self::default()
    }

    fn long(mut self, long: &'static str) -> Self {
        self.long = Some(long);
        self
    }

    fn short(mut self, short: char) -> Self {
        self.short = Some(short);
        self
    }

    fn help(mut self, help: &'static str) -> Self {
        self.help = Some(help);
        self
    }

    fn build(self) -> impl Parser<Duration> {
        let mut parser = long(self.long.unwrap_or("timeout"));

        if let Some(short) = self.short {
            parser = parser.short(short);
        }

        parser
            .argument::<u32>(self.meta.unwrap_or("TIMEOUT"))
            .help(self.help.unwrap_or("timeout in seconds"))
            .map(|v| Duration::from_secs(v.into()))
    }
}

fn get_runtime_dir() -> Result<PathBuf, RuntimeDirErrors> {
    let base_dirs = xdg::BaseDirectories::new();
    let mut errors = RuntimeDirErrors::new();
    base_dirs
        .runtime_dir
        .and_then(|xdg_dir| {
            let runtime_dir = xdg_dir.join("procfarm");
            std::fs::create_dir_all(&runtime_dir)
                .map(|_| runtime_dir)
                .with_context(|| {
                    format!(
                        "could not create runtime dir in {}",
                        xdg_dir.to_string_lossy()
                    )
                })
                .map_err(|e| {
                    errors.push(e);
                })
                .ok()
        })
        .or_else(|| {
            std::env::home_dir()
                .map(|dir| dir.join(".local/run/procfarm"))
                .and_then(|dir| {
                    std::fs::create_dir_all(&dir)
                        .with_context(|| {
                            format!(
                                "could not create runtime directory at {}",
                                dir.to_string_lossy()
                            )
                        })
                        .map(|_| dir)
                        .map_err(|e| {
                            errors.push(e);
                        })
                        .ok()
                })
        })
        .or_else(|| {
            let runtime_dir = temp_dir().join("procfarm");
            std::fs::create_dir_all(&runtime_dir)
                .with_context(|| {
                    format!(
                        "could not create runtime directory at {}",
                        runtime_dir.to_string_lossy()
                    )
                })
                .map(|_| runtime_dir)
                .map_err(|e| {
                    errors.push(e);
                })
                .ok()
        })
        .ok_or(errors)
}

/// One-off error type for collecting and presenting the set of errors encountered
/// during runtime directory resolution.
#[derive(Debug, thiserror::Error)]
#[error("Failed to create runtime directory in XDG_RUNTIME_DIRECTORY or any \
         fallback directory: {}", .0.iter().map(|e| format!("{e:#}")).join(": "))]
struct RuntimeDirErrors(Vec<anyhow::Error>);
impl RuntimeDirErrors {
    fn new() -> Self {
        Self(vec![])
    }
    fn push(&mut self, error: anyhow::Error) {
        self.0.push(error)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parser_invariants() {
        Args::parser().check_invariants(true);
    }
}
