/* Copyright 2021 Dominik George <dominik.george@teckids.org>
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

use crate::config::{
    get_optional
};

use config::Config;

use oauth2::{
    AuthUrl,
    ClientId,
    ClientSecret,
    RequestTokenError,
    ResourceOwnerUsername,
    ResourceOwnerPassword,
    Scope,
    TokenResponse,
    TokenUrl
};
use oauth2::basic::{
    BasicClient,
    BasicTokenResponse
};
use oauth2::reqwest::http_client;

use std::error;

use serde::{Deserialize, Serialize};
use reqwest;

use serde_json;
use jq_rs;

/// Construct a configuration key from parts
///
/// Simply joins all elements of the passed array with a '.'
fn full_key(parts: Vec<&str>) -> String {
    parts.join(".")
}

/// Construct an OAuth2 client ready to exchange credentials
///
/// Arguments
/// ---------
///
/// `conf` - reference to the config object
/// `prefix` - current prefix (probably `nss` or `pam`)
/// `error_value` - The value to return as Err result on error
fn get_client<E: Copy>(conf: &Config, prefix: &str, error_value: E) -> Result<BasicClient, E> {
    // Get all the configuration parameters
    let client_id = ClientId::new(get_optional(&conf, &full_key(vec![prefix, "client_id"])).ok_or(error_value)?);
    let client_secret = match get_optional(&conf, &full_key(vec![prefix, "client_secret"])) {
        Some(v) => Some(ClientSecret::new(v)),
        None => None,
    };
    let auth_url = match AuthUrl::new(get_optional(&conf, &full_key(vec![prefix, "auth_url"])).ok_or(error_value)?) {
        Ok(u) => u,
        _ => {
            error!("Could not parse authorization URL");
            return Err(error_value);
        },
    };
    let token_url = match get_optional(&conf, &full_key(vec![prefix, "token_url"])) {
        Some(v) => match TokenUrl::new(v) {
            Ok(u) => Some(u),
            Err(_) => {
                error!("Could not parse token URL");
                return Err(error_value);
            }
        },
        None => None,
    };

    // Construct an OAuth 2 client ready to exchange credentials
    let client = BasicClient::new(client_id, client_secret, auth_url, token_url);
    return Ok(client);
}

pub fn get_access_token_client<E: Copy>(conf: &Config, prefix: &str, error_value: E, unauth_value: E) -> Result<BasicTokenResponse, E> {
    let scopes: Vec<String> = match get_optional(&conf, &full_key(vec![prefix, "scopes"])) {
        Some(v) => v,
        None => vec![]
    };

    let client = get_client(conf, prefix, error_value)?;
    let mut request = client.exchange_client_credentials();
    for scope in scopes {
        request = request.add_scope(Scope::new(scope.to_string()));
    }

    let result = request.request(http_client);
    match result {
            Ok(t) => Ok(t),
            Err(e) => match e {
                RequestTokenError::ServerResponse(t) => {
                    error!("Authorization server returned error: {}", t);
                    return Err(unauth_value);
                },
                _ => {
                    error!("Error fetchin access token: {}", e);
                    return Err(error_value);
                },
            },
        }
}

pub fn get_access_token_password<E: Copy>(conf: &Config, prefix: &str, username: String, password: String, error_value: E, unauth_value: E) -> Result<BasicTokenResponse, E> {
    let scopes: Vec<String> = match get_optional(&conf, &full_key(vec![prefix, "scopes"])) {
        Some(v) => v,
        None => vec![]
    };

    let res_username = ResourceOwnerUsername::new(username);
    let res_password = ResourceOwnerPassword::new(password);

    let client = get_client(conf, prefix, error_value)?;
    let mut request = client.exchange_password(&res_username, &res_password);
    for scope in scopes {
        request = request.add_scope(Scope::new(scope.to_string()));
    }

    let result = request.request(http_client);
    match result {
            Ok(t) => Ok(t),
            Err(e) => match e {
                RequestTokenError::ServerResponse(t) => {
                    error!("Authorization server returned error: {}", t);
                    return Err(unauth_value);
                },
                _ => {
                    error!("Error fetchin access token: {}", e);
                    return Err(error_value);
                },
            },
        }
}

/// Retrieve text data from a configured URL endpoint
///
/// Takes the same arguments as `get_data_jq`.
fn get_data(conf: &Config, prefix: &str, endpoint: &str, param: String, token: &BasicTokenResponse) -> Result<String, Box<dyn error::Error>> {
    /// Extract token as string from deserialized access token
    let access_token = token.access_token().secret();
    let token_type = "Bearer".to_string();  // FIXME Probably we need to handle other token types

    // Retreieve endpoint URL from configuration
    let mut endpoint_url: String = get_optional(&conf, &full_key(vec![prefix, "urls", endpoint])).ok_or("")?;
    endpoint_url = endpoint_url.replace("{}", &param);
    debug!("Loading text data from {}", endpoint_url);

    // Send off request to server
    let client = reqwest::blocking::Client::new();
    Ok(client
        .get(&endpoint_url)
        .header(reqwest::header::AUTHORIZATION, format!("{} {}", token_type, access_token))
        .send()?
        .text()?)
}

/// Retrieve a jq program from the config
///
/// Arguments
/// ---------
///
/// `conf` - reference to the config object
/// `prefix` - current prefix (probably `nss` or `pam`)
/// `endpoint` - name of the URL endpoint operating on, e.g. `passwd.list`
/// `multi` - `true` if the nndpoint will return an array
/// `rev` - `true` if looking for the reverse mapping program (see `get_data_jq`)
///
/// The endpoint will be truncated starting from the right if no program is found under
/// the config key, so that a single jq programm can be configured for `passwd` instead of
/// three separate programs for `passwd.list`, `passwd.by_uid` and `passwd.by_name`.
///
/// Lookup of the program in the config is done through the general config fetching mechanism
/// (see `get_optional`).
///
/// The programm will be looked up unter `<prefix>.maps`. If `rev` is true, the programm will
/// be looked up under `maps.rev` instead of `maps`.
///
/// If `multi` is `true`, the resulting program will be wrapped into jq's `map()` function
/// to be applied to an array of objects.
fn get_jq_prog(conf: &Config, prefix: &str, endpoint: &str, multi: bool, rev: bool) -> jq_rs::Result<jq_rs::JqProgram>  {
    // Generate config key to find jq program under
    let prog_key = match rev {
        false => full_key(vec![prefix, "maps", endpoint]),
        true => full_key(vec![prefix, "maps.rev", endpoint]),
    };

    // Retrieve jq program code from config
    let mut jq_code: String = match get_optional(&conf, &prog_key) {
        // We got the value immediately
        Some(v) => v,
        None => {
            if rev {
                // Do not fallback to more generic program for reverse mapping
                ".".to_string()
            } else {
                // Try falling back to more generic program
                match endpoint.find('.') {
                    Some(i) => {
                        debug!("JQ mapping program for {} not found; trying more generic definition", endpoint);
                        // Call recursively (and return verbatim, as we get a compiled program)
                        return get_jq_prog(conf, prefix, &endpoint[..i], multi, rev)
                    }
                    // Lookup failed ultimately; fallback to jq "identity" (no-op)
                    None => ".".to_string()
                }
            }
        }
    };

    // If multi mode is passed, the program will be applied to an array of entries
    // We thus wrap it in a call to jq's `map()`
    if multi {
        jq_code = "map(".to_string() + &jq_code + ")";
    }

    debug!("Compiling JQ program for endpoint {}: {}", endpoint, jq_code);
    jq_rs::compile(&jq_code)
}

/// Retrieve JSON data from a configured endpoint, transforming using configured jq programs
///
/// This function takes a prefix (probably `nss` or `pam`, and an endpoint name to lookup
/// information in the passed configuration. It then uses this information and the passed
/// parameter to construct a URL to retrieve data from the HTTP API.
///
/// Transformation occurs both for the passed parameter and for the retrieved data.
/// Consider the following scenario:
///
/// The configured backend server provides three URLs for doing NSS-like lookups:
///
///  * `https://example.com/users/` - get a JSON array of all users
///  * `https://example.com/user/1234/` - get a single JSON object for a user with a numeric user ID
///  * `https://example.com/user/janedoe/` - get a single JSON object for a user with a user name
///
/// Any user object returned by the server looks like this:
///
/// ```json
/// {
///   "username": "janedoe",
///   "uid": 1234,
///   "primary_gid": 100,
///   "home_directory": "/home/janedoe",
///   "login_shell": "/bin/bash",
///   "full_name": "Doe, Jane"
/// }
/// ```
///
/// To integrate the information in our system, which also has other user sources,
/// we need to ensure the following:
///
///  * Numeric user IDs need to be mapped starting at 10000
///  * Home directories need to be re-mapped to /srv/remote_users
///  * The full name (GECOS) shall be suffixed with "(Remote)" for clarity
///  * We need to set a static password of "x" because the API (correctly) does not serve passwords
///  * All field names need to be renamed to the fields of the passwd struct
///
/// To accomplish this, we will configure two jq programs:
///
/// ```toml
/// nss.maps.passwd = """
///     {
///         name: .username,
///         # No passwords in passwd
///         passwd: "x",
///         # Map user and group IDs starting at 10000
///         uid: (.uid + 10000),
///         gid: (.primary_gid + 10000),
///         # Append organisation name to Gecos field
///         gecos: (.full_name + " (Remote)"),
///         # Remap /home from server to /srv/teckids locally
///         dir: ("/srv/remote_users/" + (.home_directory|ltrimstr("/home/"))),
///         shell: .login_shell
///     }
/// """
///
/// # Reverse mapping to make sure uid lookups on entries mapped above still work
/// nss.maps.rev.passwd.by_uid = ". - 10000"
/// ```
///
/// The first program maps result objects into new JSON objects with the rules described
/// inline. The `map()` function from jq that is needed to apply the program to every
/// object in an array will be added automatically if a list is retrieved (`multi` is `true`).
///
/// The second program is used when a single user is queried by their numeric user ID, and it
/// reverses the transformation done to the uid field so the lookup on the server gets the
/// expected user ID.
///
/// Along with the following URL configuration, this brings everything into place:
///
/// ```toml
/// nss.urls.passwd.list = "https://example.com/users/"
/// nss.urls.passwd.by_uid = "https://example.com/users/{}/"
/// nss.urls.passwd.by_name = "https://example.com/users/{}/"
/// ```
///
/// In the second and thrid URL, the placeholder `{}` will be replaced with the lookup key.
pub fn get_data_jq<T: for<'de> Deserialize<'de>, V: Serialize>(conf: &Config, prefix: &str, endpoint: &str, param: V, token: &BasicTokenResponse, multi: bool) -> Result<T, Box<dyn error::Error>> {
    // Get jq mapping programs for forward and reverse mappings
    let mut jq_prog_fwd = get_jq_prog(&conf, prefix, endpoint, multi, false)?;
    let mut jq_prog_rev = get_jq_prog(&conf, prefix, endpoint, false, true)?;

    // Convert and transform the passed param using the reverse JQ mapping program
    //  1. Serialize into JSON value (atomic) to be bale to pass into jq
    let param = serde_json::to_string(&param)?;
    //  2. Transform using the JQ program loaded above
    let param = jq_prog_rev.run(&param)?.trim().to_string();
    //  3. Deserialize into serde_json value so we get numbers as numbers, strings properly unquoted
    let param: serde_json::Value = serde_json::from_str(&param)?;
    let param = match param {
        serde_json::Value::String(v) => v,   // We want strings verbatim without JSON quoting
        _ => param.to_string()  // We want numbers converted to string
    };

    // Retrieve data via HTTP and transform using jq forward mapping program
    let data = get_data(&conf, prefix, endpoint, param, token)?;
    let data = jq_prog_fwd.run(&data)?;

    // Deserialize transformed JSON and return
    Ok(serde_json::from_str(&data)?)
}