mod feature;
pub mod token;
pub mod key;

use std::collections::HashMap;
use std::fs::File;
use std::io::{BufRead, BufReader};
use std::path::Path;
use uuid::Uuid;
use crate::feature::{featured, SupportedDb};
use crate::key::KeyFile;

const CMD_FILE: &str = "file";
const CMD_DB: &str = "db";
const CMD_TBL: &str = "config";
const CMD_TOKEN: &str = "token";
const CMD_TTL: &str = "ttl";
const CMD_KEY: &str = "key";
const CMD_SECRET: &str = "secret";
const CMD_PASS: &str = "PASSPHRASE";
/// self host or node id
const CMD_UUID: &str = "uuid";


/// App startup args:
/// - db connection url: usually the first arg
///   - or prefix with: '--db '
///     (optional, default postgres:// , trying to identify by prefix)
///
/// - config table name in format: schema.table, usually the second arg trying to identify with a dot in the middle.
///   - or prefix with: '--config '
///     (optional, default public.{the_appname})
///
/// - UUID this app instance to use as a node id or config recognition. trying to auto identify a UUID formatted string.
///   - or prefix with '--uuid '
///
/// - token (db pwd) script name usually the third arg (required feature 'token')
///   - or prefix with '--token '
///
/// - token live in minutes, usually the forth arg (required feature 'token')
///   - or prefix with '--ttl '
///
/// - (Private) Key text file name to use with RSA OR AES encryption (required feature 'rsa')
///   - or prefix with '--key '
///
/// - cipher secret for AES taken from env \[SECRET\] (more secure) OR cml --secret (not recommended)
///  <br>Also: compile with keep_env_secret feature to not remove from env
///
/// Alternative configuration:
/// - file name, usually the first arg
///   - or prefix with '--file '
///
/// File format:
///  - db: OR db=
///  - config: OR config=
///  - uuid: OR uuid=
///  - token: OR token=
///  - ttl: OR ttl=
///  - pk: OR pk=
///
/// params passed in cmd line override params loaded from file & env.
///
/// env:
/// - PGPASSWORD, in case of postgres db url, use to connect to the DB
/// - PASSPHRASE, in case of RSA private key required a passphrase
/// - SECRET
///
#[derive(Debug, Clone)]
pub struct ArgConfig {
    /// instance ID
    pub uuid: Uuid,
    /// indicate the instance uuid was set or autogenerated on (every) start
    pub uuid_gen: bool,
    /// database connection string
    pub db_url: String,

    /// Format: schema.table  
    pub table: String,
    /// key=value loaded from file if present
    pub cfg: HashMap<String, String>,
    /// token (i.e. db pwd) script name usually the third arg (required feature 'token')
    pub token: token::Token,
    /// RSA private key file name to use with RSA OR AES encryption (required feature 'rsa')
    pub pk: Option<KeyFile>,
    /// cipher secret for AES taken from env \[SECRET\] OR cml --secret
    /// use keep_env_secret feature to not remove from env
    pub secret: Option<String>,
}


impl ArgConfig {

    pub fn from_args() -> Result<ArgConfig, String> {
        let user = match std::env::var_os("USER") {
            Some(a) => a.to_str().unwrap_or("postgres").to_string(),
            _ => "postgres".to_string(),
        };
        let f = featured();
        let pwd = if !f.env_pwd().is_empty() {
            match std::env::var_os(f.env_pwd().as_str()).map(|v| v) {
                Some(a) => a.to_str().map(|v| v.to_string()),
                _ => None,
            }
        } else {
            None
        };
        let input: Vec<String> = std::env::args_os().map(|e| e.to_string_lossy().to_string()).collect();

        ArgConfig::new(input, f, user, pwd)
    }

