use crate::constants::PamResultCode;
use crate::module::PamResult;
use crate::pam::ModuleOptions;
use kanidm_unix_common::client_sync::DaemonClientBlocking;
use kanidm_unix_common::unix_config::PamNssConfig;
use kanidm_unix_common::unix_passwd::{
    read_etc_passwd_file, read_etc_shadow_file, EtcShadow, EtcUser,
};
use kanidm_unix_common::unix_proto::{ClientRequest, ClientResponse};
use kanidm_unix_common::unix_proto::{
    DeviceAuthorizationResponse, PamAuthRequest, PamAuthResponse, PamServiceInfo,
};
use std::time::Duration;
use time::OffsetDateTime;

use tracing::{debug, error};

#[cfg(test)]
use kanidm_unix_common::client_sync::UnixStream;

pub enum RequestOptions {
    Main {
        config_path: &'static str,
    },
    #[cfg(test)]
    Test {
        socket: Option<UnixStream>,
        users: Vec<EtcUser>,
        // groups: Vec<EtcGroup>,
        shadow: Vec<EtcShadow>,
    },
}

enum Source {
    Daemon(DaemonClientBlocking),
    Fallback {
        users: Vec<EtcUser>,
        // groups: Vec<EtcGroup>,
        shadow: Vec<EtcShadow>,
    },
}

impl RequestOptions {
    fn connect_to_daemon(self) -> Source {
        match self {
            RequestOptions::Main { config_path } => {
                let maybe_client = PamNssConfig::new()
                    .read_options_from_optional_config(config_path)
                    .ok()
                    .and_then(|cfg| {
                        DaemonClientBlocking::new(cfg.sock_path.as_str(), cfg.unix_sock_timeout)
                            .ok()
                    });

                if let Some(client) = maybe_client {
                    Source::Daemon(client)
                } else {
                    let users = read_etc_passwd_file("/etc/passwd").unwrap_or_default();
                    // let groups = read_etc_group_file("/etc/group").unwrap_or_default();
                    let shadow = read_etc_shadow_file("/etc/shadow").unwrap_or_default();
                    Source::Fallback {
                        users,
                        // groups,
                        shadow,
                    }
                }
            }
            #[cfg(test)]
            RequestOptions::Test {
                socket,
                users,
                // groups,
                shadow,
            } => {
                if let Some(socket) = socket {
                    Source::Daemon(DaemonClientBlocking::from(socket))
                } else {
                    Source::Fallback { users, shadow }
                }
            }
        }
    }
}

pub trait PamHandler {
    fn account_id(&self) -> PamResult<String>;

    fn service_info(&self) -> PamResult<PamServiceInfo>;

    fn authtok(&self) -> PamResult<Option<String>>;

    /// Display a message to the user.
    fn message(&self, prompt: &str) -> PamResult<()>;

    /// Display a device grant request to the user.
    fn message_device_grant(&self, data: &DeviceAuthorizationResponse) -> PamResult<()>;

    /// Request a password from the user.
    fn prompt_for_password(&self) -> PamResult<Option<String>>;

    fn prompt_for_pin(&self, msg: Option<&str>) -> PamResult<Option<String>>;

    fn prompt_for_mfacode(&self) -> PamResult<Option<String>>;
}

