//
// Syd: rock-solid application kernel
// src/syd-sh.rs: confined shell
//
// Copyright (c) 2024, 2025 Ali Polatel <alip@chesswob.org>
//
// SPDX-License-Identifier: GPL-3.0

use std::{
    env,
    fs::File,
    io::{Read, Seek, SeekFrom, Write},
    os::fd::AsRawFd,
    process::{exit, ExitCode},
    time::Duration,
};

use linefeed::{Interface, ReadResult};
use nix::{
    errno::Errno,
    libc::STDIN_FILENO,
    unistd::{isatty, Gid, Uid},
};
use syd::{
    config::*,
    debug,
    err::SydResult,
    fs::{create_memfd, safe_copy_if_exists, seal_memfd, set_cloexec, MFD_ALLOW_SEALING},
    get_user_home, get_user_name, human_size,
    path::{XPath, XPathBuf},
    syslog::LogLevel,
    wordexp::*,
};

fn main() -> SydResult<ExitCode> {
    use lexopt::prelude::*;

    syd::set_sigpipe_dfl()?;

    // Initialize logging.
    syd::log::log_init_simple(LogLevel::Warn)?;

    // Parse options.
    let mut optc = false;
    let mut opte = false;
    let mut optl = false;
    let mut optx = false;

    // Skip options with `+` prefix for POSIX compat.
    // `-` at argv[0][0] triggers login mode.
    let mut args = Vec::new();
    let mut aend = false;
    for (idx, arg) in env::args().enumerate() {
        match arg.chars().next() {
            Some('-') if idx == 0 => {
                optl = true;
                continue;
            }
            _ if idx == 0 => continue,
            Some('+') if !aend => continue,
            Some('-') if arg == "--" => aend = true,
            _ => aend = true,
        }
        args.push(arg);
    }

    let mut parser = lexopt::Parser::from_args(&args);
    let mut args = Vec::new();
    while let Some(arg) = parser.next()? {
        match arg {
            Short('h') => {
                help();
                return Ok(ExitCode::SUCCESS);
            }
            Short('c') => optc = true,
            Short('e') => opte = true,
            Short('l') => optl = true,
            Short('x') => optx = true,
            // Ignore unknown options for POSIX compat.
            Short(_) | Long(_) => {}
            Value(prog) => {
                args.push(prog);
                args.extend(parser.raw_args()?);
            }
        }
    }

    // Create a memory fd to write input into,
    // and pass to the internal /bin/sh invoked
    // by wordexp(3).
    let mut file = create_memfd(b"syd-sh\0", MFD_ALLOW_SEALING).map(File::from)?;
    debug!("ctx": "sh",
        "msg": format!("created memory-file {} with close-on-exec flag set",
            file.as_raw_fd()));

    // Configure options to pass to /bin/sh.
    if opte {
        file.write_all(b"set -e\n")?;
    }
    if optx {
        file.write_all(b"set -x\n")?;
    }

    // Define the `esyd` function.
    file.write_all(ESYD_SH.as_bytes())?;
    file.write_all(b"\n")?;

    // Handle system-wide configuration.
    if optl {
        safe_copy_if_exists(&mut file, XPath::from_bytes(b"/etc/syd/init_login.sh"))?;
        file.write_all(b"\n")?;
    }
    safe_copy_if_exists(&mut file, XPath::from_bytes(b"/etc/syd/init.sh"))?;
    file.write_all(b"\n")?;

    // Handle user-specific configuration.
    let uid = Uid::effective();
    let name = get_user_name(uid);
    let home = get_user_home(&name);
    if optl {
        safe_copy_if_exists(&mut file, &home.join(b".config/syd/init_login.sh"))?;
        file.write_all(b"\n")?;
    }
    safe_copy_if_exists(&mut file, &home.join(b".config/syd/init.sh"))?;
    file.write_all(b"\n")?;

    // Handle -c command_name argument...
    let mut args = args.into_iter().peekable();
    if optc {
        if args.peek().is_none() {
            eprintln!("syd-sh: -c requires an argument!");
            return Ok(ExitCode::FAILURE);
        }

        let mut argc = 0;
        let mut input = String::new();
        for arg in args {
            argc += 1;

            let arg = arg.to_str().ok_or(Errno::EINVAL)?;
            file.write_all(quote(arg).as_bytes())?;
            file.write_all(b" ")?;

            if optx {
                input.push_str(arg);
                input.push(' ');
            }
        }
        file.write_all(b"\n")?;
        debug!("ctx": "sh",
            "msg": format!("written {argc} argument{} into memory-file {}",
                if argc > 1 { "s" } else { "" },
                file.as_raw_fd()));
        if optx {
            eprintln!("+ {input}");
        }

        seal_memfd(&file)?;
        debug!("ctx": "sh",
            "msg": format!("sealed memory-file {} against grows, shrinks and writes",
                file.as_raw_fd()));

        set_cloexec(&file, false)?;
        debug!("ctx": "sh",
            "msg": format!("set close-on-exec flag to off for memory-file {}",
                file.as_raw_fd()));

        let shell = format!("`. /proc/self/fd/{}`", file.as_raw_fd());
        debug!("ctx": "sh",
            "msg": format!("passing memory file {} to WordExp::expand with 3 seconds timeout...",
                file.as_raw_fd()));
        match WordExp::expand(&shell, true, Duration::from_secs(3)) {
            Ok(out) => {
                println!("{out}");
                return Ok(ExitCode::SUCCESS);
            }
            Err(err) => {
                let err = err.into();
                if opte {
                    eprintln!("syd-sh: 1: {}", wrde2str(err));
                }
                exit(err);
            }
        };
    }

    #[allow(clippy::type_complexity)]
    #[allow(clippy::disallowed_methods)]
    let input: Option<(Box<dyn Read>, String)> = if let Some(path) = args.next() {
        Some((
            Box::new(File::open(&path)?),
            XPathBuf::from(path).to_string(),
        ))
    } else if isatty(STDIN_FILENO).unwrap_or(false) {
        None
    } else {
        Some((Box::new(std::io::stdin()), "standard input".to_string()))
    };

    if let Some((mut input_file, input_name)) = input {
        debug!("ctx": "sh",
            "msg": format!("copying from {input_name} to memory-file {}...",
                file.as_raw_fd()));
        let copylen = std::io::copy(&mut input_file, &mut file)?;
        debug!("ctx": "sh",
            "msg": format!("copied {} from {input_name} to memory-file {}",
                human_size(copylen.try_into()?),
                file.as_raw_fd()));

        seal_memfd(&file)?;
        debug!("ctx": "sh",
            "msg": format!("sealed memory-file {} against grows, shrinks and writes",
                file.as_raw_fd()));

        set_cloexec(&file, false)?;
        debug!("ctx": "sh",
            "msg": format!("set close-on-exec flag to off for memory-file {}",
                file.as_raw_fd()));

        let shell = format!("`. /proc/self/fd/{}`", file.as_raw_fd());
        debug!("ctx": "sh",
            "msg": format!("passing memory file {} to WordExp::expand with 3 seconds timeout...",
                file.as_raw_fd()));
        match WordExp::expand(&shell, true, Duration::from_secs(3)) {
            Ok(val) => {
                println!("{val}");
                return Ok(ExitCode::SUCCESS);
            }
            Err(err) => {
                let err = err.into();
                if opte {
                    eprintln!("syd-sh: {err}");
                }
                exit(err);
            }
        }
    }

    // SAFETY: Quoting sh(1p):
    //   -i        Specify that the shell is interactive; see below. An
    //             implementation may treat specifying the -i option as an
    //             error if the real user ID of the calling process does
    //             not equal the effective user ID or if the
    // TODO: Make this check before we open the memory-fd to be polite.
    assert_eq!(
        Uid::current(),
        Uid::effective(),
        "real user ID must match effective user ID in interactive mode!",
    );
    assert_eq!(
        Gid::current(),
        Gid::effective(),
        "real group ID must match effective group ID in interactive mode!",
    );

    // Write successful commands who generate no output
    // to the memory fd. The user can also explicitly
    // save into history with the '>' prefix.
    // This way we maintain a simple form of shell state.
    let reader = Interface::new("syd-sh")?;
    reader.set_prompt("; ")?;
    while let ReadResult::Input(input) = reader.read_line()? {
        if matches!(input.chars().next(), Some('>')) {
            // explicit push into history.
            let histlen = file.seek(SeekFrom::End(0))?;
            file.write_all(&input.as_bytes()[1..])?;
            file.write_all(b"\n")?;
            let len = input.len();
            reader.set_prompt("OKHIST; ")?;
            debug!("ctx": "sh",
                "msg": format!("pushed {} into memory-file of {}",
                    human_size(len),
                    human_size(histlen.try_into()?)));
            continue;
        } else if matches!(input.trim().chars().next(), None | Some('#')) {
            reader.set_prompt("; ")?;
            continue;
        } else if optx {
            eprintln!("+ {input}");
        }

        // SAFETY: create a private, write-sealed copy of the memory-file.
        let mut fdup = create_memfd(b"syd-sh\0", MFD_ALLOW_SEALING).map(File::from)?;
        debug!("ctx": "sh",
            "msg": format!("created memory-file {} with sealing allowed",
                fdup.as_raw_fd()));

        // rewrite history!
        file.seek(SeekFrom::Start(0))?;
        let copylen = std::io::copy(&mut file, &mut fdup)?;
        debug!("ctx": "sh",
            "msg": format!("copied {} from memory-file {} to {}",
                human_size(copylen.try_into()?),
                file.as_raw_fd(),
                fdup.as_raw_fd()));

        fdup.write_all(input.as_bytes())?;
        debug!("ctx": "sh",
            "msg": format!("written {} of input to memory-file {}",
                human_size(input.len()),
                fdup.as_raw_fd()));

        seal_memfd(&fdup)?;
        debug!("ctx": "sh",
            "msg": format!("sealed memory-file {} against grows, shrinks and writes",
                fdup.as_raw_fd()));

        set_cloexec(&fdup, false)?;
        debug!("ctx": "sh",
            "msg": format!("set close-on-exec flag to off for memory-file {}",
                fdup.as_raw_fd()));

        let shell = format!("`. /proc/self/fd/{} 2>&1`", fdup.as_raw_fd());
        debug!("ctx": "sh",
            "msg": format!("passing memory-file {} to WordExp::expand with 3 seconds timeout...",
                fdup.as_raw_fd()));
        let result = WordExp::expand(&shell, true, Duration::from_secs(3));

        let fdup_fd = fdup.as_raw_fd();
        drop(fdup);
        match result {
            Ok(ref val) => {
                debug!("ctx": "sh",
                    "msg": format!("closed memory-file {fdup_fd} after WordExp::expand returned {} of output",
                        human_size(val.len())));
            }
            Err(ref err) => {
                debug!("ctx": "sh",
                    "msg": format!("closed memory-file {fdup_fd} after WordExp::expand error {err}"));
            }
        }

        match result {
            Ok(val) => {
                reader.set_prompt("; ")?;
                println!("{val}");
            }
            Err(WordExpError::BadValue) if !input.contains(';') => {
                reader.set_prompt("; ")?;
                if let Some(cmd) = input.split_whitespace().next() {
                    for builtin in SHELL_BUILTINS {
                        if cmd == *builtin {
                            let histlen = file.seek(SeekFrom::End(0))?;
                            file.write_all(input.as_bytes())?;
                            file.write_all(b"\n")?;
                            debug!("ctx": "sh",
                                "msg": format!("pushed {} into memory-file of {}",
                                    human_size(input.len() + 1),
                                    human_size(histlen.try_into()?)));
                            break;
                        }
                    }
                }
            }
            Err(err) => {
                let prompt = format!("{}; ", wrde2str(err.into()));
                reader.set_prompt(&prompt)?;
            }
        }
    }

    Ok(ExitCode::SUCCESS)
}