    // first arg is an app name itself
    fn new(input: Vec<String>, feature: SupportedDb, user: String, pwd: Option<String>) -> Result<Self, String> {
        let mut cfg = HashMap::new();
        let mut db: Option<String> = None;
        let mut tbl: Option<String>  = None;
        let mut token: Option<String>  = None;

        let mut ttl: Option<String>  = None;
        let mut pk: Option<String>  = None;
        let mut secret: Option<String>  = None;
        let mut uuid: Option<Uuid> = None;
        let mut ignore_next = false;
        for i in 1..input.len() {
            if ignore_next { ignore_next = false; continue }
            ignore_next = false;
            if input[i].starts_with("--") {
                if i < input.len() - 1 {
                    let v = &input[i].as_str()[2..];
                    if v == CMD_FILE {
                        let _ = load(v, &mut cfg)?;
                        ignore_next = true;
                    } else if v == CMD_DB {
                        db = Some(input[i + 1].to_string());
                        ignore_next = true;
                    } else if v == CMD_TBL {
                        tbl = Some(input[i + 1].to_string());
                        ignore_next = true;
                    } else if v == CMD_TOKEN {
                        token = Some(input[i + 1].to_string());
                        ignore_next = true;
                    } else if v == CMD_TTL {
                        ttl = Some(input[i + 1].to_string());
                        ignore_next = true;
                    } else if v == CMD_KEY {
                        let file = input[i + 1].to_string();
                        if key::is_key_file(&file) {
                            pk = Some(file);
                            ignore_next = true;
                        }
                    } else if v == CMD_UUID {
                        uuid = Uuid::parse_str(input[i + 1].as_str()).ok();
                        ignore_next = true;
                    } else if v == CMD_SECRET {
                        secret = Some(input[i + 1].to_string());
                        ignore_next = true;
                    }
                }
            } else {
            }
        }
        // first was a check by tag names, then try to guess
        for i in &input {
            if i.starts_with("--") {
                continue
            }
            // first - trying to detect db url
            if db.is_none() && feature.is_valid_url(i) {
                db = Some(i.to_string());
                continue
            }
            // second - trying to detect config table name
            if tbl.is_none() && is_sound_schema_table(i) {
                tbl = Some(i.to_string());
                continue
            }
            // third - trying to detect token script name
            if uuid.is_none() {
                uuid = Uuid::parse_str(i).ok();
                if uuid.is_some() {
                    continue
                }
            };
            // then - trying to detect token script
            if token.is_none() && i.len() > 1 {
                token = Some(i.to_string());
                continue
            }
            if ttl.is_none() && i.parse::<u16>().is_ok() {
                ttl = Some(i.to_string());
            }
        }
        if uuid.is_none() {
            uuid  = Uuid::parse_str(get_env_or_cfg(CMD_UUID, &cfg, "").as_str()).ok();
        }

        if let Some(a) = std::env::var_os(CMD_SECRET.to_uppercase()).map(|v| v) {
            secret = Some(a.to_str().map(|v| v.to_string()).unwrap_or("".to_string()));
            #[cfg(not(feature="keep_env_secret"))]
            unsafe { 
                std::env::remove_var(CMD_SECRET); 
            }
        }

        Ok(ArgConfig {
            uuid_gen: uuid.is_none(),
            uuid: uuid.unwrap_or(Uuid::new_v4()),
            db_url: link_db_user(db.unwrap_or(get_env_or_cfg(CMD_DB, &cfg, feature.default_url(&user).as_str())), user),
            table: tbl.unwrap_or(get_env_or_cfg(CMD_TBL, &cfg, get_exec_name("public.", input[0].as_str()).as_str())),
            token: token::Token::new(
                token.unwrap_or(get_env_or_cfg(CMD_TOKEN, &cfg, "")),
                ttl.unwrap_or(get_env_or_cfg(CMD_TTL, &cfg, "1")),
                pwd
            )?,
            pk: KeyFile::new(
                pk.unwrap_or(get_env_or_cfg(CMD_KEY, &cfg, &"")),
                std::env::var_os(CMD_PASS).map(|p| p.to_string_lossy().to_string()),
            )?,
            cfg,
            secret,
        })
    }

    /// append with password if $PWD present in 'db'
    pub fn db_url(&self) -> String {
        let url = self.db_url.clone();
        if let Some(i) = url.find(":$P") {
            if let Some(y) = url.find("@") {
                let pwd = url.as_str()[i+1..y].to_owned();
                return url.replace(
                    pwd.as_str(),
                    self.token.value.clone().unwrap_or("".into()).as_str()).to_string();
            }
        }
        url
    }
}

#[inline]
fn link_db_user(url: String, user: String) -> String {
    url.replace("$USER", user.as_str())
}