pub fn sm_authenticate_connected<P: PamHandler>(
    pamh: &P,
    opts: &ModuleOptions,
    _current_time: OffsetDateTime,
    mut daemon_client: DaemonClientBlocking,
) -> PamResultCode {
    let info = match pamh.service_info() {
        Ok(info) => info,
        Err(e) => {
            error!(err = ?e, "get_pam_info");
            return e;
        }
    };

    let account_id = match pamh.account_id() {
        Ok(acc) => acc,
        Err(err) => return err,
    };

    let mut timeout: Option<u64> = None;
    let mut active_polling_interval = Duration::from_secs(1);

    let mut stacked_authtok = if opts.use_first_pass {
        match pamh.authtok() {
            Ok(authtok) => authtok,
            Err(err) => return err,
        }
    } else {
        None
    };

    let mut req = ClientRequest::PamAuthenticateInit { account_id, info };

    loop {
        let client_response = match daemon_client.call_and_wait(&req, timeout) {
            Ok(r) => r,
            Err(err) => {
                // Something unrecoverable occurred, bail and stop everything
                error!(?err, "PAM_AUTH_ERR");
                return PamResultCode::PAM_AUTH_ERR;
            }
        };

        match client_response {
            ClientResponse::PamAuthenticateStepResponse(PamAuthResponse::Success) => {
                return PamResultCode::PAM_SUCCESS;
            }
            ClientResponse::PamAuthenticateStepResponse(PamAuthResponse::Denied) => {
                return PamResultCode::PAM_AUTH_ERR;
            }
            ClientResponse::PamAuthenticateStepResponse(PamAuthResponse::Unknown) => {
                if opts.ignore_unknown_user {
                    return PamResultCode::PAM_IGNORE;
                } else {
                    return PamResultCode::PAM_USER_UNKNOWN;
                }
            }
            ClientResponse::PamAuthenticateStepResponse(PamAuthResponse::Password) => {
                let mut authtok = None;
                std::mem::swap(&mut authtok, &mut stacked_authtok);

                let cred = if let Some(cred) = authtok {
                    cred
                } else {
                    match pamh.prompt_for_password() {
                        Ok(Some(cred)) => cred,
                        Ok(None) => return PamResultCode::PAM_CRED_INSUFFICIENT,
                        Err(err) => return err,
                    }
                };

                // Now setup the request for the next loop.
                timeout = None;
                req = ClientRequest::PamAuthenticateStep(PamAuthRequest::Password { cred });
                continue;
            }
            ClientResponse::PamAuthenticateStepResponse(
                PamAuthResponse::DeviceAuthorizationGrant { data },
            ) => {
                if let Err(err) = pamh.message_device_grant(&data) {
                    return err;
                };

                timeout = Some(u64::from(data.expires_in));
                req =
                    ClientRequest::PamAuthenticateStep(PamAuthRequest::DeviceAuthorizationGrant {
                        data,
                    });
                continue;
            }
            ClientResponse::PamAuthenticateStepResponse(PamAuthResponse::MFACode { msg: _ }) => {
                let cred = match pamh.prompt_for_mfacode() {
                    Ok(Some(cred)) => cred,
                    Ok(None) => return PamResultCode::PAM_CRED_INSUFFICIENT,
                    Err(err) => return err,
                };

                // Now setup the request for the next loop.
                timeout = None;
                req = ClientRequest::PamAuthenticateStep(PamAuthRequest::MFACode { cred });
                continue;
            }
            ClientResponse::PamAuthenticateStepResponse(PamAuthResponse::MFAPoll {
                msg,
                polling_interval,
            }) => {
                if let Err(err) = pamh.message(msg.as_str()) {
                    if opts.debug {
                        println!("Message prompt failed");
                    }
                    return err;
                }

                active_polling_interval = Duration::from_secs(polling_interval.into());

                timeout = None;
                req = ClientRequest::PamAuthenticateStep(PamAuthRequest::MFAPoll);
                // We don't need to actually sleep here as we immediately will poll and then go
                // into the MFAPollWait response below.
            }
            ClientResponse::PamAuthenticateStepResponse(PamAuthResponse::MFAPollWait) => {
                // Counter intuitive, but we don't need a max poll attempts here because
                // if the resolver goes away, then this will error on the sock and
                // will shutdown. This allows the resolver to dynamically extend the
                // timeout if needed, and removes logic from the front end.
                std::thread::sleep(active_polling_interval);
                timeout = None;
                req = ClientRequest::PamAuthenticateStep(PamAuthRequest::MFAPoll);
            }

            ClientResponse::PamAuthenticateStepResponse(PamAuthResponse::SetupPin { msg }) => {
                if let Err(err) = pamh.message(msg.as_str()) {
                    return err;
                }

                let mut pin;
                let mut confirm;

                loop {
                    pin = match pamh.prompt_for_pin(Some("New PIN: ")) {
                        Ok(Some(p)) => p,
                        Ok(None) => {
                            debug!("no pin");
                            return PamResultCode::PAM_CRED_INSUFFICIENT;
                        }
                        Err(err) => {
                            debug!("unable to get pin");
                            return err;
                        }
                    };

                    confirm = match pamh.prompt_for_pin(Some("Confirm PIN: ")) {
                        Ok(Some(p)) => p,
                        Ok(None) => {
                            debug!("no pin");
                            return PamResultCode::PAM_CRED_INSUFFICIENT;
                        }
                        Err(err) => {
                            debug!("unable to get pin");
                            return err;
                        }
                    };

                    if pin == confirm {
                        break;
                    } else if let Err(err) = pamh.message("Inputs did not match. Try again.") {
                        return err;
                    }
                }

                // Now setup the request for the next loop.
                timeout = None;
                req = ClientRequest::PamAuthenticateStep(PamAuthRequest::SetupPin { pin });
                continue;
            }
            ClientResponse::PamAuthenticateStepResponse(PamAuthResponse::Pin) => {
                let mut authtok = None;
                std::mem::swap(&mut authtok, &mut stacked_authtok);

                let cred = if let Some(cred) = authtok {
                    cred
                } else {
                    match pamh.prompt_for_pin(None) {
                        Ok(Some(cred)) => cred,
                        Ok(None) => return PamResultCode::PAM_CRED_INSUFFICIENT,
                        Err(err) => return err,
                    }
                };

                // Now setup the request for the next loop.
                timeout = None;
                req = ClientRequest::PamAuthenticateStep(PamAuthRequest::Pin { cred });
                continue;
            }

            ClientResponse::Error(err) => {
                error!("Error from kanidm-unixd: {}", err);
                return PamResultCode::PAM_AUTH_ERR;
            }
            ClientResponse::Ok
            | ClientResponse::SshKeys(_)
            | ClientResponse::NssAccounts(_)
            | ClientResponse::NssAccount(_)
            | ClientResponse::NssGroups(_)
            | ClientResponse::PamStatus(_)
            | ClientResponse::ProviderStatus(_)
            | ClientResponse::NssGroup(_) => {
                debug!("PamResultCode::PAM_AUTH_ERR");
                return PamResultCode::PAM_AUTH_ERR;
            }
        }
    } // while true, continue calling PamAuthenticateStep until we get a decision.
}

