//
// pandora: Syd's Dump Inspector & Profile Writer
// cli.rs: Command line and environment parsing
//
// Copyright (c) 2023, 2024, 2025, 2026 Ali Polatel <alip@chesswob.org>
//
// SPDX-License-Identifier: GPL-3.0

#![forbid(unsafe_code)]

use std::{
    env,
    ffi::OsString,
    io::{stderr, Write as IoWrite},
    os::unix::ffi::OsStrExt,
    time::Duration,
};

use btoi::btoi;
use nix::errno::Errno;

#[cfg(feature = "force")]
use crate::hasher::hash_auto;
use crate::{cpu, PKG_AUTHORS, PKG_DESCRIPTION, PKG_LICENSE, PKG_NAME, PKG_VERSION};

// Top-level subcommands.
pub(crate) enum MainOpts {
    // "profile" subcommand
    Profile(ProfileOpts),
    // "inspect" subcommand
    Inspect(InspectOpts),
    // Top-level help
    Help,
    // Top-level version
    Version,
}

// Options for `profile` subcommand.
pub(crate) struct ProfileOpts {
    // Syd binary
    pub(crate) bin: String,
    // Repeated -s flags
    pub(crate) syd: Vec<String>,
    // Output path
    pub(crate) output: String,
    // Hash algorithm
    pub(crate) hash: String,
    // Path limit
    pub(crate) limit: u8,
    // Optional timeout
    pub(crate) timeout: Option<Duration>,
    // Thread count
    pub(crate) threads: usize,
    // Positional subcommand
    pub(crate) cmd: Vec<OsString>,
}

// Options for `inspect` subcommand.
pub(crate) struct InspectOpts {
    // Input path
    pub(crate) input: String,
    // Output path
    pub(crate) output: String,
    // Hash algorithm
    pub(crate) hash: String,
    // Path limit
    pub(crate) limit: u8,
    // Threads
    pub(crate) threads: usize,
}

// Internal map to track access control rules.

// Parse the top-level argument.
pub(crate) fn parse_main_opts() -> Result<MainOpts, lexopt::Error> {
    use lexopt::prelude::*;

    // Parse CLI options.
    //
    // Note, option parsing is POSIXly correct:
    // POSIX recommends that no more options are parsed after the first
    // positional argument. The other arguments are then all treated as
    // positional arguments.
    // See: https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap12.html#tag_12_02
    let mut parser = lexopt::Parser::from_env();
    let first_arg = parser.next()?;

    match first_arg {
        None => Ok(MainOpts::Help),
        Some(Short('h') | Long("help")) => Ok(MainOpts::Help),
        Some(Short('V') | Long("version")) => Ok(MainOpts::Version),
        Some(ref arg @ Value(ref cmd)) => match cmd.as_bytes() {
            b"profile" => {
                let prof = parse_profile_opts(parser)?;
                Ok(MainOpts::Profile(prof))
            }
            b"inspect" => {
                let insp = parse_inspect_opts(parser)?;
                Ok(MainOpts::Inspect(insp))
            }
            _ => Err(arg.clone().unexpected()),
        },
        Some(arg) => Err(arg.unexpected()),
    }
}

