// License: see LICENSE file at root directory of `master` branch

//! # Bailamos

#![warn(missing_docs)]

#[macro_use]
#[allow(unused_macros)]
mod __;

macro_rules! print_internal_error { () => { __e!("Internal error"); }}

mod db;
mod task;

use std::{
    borrow::Cow,
    convert::TryFrom,
    env,
    fs,
    io::{self, Error, ErrorKind, Write},
    net::TcpListener,
    path::PathBuf,
    process,
    sync::Arc,
};

use bailamos::{
    Result,
    db::Id,
    rpc,
};
use blackhole::{ActiveLimit, Blackhole};
use dia_args::{
    Args, MergeOption,
    paths::{
        self, PathKind, TakeOption,
        permissions::Permissions,
    },
};
use native_tls::{Identity, TlsAcceptor};

use db::{
    ID,
    users::{self, Passphrase, User},
};
use task::{DbManager, RequestVerifier, Task};

macro_rules! internal_error { () => { Error::new(ErrorKind::Other, __!("Internal error")) }}

const TAG: &str = bailamos::TAG;
const ARGS_FILE_PERMISSIONS: Permissions = 0o600;

#[cfg(unix)]
const LINE_BREAK: &[u8] = &[b'\n'];

#[cfg(not(unix))]
const LINE_BREAK: &[u8] = &[b'\r', b'\n'];

macro_rules! work_dir_notes { () => { "The program will work on your work directory." }}

const CMD_HELP: &str = "help";
const CMD_HELP_DOCS: Cow<str> = Cow::Borrowed("Prints help and exits.");

const CMD_VERSION: &str = "version";
const CMD_VERSION_DOCS: Cow<str> = Cow::Borrowed("Prints version and exits.");

const CMD_RUN: &str = "run";
const CMD_RUN_DOCS: Cow<str> = Cow::Borrowed("Runs the server.");

const CMD_ADD_USER: &str = "add-user";
const CMD_ADD_USER_DOCS: Cow<str> = Cow::Borrowed(concat!("Adds new user.\n\n", work_dir_notes!()));

const CMD_EDIT_USER: &str = "edit-user";
const CMD_EDIT_USER_DOCS: Cow<str> = Cow::Borrowed(concat!("Edits user.\n\n", work_dir_notes!()));

const CMD_REMOVE_USER: &str = "remove-user";
const CMD_REMOVE_USER_DOCS: Cow<str> = Cow::Borrowed(concat!("Removes user.\n\n", work_dir_notes!()));

const ARG_HOME: &[&str] = &["--home"];
const ARG_HOME_DOCS: Cow<str> = Cow::Borrowed(concat!(
    "Home directory.\n\n",
    "If provided, it will have higher priority than HOME environment variable. Note that it should have the same meaning as that variable.",
));

const ARG_ARGS: &[&str] = &["--args"];

const ARG_PORT: &[&str] = &["--port"];
const ARG_PORT_DOCS: Cow<str> = Cow::Borrowed("Server port.");
const ARG_PORT_DEFAULT: u16 = rpc::DEFAULT_SERVER_PORT;

const ARG_ADDRESS: &[&str] = &["--address"];
const ARG_ADDRESS_DEFAULT: &str = "127.0.0.1";

/// # Main
fn main() {
    if let Err(err) = run() {
        eprintln!("{}", err);
        process::exit(1);
    }
}

/// # Runs the program
fn run() -> Result<()> {
    let args = dia_args::parse()?;
    match args.cmd() {
        Some(CMD_HELP) => {
            ensure_args_are_empty(args.into_sub_cmd().1)?;
            print_help()
        },
        Some(CMD_VERSION) => {
            ensure_args_are_empty(args.into_sub_cmd().1)?;
            print_version()
        },
        Some(CMD_RUN) => run_server(args.into_sub_cmd().1),
        Some(CMD_ADD_USER) => add_user(args.into_sub_cmd().1),
        Some(CMD_EDIT_USER) => edit_user(args.into_sub_cmd().1),
        Some(CMD_REMOVE_USER) => remove_user(args.into_sub_cmd().1),
        Some(other) => Err(Error::new(ErrorKind::InvalidInput, format!("Unknown command: {:?}", other))),
        None => Err(Error::new(ErrorKind::Other, "Missing command")),
    }
}

/// # Ensures arguments are empty
fn ensure_args_are_empty<A>(args: A) -> Result<()> where A: AsRef<Args> {
    let args = args.as_ref();
    match args.is_empty() {
        true => Ok(()),
        false => Err(Error::new(ErrorKind::InvalidInput, format!("Unknown arguments: {:?}", args))),
    }
}