pub fn sm_authenticate_fallback<P: PamHandler>(
    pamh: &P,
    opts: &ModuleOptions,
    current_time: OffsetDateTime,
    users: Vec<EtcUser>,
    shadow: Vec<EtcShadow>,
) -> PamResultCode {
    let account_id = match pamh.account_id() {
        Ok(acc) => acc,
        Err(err) => return err,
    };

    let user = users.into_iter().find(|etcuser| etcuser.name == account_id);

    let shadow = shadow
        .into_iter()
        .find(|etcshadow| etcshadow.name == account_id);

    let (_user, shadow) = match (user, shadow) {
        (Some(user), Some(shadow)) => (user, shadow),
        _ => {
            if opts.ignore_unknown_user {
                debug!("PamResultCode::PAM_IGNORE");
                return PamResultCode::PAM_IGNORE;
            } else {
                debug!("PamResultCode::PAM_USER_UNKNOWN");
                return PamResultCode::PAM_USER_UNKNOWN;
            }
        }
    };

    let expiration_date = shadow
        .epoch_expire_date
        .map(|expire| OffsetDateTime::UNIX_EPOCH + time::Duration::days(expire));

    if let Some(expire) = expiration_date {
        if current_time >= expire {
            debug!("PamResultCode::PAM_ACCT_EXPIRED");
            return PamResultCode::PAM_ACCT_EXPIRED;
        }
    };

    // All checks passed! We can now proceed to authenticate the account.
    let mut stacked_authtok = if opts.use_first_pass {
        match pamh.authtok() {
            Ok(authtok) => authtok,
            Err(err) => return err,
        }
    } else {
        None
    };

    let mut authtok = None;
    std::mem::swap(&mut authtok, &mut stacked_authtok);

    let cred = if let Some(cred) = authtok {
        cred
    } else {
        match pamh.prompt_for_password() {
            Ok(Some(cred)) => cred,
            Ok(None) => return PamResultCode::PAM_CRED_INSUFFICIENT,
            Err(err) => return err,
        }
    };

    if shadow.password.check_pw(cred.as_str()) {
        PamResultCode::PAM_SUCCESS
    } else {
        PamResultCode::PAM_AUTH_ERR
    }
}

pub fn sm_authenticate<P: PamHandler>(
    pamh: &P,
    opts: &ModuleOptions,
    req_opt: RequestOptions,
    current_time: OffsetDateTime,
) -> PamResultCode {
    match req_opt.connect_to_daemon() {
        Source::Daemon(daemon_client) => {
            sm_authenticate_connected(pamh, opts, current_time, daemon_client)
        }
        Source::Fallback { users, shadow } => {
            sm_authenticate_fallback(pamh, opts, current_time, users, shadow)
        }
    }
}