// Parse "profile" subcommand options.
pub(crate) fn parse_profile_opts(mut parser: lexopt::Parser) -> Result<ProfileOpts, lexopt::Error> {
    use lexopt::prelude::*;

    let bin = parse_env_str("SYD_BIN", b"syd").map_err(|errno| lexopt::Error::ParsingFailed {
        value: "SYD_BIN".to_string(),
        error: Box::new(errno),
    })?;
    let output = parse_env_str("PANDORA_OUT", b"./pandora_out.syd-3").map_err(|errno| {
        lexopt::Error::ParsingFailed {
            value: "PANDORA_OUT".to_string(),
            error: Box::new(errno),
        }
    })?;
    let hash = match env::var("PANDORA_HASH") {
        Ok(val) if !val.is_empty() => val,
        _ => "auto".to_string(),
    };
    let limit = parse_env_u8("PANDORA_LIMIT", 3).map_err(|errno| lexopt::Error::ParsingFailed {
        value: "PANDORA_LIMIT".to_string(),
        error: Box::new(errno),
    })?;
    let timeout = {
        let val = env::var_os("PANDORA_TIMEOUT").unwrap_or_default();
        if val.is_empty() {
            None
        } else {
            Some(
                parse_os_u64(&val)
                    .map(Duration::from_secs)
                    .map_err(|errno| lexopt::Error::ParsingFailed {
                        value: "PANDORA_TIMEOUT".to_string(),
                        error: Box::new(errno),
                    })?,
            )
        }
    };
    let threads = parse_env_usize("PANDORA_NPROC", cpu::get().unwrap_or(2)).map_err(|errno| {
        lexopt::Error::ParsingFailed {
            value: "PANDORA_NPROC".to_string(),
            error: Box::new(errno),
        }
    })?;

    let mut prof = ProfileOpts {
        bin,
        syd: Vec::new(),
        output,
        hash,
        limit,
        timeout,
        threads,
        cmd: vec![env::var_os("SYD_SHELL").unwrap_or(OsString::from("/bin/sh"))],
    };

    while let Some(arg) = parser.next()? {
        match arg {
            // -h => subcommand help
            Short('h') | Long("help") => {
                print_help_profile();
                std::process::exit(0);
            }
            // -V => version
            Short('V') | Long("version") => {
                print_version();
                std::process::exit(0);
            }
            // -H => hash algorithm.
            Short('H') => {
                prof.hash = parse_utf8_str(parser.value()?.as_bytes()).map_err(|errno| {
                    lexopt::Error::ParsingFailed {
                        value: "-H".to_string(),
                        error: Box::new(errno),
                    }
                })?;
            }
            // -x => bin
            Short('x') => {
                prof.bin = parse_utf8_str(parser.value()?.as_bytes()).map_err(|errno| {
                    lexopt::Error::ParsingFailed {
                        value: "-x".to_string(),
                        error: Box::new(errno),
                    }
                })?;
            }
            // -s => repeated Syd options
            Short('s') => {
                prof.syd
                    .push(parse_utf8_str(parser.value()?.as_bytes()).map_err(|errno| {
                        lexopt::Error::ParsingFailed {
                            value: "-s".to_string(),
                            error: Box::new(errno),
                        }
                    })?);
            }
            // -o => output
            Short('o') => {
                prof.output = parse_utf8_str(parser.value()?.as_bytes()).map_err(|errno| {
                    lexopt::Error::ParsingFailed {
                        value: "-s".to_string(),
                        error: Box::new(errno),
                    }
                })?;
            }
            // -l => limit
            Short('l') => {
                prof.limit = parse_u8(parser.value()?.as_bytes()).map_err(|errno| {
                    lexopt::Error::ParsingFailed {
                        value: "-l".to_string(),
                        error: Box::new(errno),
                    }
                })?;
            }
            // -t => timeout
            Short('t') => {
                prof.timeout = Some(
                    parse_u64(parser.value()?.as_bytes())
                        .map(Duration::from_secs)
                        .map_err(|errno| lexopt::Error::ParsingFailed {
                            value: "-t".to_string(),
                            error: Box::new(errno),
                        })?,
                );
            }
            // -T => threads
            Short('T') => {
                prof.threads = parse_usize(parser.value()?.as_bytes()).map_err(|errno| {
                    lexopt::Error::ParsingFailed {
                        value: "-T".to_string(),
                        error: Box::new(errno),
                    }
                })?;
            }
            // positional => belongs to cmd
            Value(prog) => {
                prof.cmd.clear();
                prof.cmd.push(prog);
                prof.cmd.extend(parser.raw_args()?);
            }
            _ => return Err(arg.unexpected()),
        }
    }

    // Resolve "auto" hash to preferred algorithm.
    #[cfg(feature = "force")]
    if prof.hash == "auto" {
        prof.hash = hash_auto().to_string();
    }

    #[cfg(not(feature = "force"))]
    if prof.hash != "auto" {
        return Err(lexopt::Error::ParsingFailed {
            value: "-H".to_string(),
            error: Box::new(Errno::ENOSYS),
        });
    }

    Ok(prof)
}