#[inline]
fn get_env_or_cfg(input: &str, cfg: &HashMap<String, String>, def: &str) -> String {
    match std::env::var_os(input).map(|v| v) {
        Some(a) => a.to_str().map(|v| v.to_string()).unwrap_or(def.to_string()),
        None => cfg.get(input).unwrap_or(&def.to_string()).into()
    }
}

/// Safe taking value
#[inline]
fn get_exec_name(schema: &str, input: &str) -> String {
    if input.is_empty() {
        return "".to_string();
    }
    let e = Path::new(input);
    let name = e.file_name().map(|f|f.to_str().unwrap_or("")).unwrap_or("").to_string();
    #[cfg(windows)]
    let name = name.replace(".exe", "");
    let name = if let Some(i) = name.rfind(std::path::MAIN_SEPARATOR_STR) {
        name[i+1..].to_string()
    } else {
        name
    };
    format!("{}{}", schema, name)
}

#[inline]
fn is_sound_schema_table(input: &str) -> bool {
    let y = input.contains(".");
    #[cfg(windows)]
    let y = y && !input.ends_with(".exe");
    y
}

#[inline]
fn load(file: &str, cfg: &mut HashMap<String, String>) -> Result<(), String> {
    let f = File::open(file).map_err(|e| e.to_string())?;
    let reader = BufReader::new(f);
    for line in reader.lines() {
        if let Ok(l) = line {
            if let Some(i) = l.chars().position(|c| c == '=' || c == ':' || c == '#' || c == ';' || c == '/' || c == '[') {
                if l.as_bytes()[i] != b'#' && l.as_bytes()[i] != b';' && l.as_bytes()[i] != b'/' && l.as_bytes()[i] != b'[' {
                    let key = l[..i].trim().to_lowercase();
                    let value = l[i + 1..].trim().to_string();
                    cfg.insert(key, value);
                }
            }
        }
    }
    Ok(())
}

#[allow(warnings)]
#[cfg(test)]
mod tests {
    use super::*;
    use uuid::Uuid;


    #[test]
    #[cfg(unix)]
    fn test_file_name() {
        assert_eq!(get_exec_name("","").as_str(), "");
        assert_eq!(get_exec_name("","target/debug/marg").as_str(), "marg");
        assert_eq!(get_exec_name("","marg").as_str(), "marg");
    }

    #[test]
    #[cfg(windows)]
    fn test_file_name() {
        assert_eq!(get_exec_name("","").as_str(), "");
        assert_eq!(get_exec_name("","target\\debug\\marg.exe").as_str(), "marg");
        assert_eq!(get_exec_name("","target\\\\debug\\\\marg.exe").as_str(), "marg");
    }

    #[test]
    fn config_args_file1_test() {
        //
        let cfg = ArgConfig::new(
            vec!["".to_string()],
                 SupportedDb::Postgres, "".to_string(), None).unwrap();
        assert_eq!(0, cfg.cfg.len());

    }

    #[test]
    fn config_args_file2_test() {
        let url =  "postgresql://user:pwd@host/db".to_string();
        let cfg = ArgConfig::new(
            vec![url.clone()],
                 SupportedDb::Postgres, "vk".to_string(), None).unwrap();
        assert_eq!(url, cfg.db_url());
    }

    #[test]
    fn config_args_file3_test() {
        let url =  "postgresql://user:pwd@host/db".to_string();
        let t = "public.table".to_string();
        let cfg = ArgConfig::new(
            vec![url.clone(), t.clone()],
                 SupportedDb::Postgres, "".to_string(), None).unwrap();
        assert_eq!(url, cfg.db_url());
        assert_eq!(t, cfg.table);
    }

    #[test]
    fn config_args_user_test() {
        let user = match std::env::var_os("USER") {
            Some(a) => a.to_str().unwrap_or("postgres").to_string(),
            _ => "postgres".to_string(),
        };

        assert_eq!(format!("postgresql://{}:pwd@host/db", user), link_db_user("postgresql://$USER:pwd@host/db".to_string(), user.clone()));
        assert_eq!(format!("postgresql://{}:$PWD@host/db", user), link_db_user("postgresql://$USER:$PWD@host/db".to_string(), user.clone()));
        assert_eq!(format!("postgresql://{}@host/db", user), link_db_user("postgresql://$USER@host/db".to_string(), user.clone()));
        assert_eq!(format!("postgresql://{}@host/db", ""), link_db_user("postgresql://@host/db".to_string(), user.clone()));
    }