/// # Makes version string
fn make_version_string<'a>() -> Cow<'a, str> {
    format!("{} {} {:?}", bailamos::NAME, bailamos::VERSION, bailamos::RELEASE_DATE).into()
}

/// # Prints version
fn print_version() -> Result<()> {
    println!("{}", make_version_string());
    Ok(())
}

/// # Prints help
fn print_help() -> Result<()> {
    use dia_args::docs::{Cmd, Docs, Opt, Project};

    let commands = Some(dia_args::make_cmds![
        Cmd::new(CMD_HELP, CMD_HELP_DOCS, None),
        Cmd::new(CMD_VERSION, CMD_VERSION_DOCS, None),
        Cmd::new(CMD_RUN, CMD_RUN_DOCS, Some(dia_args::make_opts![
            Opt::new(ARG_HOME, false, None, None, ARG_HOME_DOCS),
            Opt::new(ARG_ARGS, false, None, None, Cow::Owned(format!(
                concat!(
                    "Path to args file. Its permissions must be exactly 0o{:03o}.\n\n",
                    "Note that except for {:?}, all other options can be set via either command line or this file. Options set via command",
                    " line will overwrite the ones from file."
                ),
                ARGS_FILE_PERMISSIONS, ARG_HOME,
            ))),
            Opt::new(ARG_ADDRESS, false, None, Some(&ARG_ADDRESS_DEFAULT), Cow::Owned(format!(
                "Server address. Port can be included, but there will be a clash with {:?} if you also provide it.",
                ARG_PORT,
            ))),
            Opt::new(ARG_PORT, false, None, Some(&ARG_PORT_DEFAULT), ARG_PORT_DOCS),
        ])),
        Cmd::new(CMD_ADD_USER, CMD_ADD_USER_DOCS, None),
        Cmd::new(CMD_EDIT_USER, CMD_EDIT_USER_DOCS, None),
        Cmd::new(CMD_REMOVE_USER, CMD_REMOVE_USER_DOCS, None),
    ]);

    let project = {
        let license = include_str!("../../../LICENSE");
        Some(Project::new("https://bitbucket.org/haibison/bailamos", license.lines().next().unwrap(), Some(Cow::Borrowed(license))))
    };

    let mut docs = Docs::new(
        make_version_string(), Cow::Owned(format!("Your work directory: {:?}\n\n{}", work_dir(None)?, dia_args::DIA_ARGS_FILE_FORMAT)),
    );
    docs.commands = commands;
    docs.project = project;
    docs.print();

    Ok(())
}

/// # Gets or makes work directory
fn work_dir(args: Option<&mut Args>) -> Result<PathBuf> {
    let home_dir = match args {
        Some(args) => paths::take_path(args, ARG_HOME, PathKind::Directory, TakeOption::MustExist)?,
        None => None,
    };
    let home_dir = match home_dir {
        Some(home_dir) => home_dir,
        None => match env::var("HOME") {
            Ok(home_dir) => PathBuf::from(home_dir),
            Err(err) => return Err(Error::new(ErrorKind::NotFound, format!("Failed getting $HOME: {:?}", err))),
        },
    };
    let home_dir = match home_dir.canonicalize() {
        Ok(home_dir) => home_dir,
        Err(err) => return Err(Error::new(ErrorKind::NotFound, format!("Invalid home directory {:?}: {:?}", home_dir, err))),
    };

    let result = home_dir.join(format!(".{}.{}", bailamos::ID, bailamos::CODE_NAME));
    if result.is_dir() == false {
        fs::create_dir(&result)?;
    }
    Ok(result)
}