// Parse "inspect" subcommand options.
pub(crate) fn parse_inspect_opts(mut parser: lexopt::Parser) -> Result<InspectOpts, lexopt::Error> {
    use lexopt::prelude::*;

    let input =
        parse_env_str("PANDORA_IN", b"-").map_err(|errno| lexopt::Error::ParsingFailed {
            value: "PANDORA_IN".to_string(),
            error: Box::new(errno),
        })?;
    let output = parse_env_str("PANDORA_OUT", b"./pandora_out.syd-3").map_err(|errno| {
        lexopt::Error::ParsingFailed {
            value: "PANDORA_OUT".to_string(),
            error: Box::new(errno),
        }
    })?;
    let hash = match env::var("PANDORA_HASH") {
        Ok(val) if !val.is_empty() => val,
        _ => "auto".to_string(),
    };
    let limit = parse_env_u8("PANDORA_LIMIT", 3).map_err(|errno| lexopt::Error::ParsingFailed {
        value: "PANDORA_LIMIT".to_string(),
        error: Box::new(errno),
    })?;
    let threads = parse_env_usize("PANDORA_NPROC", cpu::get().unwrap_or(2)).map_err(|errno| {
        lexopt::Error::ParsingFailed {
            value: "PANDORA_NPROC".to_string(),
            error: Box::new(errno),
        }
    })?;

    let mut io = InspectOpts {
        input,
        output,
        hash,
        limit,
        threads,
    };

    while let Some(arg) = parser.next()? {
        match arg {
            Short('h') | Long("help") => {
                print_help_inspect();
                std::process::exit(0);
            }
            Short('V') | Long("version") => {
                print_version();
                std::process::exit(0);
            }
            // -H => hash
            Short('H') => {
                io.hash = parse_utf8_str(parser.value()?.as_bytes()).map_err(|errno| {
                    lexopt::Error::ParsingFailed {
                        value: "-H".to_string(),
                        error: Box::new(errno),
                    }
                })?;
            }
            // -i => input
            Short('i') => {
                io.input = parse_utf8_str(parser.value()?.as_bytes()).map_err(|errno| {
                    lexopt::Error::ParsingFailed {
                        value: "-i".to_string(),
                        error: Box::new(errno),
                    }
                })?;
            }
            // -o => output
            Short('o') => {
                io.output = parse_utf8_str(parser.value()?.as_bytes()).map_err(|errno| {
                    lexopt::Error::ParsingFailed {
                        value: "-o".to_string(),
                        error: Box::new(errno),
                    }
                })?;
            }
            // -l => limit
            Short('l') => {
                io.limit = parse_u8(parser.value()?.as_bytes()).map_err(|errno| {
                    lexopt::Error::ParsingFailed {
                        value: "-l".to_string(),
                        error: Box::new(errno),
                    }
                })?;
            }
            // -T => threads
            Short('T') => {
                io.threads = parse_usize(parser.value()?.as_bytes()).map_err(|errno| {
                    lexopt::Error::ParsingFailed {
                        value: "-T".to_string(),
                        error: Box::new(errno),
                    }
                })?;
            }
            _ => return Err(arg.unexpected()),
        }
    }

    // Resolve "auto" hash to preferred algorithm.
    #[cfg(feature = "force")]
    if io.hash == "auto" {
        io.hash = hash_auto().to_string();
    }

    #[cfg(not(feature = "force"))]
    if io.hash != "auto" {
        return Err(lexopt::Error::ParsingFailed {
            value: "-H".to_string(),
            error: Box::new(Errno::ENOSYS),
        });
    }

    Ok(io)
}

pub(crate) fn print_help_main() {
    let nproc = env::var("PANDORA_NPROC").unwrap_or_else(|_| "2".to_string());
    let _ = w!(
        stderr(),
        r#"{PKG_NAME} {PKG_VERSION}
{PKG_DESCRIPTION}
Copyright (c) 2023, 2024, 2025 {PKG_AUTHORS}
SPDX-License-Identifier: {PKG_LICENSE}

Usage: {PKG_NAME} [COMMAND] [OPTIONS...]

Commands:
  profile  Execute a program under inspection and write a Syd profile
  inspect  Read Syd logs from input and write a Syd profile

Options:
  -h  Print help
  -V  Print version

Environment Variables:
  SYD_BIN         Path to Syd binary [default: syd]
  PANDORA_IN      Path to Syd access violation logs, use "-" for standard input, "syslog" for syslog(2) [default: -]
  PANDORA_OUT     Path to Syd profile output, use "-" for standard output [default: ./pandora_out.syd-3]
  PANDORA_LIMIT   Maximum number of path members before trim, 0 to disable [default: 3]
  PANDORA_TIMEOUT Timeout in seconds
  PANDORA_HASH    Hash algorithm [default: blake3]
                  e.g. blake3, sha256, sha512, sha3-512, blake2b-256, md5, crc32c
                  Use "syd-sum -a list" to see the complete list of supported algorithms.
  PANDORA_NPROC   Number of concurrency threads used for parallel hashing [default: {nproc}]

Hey you, out there beyond the wall,
Breaking bottles in the hall,
Can you help me?

Send bug reports to {PKG_AUTHORS}.
Attaching poems encourages consideration tremendously.

Homepage: https://sydbox.exherbo.org
Repository: https://gitlab.exherbo.org/sydbox/
"#,
    );
}