    // uuid_gen is true when no UUID is provided
    #[test]
    fn uuid_gen_autogenerated_test() {
        let cfg = ArgConfig::new(
            vec!["".to_string()],
            SupportedDb::Postgres, "".to_string(), None).unwrap();
        assert!(cfg.uuid_gen);
    }

    // UUID detected positionally from a UUID-formatted string
    #[test]
    fn uuid_positional_test() {
        let id = "550e8400-e29b-41d4-a716-446655440000";
        let cfg = ArgConfig::new(
            vec!["".to_string(), id.to_string()],
            SupportedDb::Postgres, "".to_string(), None).unwrap();
        assert_eq!(Uuid::parse_str(id).unwrap(), cfg.uuid);
        assert!(!cfg.uuid_gen);
    }

    // --db sets db_url explicitly, overriding positional detection
    #[test]
    fn explicit_db_flag_test() {
        let url = "postgresql://user:pwd@host/db".to_string();
        let cfg = ArgConfig::new(
            vec!["".to_string(), "--db".to_string(), url.clone()],
            SupportedDb::Postgres, "".to_string(), None).unwrap();
        assert_eq!(url, cfg.db_url());
    }

    // --config sets table name explicitly
    #[test]
    fn explicit_config_flag_test() {
        let tbl = "myschema.mytable".to_string();
        let cfg = ArgConfig::new(
            vec!["".to_string(), "--config".to_string(), tbl.clone()],
            SupportedDb::Postgres, "".to_string(), None).unwrap();
        assert_eq!(tbl, cfg.table);
    }

    // --uuid sets uuid and uuid_gen = false
    #[test]
    fn explicit_uuid_flag_test() {
        let id = "550e8400-e29b-41d4-a716-446655440000";
        let cfg = ArgConfig::new(
            vec!["".to_string(), "--uuid".to_string(), id.to_string()],
            SupportedDb::Postgres, "".to_string(), None).unwrap();
        assert_eq!(Uuid::parse_str(id).unwrap(), cfg.uuid);
        assert!(!cfg.uuid_gen);
    }

    // --token sets token command
    #[test]
    fn explicit_token_flag_test() {
        let cfg = ArgConfig::new(
            vec!["".to_string(), "--token".to_string(), "get-token.sh".to_string()],
            SupportedDb::Postgres, "".to_string(), None).unwrap();
        assert_eq!("get-token.sh", cfg.token.cmd);
    }

    // --ttl sets token TTL in minutes
    #[test]
    fn explicit_ttl_flag_test() {
        let cfg = ArgConfig::new(
            vec!["".to_string(), "--ttl".to_string(), "30".to_string()],
            SupportedDb::Postgres, "".to_string(), None).unwrap();
        assert_eq!(30, cfg.token.min);
    }

    // --token and --ttl together
    #[test]
    fn explicit_token_ttl_combined_test() {
        let cfg = ArgConfig::new(
            vec!["".to_string(),
                 "--token".to_string(), "get-token.sh".to_string(),
                 "--ttl".to_string(), "15".to_string()],
            SupportedDb::Postgres, "".to_string(), None).unwrap();
        assert_eq!("get-token.sh", cfg.token.cmd);
        assert_eq!(15, cfg.token.min);
    }

    // --secret stores the AES cipher secret
    #[test]
    fn explicit_secret_flag_test() {
        let cfg = ArgConfig::new(
            vec!["".to_string(), "--secret".to_string(), "mysecret".to_string()],
            SupportedDb::Postgres, "".to_string(), None).unwrap();
        assert_eq!(Some("mysecret".to_string()), cfg.secret);
    }

    // token detected positionally as third unmatched arg (after db and table)
    #[test]
    fn positional_token_test() {
        let cfg = ArgConfig::new(
            vec!["".to_string(),
                 "postgresql://host/db".to_string(),
                 "myschema.table".to_string(),
                 "mytoken".to_string()],
            SupportedDb::Postgres, "".to_string(), None).unwrap();
        assert_eq!("mytoken", cfg.token.cmd);
    }