fn help() {
    println!("Usage:");
    println!("  syd-sh [-helsx] [--] [_command_file_ [argument...]]");
    println!("  syd-sh [-helx] -c _command_string_ [_command_name_ [argument...]]");
    println!("Simple confined shell based on wordexp(3)");
    println!("Given no arguments, enter read-eval-print loop.");
    println!("Given -c with an argument, evaluate and print the result.");
}

fn wrde2str(err: i32) -> String {
    match err {
        0 => "".to_string(),
        128 => "ERR?".to_string(),
        WRDE_NOSPACE => "NOSPACE".to_string(),
        WRDE_BADCHAR => "BADCHAR".to_string(),
        WRDE_BADVAL => "BADVAL".to_string(),
        WRDE_CMDSUB => "CMDSUB".to_string(),
        WRDE_SYNTAX => "SYNTAX".to_string(),
        WRDE_SECCOMP => "SECCOMP".to_string(),
        WRDE_TIMEOUT => "TIMEOUT".to_string(),
        _ => format!("ERR{}", 128 - err),
    }
}

fn quote(input: &str) -> String {
    format!("'{}'", input.replace("'", "'\\''"))
}

const SHELL_BUILTINS: &[&str] = &[
    ".", "alias", "cd", "export", "hash", "readonly", "set", "shift", "source", "umask", "unalias",
    "unset",
];