#[expect(clippy::disallowed_methods)]
pub(crate) fn print_help_profile() {
    let nproc = env::var("PANDORA_NPROC").unwrap_or_else(|_| "2".to_string());
    let _ = w!(
        stderr(),
        r#"{PKG_NAME} {PKG_VERSION}
Profile subcommand

Usage: {PKG_NAME} profile [OPTIONS] <cmd>...

Options:
  -h           Print help
  -V           Print version
  -x <bin>     Path to Syd binary [default: syd, env:SYD_BIN]
  -s <option>  Pass an option to Syd during init, may be repeated
  -o <output>  Path to Syd profile output, use "-" for standard output [default: ./pandora_out.syd-3, env:PANDORA_OUT]
  -l <limit>   Maximum number of path members before trim, 0 to disable [default: 3, env:PANDORA_LIMIT]
  -t <secs>    Timeout in seconds [env:PANDORA_TIMEOUT]
  -H <hash>    Hash algorithm: [default:blake3, env:PANDORA_HASH]
               e.g. blake3, sha256, sha512, sha3-512, blake2b-256, md5, crc32c
               Use "syd-sum -a list" to see the complete list of supported algorithms.
  -T <threads> Number of concurrency threads used for parallel hashing [default: {nproc}, env:PANDORA_NPROC]
"#,
    );
}

#[expect(clippy::disallowed_methods)]
pub(crate) fn print_help_inspect() {
    let nproc = env::var("PANDORA_NPROC").unwrap_or_else(|_| "2".to_string());
    let _ = w!(
        stderr(),
        r#"{PKG_NAME} {PKG_VERSION}
Inspect subcommand

Usage: {PKG_NAME} inspect [OPTIONS]

Options:
  -h           Print help
  -V           Print version
  -i <input>   Path to Syd access violation logs, use "-" for standard input, "syslog" for syslog(2) [default: -]
  -o <output>  Path to Syd profile output, use "-" for standard output [default: ./pandora_out.syd-3, env:PANDORA_OUT]
  -l <limit>   Maximum number of path members before trim, 0 to disable [default: 3, env:PANDORA_LIMIT]
  -H <hash>    Hash algorithm: [default:blake3, env:PANDORA_HASH]
               e.g. blake3, sha256, sha512, sha3-512, blake2b-256, md5, crc32c
               Use "syd-sum -a list" to see the complete list of supported algorithms.
  -T <threads> Number of concurrency threads used for parallel hashing [default: {nproc}, env:PANDORA_NPROC]
"#,
    );
}

pub(crate) fn print_version() {
    let _ = wln!(stderr(), "{PKG_NAME}-{PKG_VERSION}");
}

// Returns longest prefix of "s" shared by >=2 keys in "set", else "s".
pub(crate) fn parse_env_str(var: &str, default: &[u8]) -> Result<String, Errno> {
    if let Some(osv) = env::var_os(var) {
        if osv.is_empty() {
            return Err(Errno::EINVAL);
        }
        let bytes = osv.as_bytes();
        parse_utf8_str(bytes)
    } else {
        parse_utf8_str(default)
    }
}

pub(crate) fn parse_env_u8(var: &str, default_val: u8) -> Result<u8, Errno> {
    if let Some(osv) = env::var_os(var) {
        if osv.is_empty() {
            return Err(Errno::EINVAL);
        }
        parse_u8(osv.as_bytes())
    } else {
        Ok(default_val)
    }
}

pub(crate) fn parse_env_usize(var: &str, default_val: usize) -> Result<usize, Errno> {
    if let Some(osv) = env::var_os(var) {
        if osv.is_empty() {
            return Err(Errno::EINVAL);
        }
        parse_usize(osv.as_bytes())
    } else {
        Ok(default_val)
    }
}

pub(crate) fn parse_os_u64(osv: &std::ffi::OsString) -> Result<u64, Errno> {
    if osv.is_empty() {
        return Err(Errno::EINVAL);
    }
    parse_u64(osv.as_bytes())
}

fn parse_utf8_str(bytes: &[u8]) -> Result<String, Errno> {
    match std::str::from_utf8(bytes) {
        Ok(s) => Ok(s.to_owned()),
        Err(_) => {
            wln!(stderr(), "ERROR: invalid UTF-8 data")?;
            Err(Errno::EINVAL)
        }
    }
}

fn parse_u8(bytes: &[u8]) -> Result<u8, Errno> {
    btoi::<u8>(bytes).map_err(|_| Errno::EINVAL)
}

fn parse_usize(bytes: &[u8]) -> Result<usize, Errno> {
    btoi::<usize>(bytes).map_err(|_| Errno::EINVAL)
}

fn parse_u64(bytes: &[u8]) -> Result<u64, Errno> {
    btoi::<u64>(bytes).map_err(|_| Errno::EINVAL)
}