    // TTL detected positionally as numeric string once token slot is filled
    #[test]
    fn positional_ttl_test() {
        let cfg = ArgConfig::new(
            vec!["".to_string(),
                 "postgresql://host/db".to_string(),
                 "myschema.table".to_string(),
                 "mytoken".to_string(),
                 "45".to_string()],
            SupportedDb::Postgres, "".to_string(), None).unwrap();
        assert_eq!("mytoken", cfg.token.cmd);
        assert_eq!(45, cfg.token.min);
    }

    // db_url() resolves $PWD placeholder with the active token value
    #[test]
    fn db_url_pwd_substitution_test() {
        let url = "postgresql://user:$PWD@host/db".to_string();
        let cfg = ArgConfig::new(
            vec![url.clone()],
            SupportedDb::Postgres, "".to_string(), Some("secret123".to_string())).unwrap();
        assert_eq!("postgresql://user:secret123@host/db", cfg.db_url());
    }

    // db_url() returns URL unchanged when no $PWD placeholder is present
    #[test]
    fn db_url_no_pwd_placeholder_test() {
        let url = "postgresql://user:pwd@host/db".to_string();
        let cfg = ArgConfig::new(
            vec![url.clone()],
            SupportedDb::Postgres, "".to_string(), Some("secret123".to_string())).unwrap();
        assert_eq!(url, cfg.db_url());
    }

    // all five slots filled by positional detection in documented order
    #[test]
    fn all_positional_test() {
        let id = "550e8400-e29b-41d4-a716-446655440000";
        let cfg = ArgConfig::new(
            vec!["".to_string(),
                 "postgresql://host/db".to_string(),
                 "myschema.table".to_string(),
                 id.to_string(),
                 "mytoken".to_string(),
                 "20".to_string()],
            SupportedDb::Postgres, "".to_string(), None).unwrap();
        assert_eq!("postgresql://host/db", cfg.db_url);
        assert_eq!("myschema.table", cfg.table);
        assert_eq!(Uuid::parse_str(id).unwrap(), cfg.uuid);
        assert!(!cfg.uuid_gen);
        assert_eq!("mytoken", cfg.token.cmd);
        assert_eq!(20, cfg.token.min);
    }

    // all supported flags set explicitly
    #[test]
    fn all_explicit_flags_test() {
        let url = "postgresql://user:pwd@host/db".to_string();
        let tbl = "myschema.mytable".to_string();
        let id  = "550e8400-e29b-41d4-a716-446655440000";
        let cfg = ArgConfig::new(
            vec!["".to_string(),
                 "--db".to_string(),     url.clone(),
                 "--config".to_string(), tbl.clone(),
                 "--uuid".to_string(),   id.to_string(),
                 "--token".to_string(),  "get-token.sh".to_string(),
                 "--ttl".to_string(),    "15".to_string(),
                 "--secret".to_string(), "mysecret".to_string()],
            SupportedDb::Postgres, "".to_string(), None).unwrap();
        assert_eq!(url, cfg.db_url);
        assert_eq!(tbl, cfg.table);
        assert_eq!(Uuid::parse_str(id).unwrap(), cfg.uuid);
        assert!(!cfg.uuid_gen);
        assert_eq!("get-token.sh", cfg.token.cmd);
        assert_eq!(15, cfg.token.min);
        assert_eq!(Some("mysecret".to_string()), cfg.secret);
    }

    // SupportedDb::Custom accepts any string as a valid db URL
    #[test]
    fn custom_db_any_url_test() {
        let url = "somedb://connection-string".to_string();
        let cfg = ArgConfig::new(
            vec![url.clone()],
            SupportedDb::Custom, "".to_string(), None).unwrap();
        assert_eq!(url, cfg.db_url);
    }

    // default table name derives from executable name (input[0])
    #[test]
    fn default_table_from_appname_test() {
        let cfg = ArgConfig::new(
            vec!["myapp".to_string()],
            SupportedDb::Postgres, "".to_string(), None).unwrap();
        assert_eq!("public.myapp", cfg.table);
    }


}