/// # Runs the server
fn run_server(mut args: Args) -> Result<()> {
    let work_dir = work_dir(Some(&mut args))?;

    if let Some(args_file) = paths::take_path(&mut args, ARG_ARGS, PathKind::File, TakeOption::Take { make: false })? {
        if let Some(mut args_from_file) = dia_args::parse_file(Some(args_file), Some(ARGS_FILE_PERMISSIONS), None)? {
            args.merge_options(&mut args_from_file, &[ARG_ADDRESS, ARG_PORT], MergeOption::IgnoreExisting)?;
        }
    }

    let port = args.take(ARG_PORT)?;
    let address = match args.take::<String>(ARG_ADDRESS)? {
        Some(address) => match (address.contains(':'), port) {
            (true, Some(port)) => return Err(Error::new(
                ErrorKind::InvalidInput, format!("Could not decide which port to be used: {:?} or \"{:?}\"?", address, port),
            )),
            (true, None) => address,
            (false, _) => format!("{}:{}", address, port.unwrap_or(ARG_PORT_DEFAULT)),
        },
        None => format!("{}:{}", ARG_ADDRESS_DEFAULT, port.unwrap_or(ARG_PORT_DEFAULT)),
    };

    ensure_args_are_empty(args)?;

    let blackhole = Blackhole::make(
        {
            let online_processors = unsafe {
                blackhole::get_online_processors!(None)
            };
            ActiveLimit::try_from(online_processors)
                .map_err(|_| Error::new(ErrorKind::Other, format!("Failed to convert {} to usize", online_processors)))?
        },
        1024,
    )?;

    let db_conn = db::connect_or_setup(work_dir)?;
    let (db_manager, db_manager_msg_sender) = DbManager::new(db_conn);
    match blackhole.throw(Task::DbManager(db_manager)) {
        Ok(job) => if job.is_some() {
            return Err(Error::new(ErrorKind::Other, "Could not start database manager"));
        },
        Err(err) => return Err(Error::new(ErrorKind::Other, format!("Failed starting database manager: {:?}", err))),
    };

    let acceptor = || -> native_tls::Result<_> {
        let identity = Identity::from_pkcs12(bailamos::CERT_P12, bailamos::CERT_P12_PASSPHRASE)?;
        Ok(Arc::new(TlsAcceptor::new(identity)?))
    };
    let acceptor = acceptor().map_err(|e| Error::new(ErrorKind::Other, e))?;
    let listener = TcpListener::bind(&address)?;
    __p!("Serving at: {}", &address);
    for stream in listener.incoming() {
        match stream {
            Ok(stream) => {
                let acceptor = acceptor.clone();
                match blackhole.throw(Task::RequestVerifier(RequestVerifier::new(acceptor, stream, db_manager_msg_sender.clone()))) {
                    Ok(job) => if job.is_some() {
                        __e!("Blackhole queue is full, discarding one job");
                    },
                    Err(err) => {
                        __e!("Blackhole is exploded: {:?}\nStopping server...", err);
                        break;
                    },
                };
            },
            Err(err) => __e!("Failed accepting new client: {:?}", err),
        };
    }

    Ok(())
}

/// # Adds user
fn add_user(args: Args) -> Result<()> {
    ensure_args_are_empty(args)?;

    // Name
    let name = enter_user_name()?;

    // E-mail
    print!("Enter e-mail: ");
    io::stdout().flush()?;
    let email = dia_args::read_line::<String>()?;

    // Passphrase
    let passphrase = enter_passphrase()?.ok_or_else(|| Error::new(ErrorKind::InvalidInput, "Passphrase is empty"))?;

    // Database
    let date_created = db::system_time()?;
    let db_conn = db::connect_or_setup(work_dir(None)?)?;
    match db_conn.execute(
        &format!(
            "insert into {} ({},{},{},{},{}) values (?,?,?,?,?);",
            users::TABLE, users::DATE_CREATED, users::DATE_MODIFIED, users::NAME, users::EMAIL, users::PASSPHRASE,
        ),
        rusqlite::params![date_created, date_created, name, email, passphrase.as_ref()],
    ) {
        Ok(1) => Ok(println!("Ok")),
        Ok(other) => Err(Error::new(ErrorKind::Other, format!("Expected to insert 1 row, got: {:?}", other))),
        Err(err) => Err(Error::new(ErrorKind::Other, err)),
    }
}

/// # Asks user to enter user name
fn enter_user_name() -> Result<String> {
    let mut stdout = io::stdout();
    stdout.write_all(b"Enter user name (a-z, 0-9, _, -, .): ")?;
    stdout.flush()?;

    let result = dia_args::read_line::<String>()?.to_lowercase();
    match result.is_empty() || result.chars().any(|c| match c {
        'a'..='z' | '0'..='9' | '_' | '-' | '.' => false,
        _ => true,
    }) {
        true => Err(Error::new(ErrorKind::InvalidInput, format!("Invalid user name: {:?}", result))),
        false => Ok(result),
    }
}

/// # Asks user to enter passphrase
fn enter_passphrase() -> Result<Option<Passphrase>> {
    use termion::input::TermRead;

    let stdin = io::stdin();
    let mut stdin = stdin.lock();

    let stdout = io::stdout();
    let mut stdout = stdout.lock();

    stdout.write_all(b"Enter passphrase: ")?;
    stdout.flush()?;
    let passphrase = stdin.read_passwd(&mut stdout)?;
    stdout.write_all(LINE_BREAK)?;
    let passphrase = match passphrase {
        Some(passphrase) => passphrase.trim().to_string(),
        None => return Err(Error::new(ErrorKind::Interrupted, "User cancelled")),
    };
    if passphrase.is_empty() {
        return Ok(None);
    }

    stdout.write_all(b"Confirm passphrase: ")?;
    stdout.flush()?;
    let confirmation = stdin.read_passwd(&mut stdout)?;
    stdout.write_all(LINE_BREAK)?;
    match confirmation.map(|c| c.trim() == &passphrase).unwrap_or(false) {
        true => Ok(Some(users::hash_passphrase(passphrase))),
        false => Err(Error::new(ErrorKind::Other, "Confirmation failed")),
    }
}