pub fn acct_mgmt<P: PamHandler>(
    pamh: &P,
    opts: &ModuleOptions,
    req_opt: RequestOptions,
    current_time: OffsetDateTime,
) -> PamResultCode {
    let account_id = match pamh.account_id() {
        Ok(acc) => acc,
        Err(err) => return err,
    };

    match req_opt.connect_to_daemon() {
        Source::Daemon(mut daemon_client) => {
            let req = ClientRequest::PamAccountAllowed(account_id);
            match daemon_client.call_and_wait(&req, None) {
                Ok(r) => match r {
                    ClientResponse::PamStatus(Some(true)) => {
                        debug!("PamResultCode::PAM_SUCCESS");
                        PamResultCode::PAM_SUCCESS
                    }
                    ClientResponse::PamStatus(Some(false)) => {
                        debug!("PamResultCode::PAM_AUTH_ERR");
                        PamResultCode::PAM_AUTH_ERR
                    }
                    ClientResponse::PamStatus(None) => {
                        if opts.ignore_unknown_user {
                            debug!("PamResultCode::PAM_IGNORE");
                            PamResultCode::PAM_IGNORE
                        } else {
                            debug!("PamResultCode::PAM_USER_UNKNOWN");
                            PamResultCode::PAM_USER_UNKNOWN
                        }
                    }
                    _ => {
                        // unexpected response.
                        error!(err = ?r, "PAM_IGNORE, unexpected resolver response");
                        PamResultCode::PAM_IGNORE
                    }
                },
                Err(e) => {
                    error!(err = ?e, "PamResultCode::PAM_IGNORE");
                    PamResultCode::PAM_IGNORE
                }
            }
        }
        Source::Fallback { users, shadow } => {
            let user = users.into_iter().find(|etcuser| etcuser.name == account_id);

            let shadow = shadow
                .into_iter()
                .find(|etcshadow| etcshadow.name == account_id);

            let (_user, shadow) = match (user, shadow) {
                (Some(user), Some(shadow)) => (user, shadow),
                _ => {
                    if opts.ignore_unknown_user {
                        debug!("PamResultCode::PAM_IGNORE");
                        return PamResultCode::PAM_IGNORE;
                    } else {
                        debug!("PamResultCode::PAM_USER_UNKNOWN");
                        return PamResultCode::PAM_USER_UNKNOWN;
                    }
                }
            };

            let expiration_date = shadow
                .epoch_expire_date
                .map(|expire| OffsetDateTime::UNIX_EPOCH + time::Duration::days(expire));

            if let Some(expire) = expiration_date {
                if current_time >= expire {
                    debug!("PamResultCode::PAM_ACCT_EXPIRED");
                    return PamResultCode::PAM_ACCT_EXPIRED;
                }
            };

            // All checks passed!

            debug!("PAM_SUCCESS");
            PamResultCode::PAM_SUCCESS
        }
    }
}

pub fn sm_open_session<P: PamHandler>(
    pamh: &P,
    _opts: &ModuleOptions,
    req_opt: RequestOptions,
) -> PamResultCode {
    let account_id = match pamh.account_id() {
        Ok(acc) => acc,
        Err(err) => return err,
    };

    match req_opt.connect_to_daemon() {
        Source::Daemon(mut daemon_client) => {
            let req = ClientRequest::PamAccountBeginSession(account_id);

            match daemon_client.call_and_wait(&req, None) {
                Ok(ClientResponse::Ok) => {
                    debug!("PAM_SUCCESS");
                    PamResultCode::PAM_SUCCESS
                }
                other => {
                    debug!(err = ?other, "PAM_IGNORE");
                    PamResultCode::PAM_IGNORE
                }
            }
        }
        Source::Fallback {
            users: _,
            shadow: _,
        } => {
            debug!("PAM_SUCCESS");
            PamResultCode::PAM_SUCCESS
        }
    }
}

pub fn sm_close_session<P: PamHandler>(_pamh: &P, _opts: &ModuleOptions) -> PamResultCode {
    PamResultCode::PAM_SUCCESS
}

pub fn sm_chauthtok<P: PamHandler>(_pamh: &P, _opts: &ModuleOptions) -> PamResultCode {
    PamResultCode::PAM_IGNORE
}

pub fn sm_setcred<P: PamHandler>(_pamh: &P, _opts: &ModuleOptions) -> PamResultCode {
    PamResultCode::PAM_SUCCESS
}