/// # Edits user
fn edit_user(args: Args) -> Result<()> {
    ensure_args_are_empty(args)?;

    let mut db_conn = db::connect_or_setup(work_dir(None)?)?;

    let users = users::load_users(&db_conn)?;
    let (user_id, user) = match enter_user_id("\nEnter user ID that you want to edit: ", &users)? {
        Some(result) => result,
        None => return Ok(println!("There are no users.")),
    };
    println!("\n{}\n", __b!(">>> {} ({:?})", user.name(), user.email()));

    print!("Enter new e-mail: ");
    io::stdout().flush()?;
    let new_email = match dia_args::read_line::<String>()? {
        new_email => match new_email.is_empty() || Some(new_email.as_str()) == user.email() {
            true => None,
            false => Some(new_email),
        },
    };

    let new_passphrase = match enter_passphrase()? {
        Some(new_passphrase) => match user.passphrase() == &new_passphrase {
            true => None,
            false => Some(new_passphrase),
        },
        None => None,
    };

    match new_email.is_none() && new_passphrase.is_none() {
        true => Ok(println!("Nothing changed.")),
        false => {
            let date_modified = db::system_time()?;
            let result = move || -> rusqlite::Result<_> {
                let transaction = db_conn.transaction()?;

                // E-mail
                if let Some(new_email) = new_email {
                    match transaction.execute(
                        &format!("update {} set {}=?,{}=? where {}=?;", users::TABLE, users::EMAIL, users::DATE_MODIFIED, ID),
                        rusqlite::params![new_email, date_modified, user_id],
                    )? {
                        1 => {},
                        other => return Err(rusqlite::Error::StatementChangedRows(other)),
                    };
                }

                // Passphrase
                if let Some(new_passphrase) = new_passphrase {
                    match transaction.execute(
                        &format!("update {} set {}=?,{}=? where {}=?;", users::TABLE, users::PASSPHRASE, users::DATE_MODIFIED, ID),
                        rusqlite::params![new_passphrase.as_ref(), date_modified, user_id],
                    )? {
                        1 => {},
                        other => return Err(rusqlite::Error::StatementChangedRows(other)),
                    };
                }

                transaction.commit()
            };
            result().map(|()| println!("Ok")).map_err(|e| Error::new(ErrorKind::Other, e))
        },
    }
}

/// # Asks the user to enter a user ID, then picks one from the list
fn enter_user_id<S>(msg: S, users: &[User]) -> Result<Option<(Id, &User)>> where S: AsRef<str> {
    if users.is_empty() {
        return Ok(None);
    }

    // Print users
    for user in users {
        println!("{}", __b!("[#{}] {} ({:?})", user.id().ok_or_else(|| internal_error!())?, user.name(), user.email()));
    }

    print!("{}", msg.as_ref());
    io::stdout().flush()?;

    match dia_args::read_line::<Id>() {
        Ok(user_id) => match users.iter().find(|u| u.id() == Some(user_id)) {
            Some(user) => Ok(Some((user_id, user))),
            None => Err(Error::new(ErrorKind::InvalidInput, format!("Invalid user ID: {:?}", user_id))),
        },
        Err(err) => Err(Error::new(ErrorKind::InvalidInput, format!("Invalid user ID: {:?}", err))),
    }
}

/// # Removes user
fn remove_user(args: Args) -> Result<()> {
    ensure_args_are_empty(args)?;

    let db_conn = db::connect_or_setup(work_dir(None)?)?;

    let users = users::load_users(&db_conn)?;
    let (user_id, user) = match enter_user_id("\nEnter user ID that you want to remove: ", &users)? {
        Some(result) => result,
        None => return Ok(println!("There are no users.")),
    };

    let hash = dia_assert::Level::Medium.hash();
    println!("\nRemoving: {}", __b!("{} ({:?})", user.name(), user.email()));
    print!("Type {} to continue: ", __b!("{}", &hash));
    io::stdout().flush()?;
    if dia_args::read_line::<String>()? != hash {
        return Err(Error::new(ErrorKind::InvalidInput, "Confirmation failed"));
    }

    let result = move || -> rusqlite::Result<_> {
        match db_conn.execute(&format!("delete from {} where {}=?;", users::TABLE, ID), &[user_id])? {
            1 => Ok(()),
            other => Err(rusqlite::Error::StatementChangedRows(other)),
        }
    };
    result().map(|()| println!("Removed!")).map_err(|e| Error::new(ErrorKind::Other, e))
}
