use common_enums::{BankHolderType, BankType};
use common_utils::{pii::Email, types::FloatMajorUnit};

use domain_types::{
    connector_types::{
        ClientAuthenticationTokenData, ClientAuthenticationTokenRequestData, GetPaymentMethodData,
        GetPaymentMethodResponseData, PaymentFlowData, PaymentMethodTokenResponse,
        PaymentMethodTokenizationData, PaymentsResponseData, PlaidClientAuthenticationResponse,
    },
    errors::{ConnectorError, IntegrationError, IntegrationErrorContext},
    merchant_authentication_flow_data::MerchantAuthenticationFlowData,
    payment_method_data::{
        BankAccount, BankAccountAchDetails, BankAccountBacsDetails, BankAccountDetails,
        BankAccountRoutingDetails, BankAccountSepaDetails, PaymentMethodDataTypes,
        PaymentMethodDetails,
    },
    router_data::ConnectorSpecificConfig,
    router_data_v2::RouterDataV2,
};
use error_stack::report;
use hyperswitch_masking::{ExposeInterface, PeekInterface, Secret};
use serde::{Deserialize, Serialize};

use crate::types::ResponseRouterData;

// PlaidRouterData is generated by create_all_prerequisites! in the parent plaid.rs module
use super::PlaidRouterData;

const PLAID_SUBTYPE_CHECKING: &str = "checking";
const PLAID_SUBTYPE_SAVINGS: &str = "savings";
// =============================================================================
// AUTH TYPE
// =============================================================================

#[derive(Debug)]
pub struct PlaidAuthType {
    pub client_id: Secret<String>,
    pub secret: Secret<String>,
    pub client_name: Option<String>,
}

impl TryFrom<&ConnectorSpecificConfig> for PlaidAuthType {
    type Error = error_stack::Report<IntegrationError>;

    fn try_from(config: &ConnectorSpecificConfig) -> Result<Self, Self::Error> {
        match config {
            ConnectorSpecificConfig::Plaid {
                client_id,
                secret,
                client_name,
                ..
            } => Ok(Self {
                client_id: client_id.clone(),
                secret: secret.clone(),
                client_name: client_name.clone(),
            }),
            _ => Err(report!(IntegrationError::FailedToObtainAuthType {
                context: IntegrationErrorContext {
                    additional_context: Some("Expected ConnectorSpecificConfig::Plaid".to_owned()),
                    suggested_action: Some(
                        "Ensure the connector config provides Plaid client_id and secret"
                            .to_owned(),
                    ),
                    doc_url: Some("https://plaid.com/docs/api/".to_owned()),
                }
            })),
        }
    }
}

// =============================================================================
// FLOW 1: /link/token/create
// =============================================================================

#[derive(Debug, Serialize)]
pub struct PlaidLinkTokenRequest {
    pub client_id: Secret<String>,
    pub secret: Secret<String>,
    pub client_name: String,
    pub user: PlaidUser,
    pub products: Vec<String>,
    pub country_codes: Vec<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub language: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub redirect_uri: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub webhook: Option<String>,
}

#[derive(Debug, Serialize)]
pub struct PlaidUser {
    pub client_user_id: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub legal_name: Option<Secret<String>>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub email_address: Option<Email>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub phone_number: Option<Secret<String>>,
}

impl<T: PaymentMethodDataTypes + std::fmt::Debug + Sync + Send + 'static + Serialize>
    TryFrom<
        PlaidRouterData<
            RouterDataV2<
                domain_types::connector_flow::ClientAuthenticationToken,
                MerchantAuthenticationFlowData,
                ClientAuthenticationTokenRequestData,
                PaymentsResponseData,
            >,
            T,
        >,
    > for PlaidLinkTokenRequest
{
    type Error = error_stack::Report<IntegrationError>;

    fn try_from(
        item: PlaidRouterData<
            RouterDataV2<
                domain_types::connector_flow::ClientAuthenticationToken,
                MerchantAuthenticationFlowData,
                ClientAuthenticationTokenRequestData,
                PaymentsResponseData,
            >,
            T,
        >,
    ) -> Result<Self, Self::Error> {
        let auth = PlaidAuthType::try_from(&item.router_data.connector_config)?;
        let req = &item.router_data.request;

        let client_name = auth.client_name.ok_or_else(|| {
            report!(IntegrationError::MissingRequiredField {
                field_name: "client_name",
                context: IntegrationErrorContext {
                    additional_context: Some(
                        "client_name must be set in the Plaid connector config".to_owned(),
                    ),
                    suggested_action: Some(
                        "Set client_name in the Plaid connector config".to_owned(),
                    ),
                    doc_url: Some(
                        "https://plaid.com/docs/api/tokens/#linktokencreate-client_name".to_owned(),
                    ),
                },
            })
        })?;

        let customer = req.customer.as_ref().ok_or_else(|| {
            report!(IntegrationError::MissingRequiredField {
                field_name: "customer",
                context: IntegrationErrorContext {
                    additional_context: Some(
                        "customer is required for Plaid Link token creation".to_owned()
                    ),
                    suggested_action: Some(
                        "Provide customer details including customer_id in the request".to_owned()
                    ),
                    doc_url: Some(
                        "https://plaid.com/docs/api/tokens/#linktokencreate-user".to_owned()
                    ),
                },
            })
        })?;

        let user = PlaidUser {
            client_user_id: customer.get_customer_id()?.get_string_repr().to_owned(),
            legal_name: customer.customer_name.clone(),
            email_address: customer.customer_email.clone(),
            phone_number: customer.customer_phone_number.clone(),
        };

        let country_codes = req
            .country_codes
            .iter()
            .map(|country_code| country_code.to_string())
            .collect::<Vec<_>>();

        if country_codes.is_empty() {
            return Err(report!(IntegrationError::MissingRequiredField {
                field_name: "country_codes",
                context: IntegrationErrorContext {
                    additional_context: Some(
                        "Plaid /link/token/create requires at least one country code".to_owned(),
                    ),
                    suggested_action: Some(
                        "Set country_codes in the Authenticator domain context, e.g. [\"US\"]"
                            .to_owned(),
                    ),
                    doc_url: Some(
                        "https://plaid.com/docs/api/tokens/#linktokencreate-country-codes"
                            .to_owned(),
                    ),
                },
            }));
        }

        Ok(Self {
            client_id: auth.client_id,
            secret: auth.secret,
            client_name,
            user,
            products: vec!["auth".to_owned()],
            country_codes,
            language: req.locale.clone(),
            redirect_uri: item.router_data.resource_common_data.return_url.clone(),
            webhook: req.webhook_url.clone(),
        })
    }
}

#[derive(Debug, Deserialize, Serialize)]
pub struct PlaidLinkTokenResponse {
    pub link_token: Secret<String>,
    pub expiration: Option<String>,
    pub hosted_link_url: Option<String>,
    pub request_id: String,
}

impl TryFrom<ResponseRouterData<PlaidLinkTokenResponse, Self>>
    for RouterDataV2<
        domain_types::connector_flow::ClientAuthenticationToken,
        MerchantAuthenticationFlowData,
        ClientAuthenticationTokenRequestData,
        PaymentsResponseData,
    >
{
    type Error = error_stack::Report<ConnectorError>;

    fn try_from(
        item: ResponseRouterData<PlaidLinkTokenResponse, Self>,
    ) -> Result<Self, Self::Error> {
        let res = item.response;

        let expires_in_seconds: Option<i64> = res.expiration.as_deref().and_then(|exp| {
            time::OffsetDateTime::parse(exp, &time::format_description::well_known::Rfc3339)
                .inspect_err(|e| {
                    tracing::debug!(
                        error = %e,
                        raw_expiration = %exp,
                        "Plaid link_token expiration is not RFC3339; expires_in_seconds will be omitted"
                    );
                })
                .ok()
                .and_then(|expiry_time| {
                    let secs = (expiry_time - time::OffsetDateTime::now_utc()).whole_seconds();
                    if secs > 0 { Some(secs) } else { None }
                })
        });

        let plaid_auth_response = PlaidClientAuthenticationResponse {
            link_token: res.link_token,
            expires_in_seconds,
            hosted_link_url: res.hosted_link_url,
        };

        let session_data = ClientAuthenticationTokenData::Plaid(Box::new(plaid_auth_response));

        Ok(Self {
            response: Ok(PaymentsResponseData::ClientAuthenticationTokenResponse {
                session_data,
                status_code: item.http_code,
            }),
            ..item.router_data
        })
    }
}

// =============================================================================
// FLOW 2: /item/public_token/exchange
// =============================================================================

#[derive(Debug, Serialize)]
pub struct PlaidPublicTokenExchangeRequest {
    pub client_id: Secret<String>,
    pub secret: Secret<String>,
    pub public_token: Secret<String>,
}

impl<T: PaymentMethodDataTypes + std::fmt::Debug + Sync + Send + 'static + Serialize>
    TryFrom<
        PlaidRouterData<
            RouterDataV2<
                domain_types::connector_flow::PaymentMethodToken,
                PaymentFlowData,
                PaymentMethodTokenizationData<T>,
                PaymentMethodTokenResponse,
            >,
            T,
        >,
    > for PlaidPublicTokenExchangeRequest
{
    type Error = error_stack::Report<IntegrationError>;

    fn try_from(
        item: PlaidRouterData<
            RouterDataV2<
                domain_types::connector_flow::PaymentMethodToken,
                PaymentFlowData,
                PaymentMethodTokenizationData<T>,
                PaymentMethodTokenResponse,
            >,
            T,
        >,
    ) -> Result<Self, Self::Error> {
        let auth = PlaidAuthType::try_from(&item.router_data.connector_config)?;

        let public_token = item
            .router_data
            .request
            .metadata
            .as_ref()
            .and_then(|metadata_str| {
                serde_json::from_str::<serde_json::Value>(metadata_str.peek()).ok()
            })
            .and_then(|metadata_json| {
                metadata_json
                    .get("public_token")
                    .and_then(|val| val.as_str())
                    .map(|s| Secret::new(s.to_owned()))
            })
            .ok_or_else(|| {
                report!(IntegrationError::MissingRequiredField {
                    field_name: "metadata.public_token",
                    context: IntegrationErrorContext {
                        additional_context: Some(
                            "metadata must be a JSON object containing a \"public_token\" field"
                                .to_owned(),
                        ),
                        suggested_action: Some(
                            "Pass metadata as JSON: {\"public_token\": \"<token from Plaid Link\"}"
                                .to_owned(),
                        ),
                        doc_url: Some(
                            "https://plaid.com/docs/api/items/#itempublic_tokenexchange".to_owned(),
                        ),
                    },
                })
            })?;

        Ok(Self {
            client_id: auth.client_id,
            secret: auth.secret,
            public_token,
        })
    }
}

#[derive(Debug, Deserialize, Serialize)]
pub struct PlaidPublicTokenExchangeResponse {
    pub access_token: Secret<String>,
    pub item_id: String,
    pub request_id: String,
}

impl<T: PaymentMethodDataTypes + std::fmt::Debug + Sync + Send + 'static>
    TryFrom<ResponseRouterData<PlaidPublicTokenExchangeResponse, Self>>
    for RouterDataV2<
        domain_types::connector_flow::PaymentMethodToken,
        PaymentFlowData,
        PaymentMethodTokenizationData<T>,
        PaymentMethodTokenResponse,
    >
{
    type Error = error_stack::Report<ConnectorError>;

    fn try_from(
        item: ResponseRouterData<PlaidPublicTokenExchangeResponse, Self>,
    ) -> Result<Self, Self::Error> {
        let res = item.response;
        Ok(Self {
            response: Ok(PaymentMethodTokenResponse {
                token: res.access_token.expose(),
                connector_payment_method_id: Some(res.item_id),
                status_code: item.http_code,
            }),
            ..item.router_data
        })
    }
}

// =============================================================================
// FLOW 3: /auth/get
// =============================================================================

#[derive(Debug, Serialize)]
pub struct PlaidAuthGetRequest {
    pub client_id: Secret<String>,
    pub secret: Secret<String>,
    pub access_token: Secret<String>,
}

impl<T: PaymentMethodDataTypes + std::fmt::Debug + Sync + Send + 'static + Serialize>
    TryFrom<
        PlaidRouterData<
            RouterDataV2<
                domain_types::connector_flow::GetPaymentMethod,
                PaymentFlowData,
                GetPaymentMethodData,
                GetPaymentMethodResponseData,
            >,
            T,
        >,
    > for PlaidAuthGetRequest
{
    type Error = error_stack::Report<IntegrationError>;

    fn try_from(
        item: PlaidRouterData<
            RouterDataV2<
                domain_types::connector_flow::GetPaymentMethod,
                PaymentFlowData,
                GetPaymentMethodData,
                GetPaymentMethodResponseData,
            >,
            T,
        >,
    ) -> Result<Self, Self::Error> {
        let auth = PlaidAuthType::try_from(&item.router_data.connector_config)?;

        let access_token = item
            .router_data
            .request
            .payment_method_token
            .ok_or_else(|| {
                report!(IntegrationError::MissingRequiredField {
                    field_name: "payment_method_token (access_token)",
                    context: IntegrationErrorContext {
                        additional_context: Some(
                            "Plaid /auth/get requires the access_token as payment_method_token"
                                .to_owned(),
                        ),
                        suggested_action: Some(
                            "Pass the Plaid access_token (obtained from token exchange) as payment_method_token"
                                .to_owned(),
                        ),
                        doc_url: Some(
                            "https://plaid.com/docs/api/products/auth/#authget".to_owned(),
                        ),
                    },
                })
            })?;

        Ok(Self {
            client_id: auth.client_id,
            secret: auth.secret,
            access_token,
        })
    }
}

#[derive(Debug, Deserialize, Serialize)]
pub struct PlaidAuthGetResponse {
    pub accounts: Vec<PlaidAccount>,
    pub numbers: PlaidNumbers,
    pub item: PlaidItem,
    pub request_id: String,
}

#[derive(Debug, Deserialize, Serialize)]
pub struct PlaidItem {
    pub item_id: String,
    pub institution_name: Option<String>,
}

#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum PlaidHolderCategory {
    Personal,
    Business,
}

#[derive(Debug, Deserialize, Serialize)]
pub struct PlaidAccount {
    pub account_id: Secret<String>,
    pub name: Secret<String>,
    pub subtype: Option<String>,
    pub holder_category: Option<PlaidHolderCategory>,
    pub balances: PlaidBalances,
}

#[derive(Debug, Deserialize, Serialize)]
pub struct PlaidBalances {
    pub current: Option<FloatMajorUnit>,
    pub available: Option<FloatMajorUnit>,
    pub iso_currency_code: Option<common_enums::Currency>,
}

#[derive(Debug, Default, Deserialize, Serialize)]
#[serde(default)]
pub struct PlaidNumbers {
    pub ach: Vec<PlaidAchNumbers>,
    pub eft: Vec<PlaidEftNumbers>,
    pub bacs: Vec<PlaidBacsNumbers>,
    pub international: Vec<PlaidInternationalNumbers>,
}

#[derive(Debug, Deserialize, Serialize)]
pub struct PlaidAchNumbers {
    pub account_id: Secret<String>,
    pub account: Secret<String>,
    pub routing: Secret<String>,
}

#[derive(Debug, Deserialize, Serialize)]
pub struct PlaidEftNumbers {
    pub account_id: Secret<String>,
    pub account: Secret<String>,
    pub routing: Secret<String>,
    pub institution: Secret<String>,
}

#[derive(Debug, Deserialize, Serialize)]
pub struct PlaidBacsNumbers {
    pub account_id: Secret<String>,
    pub account: Secret<String>,
    pub sort_code: Secret<String>,
}

#[derive(Debug, Deserialize, Serialize)]
pub struct PlaidInternationalNumbers {
    pub account_id: Secret<String>,
    pub iban: Secret<String>,
    pub bic: Option<Secret<String>>,
}

impl TryFrom<ResponseRouterData<PlaidAuthGetResponse, Self>>
    for RouterDataV2<
        domain_types::connector_flow::GetPaymentMethod,
        PaymentFlowData,
        GetPaymentMethodData,
        GetPaymentMethodResponseData,
    >
{
    type Error = error_stack::Report<ConnectorError>;

    fn try_from(item: ResponseRouterData<PlaidAuthGetResponse, Self>) -> Result<Self, Self::Error> {
        let res = item.response;
        let request_currency = item.router_data.resource_common_data.get_currency();

        let institution_name = res.item.institution_name.clone();
        let (numbers, accounts_info) = (res.numbers, res.accounts);
        let id_to_account: std::collections::HashMap<String, _> = accounts_info
            .into_iter()
            .map(|acct| {
                let currency = acct.balances.iso_currency_code.or(request_currency);
                let balance = acct.balances.current.zip(currency).and_then(|(amt, c)| {
                    super::PlaidAmountConvertor::convert_back(amt, c)
                        .ok()
                        .map(|amount| common_utils::types::Money {
                            amount,
                            currency: c,
                        })
                });
                let available_balance =
                    acct.balances.available.zip(currency).and_then(|(amt, c)| {
                        super::PlaidAmountConvertor::convert_back(amt, c)
                            .ok()
                            .map(|amount| common_utils::types::Money {
                                amount,
                                currency: c,
                            })
                    });
                let bank_type = acct.subtype.as_deref().and_then(plaid_subtype_to_bank_type);
                let bank_holder_type = acct.holder_category.as_ref().map(|hc| match hc {
                    PlaidHolderCategory::Personal => BankHolderType::Personal,
                    PlaidHolderCategory::Business => BankHolderType::Business,
                });
                (
                    acct.account_id.peek().to_owned(),
                    (
                        acct.account_id,
                        acct.name,
                        bank_type,
                        bank_holder_type,
                        balance,
                        available_balance,
                    ),
                )
            })
            .collect();

        let mut accounts: Vec<BankAccount> = Vec::new();

        numbers.ach.into_iter().for_each(|ach| {
            if let Some((
                account_id,
                name,
                bank_type,
                bank_holder_type,
                balance,
                available_balance,
            )) = id_to_account.get(ach.account_id.peek())
            {
                accounts.push(BankAccount {
                    account_id: account_id.clone(),
                    account_name: name.clone(),
                    bank_type: *bank_type,
                    bank_holder_type: *bank_holder_type,
                    balance: balance.clone(),
                    available_balance: available_balance.clone(),
                    account_details: Some(BankAccountRoutingDetails::Ach(BankAccountAchDetails {
                        account_number: ach.account,
                        routing_number: ach.routing,
                    })),
                    bank_name: institution_name.clone(),
                });
            }
        });

        numbers.bacs.into_iter().for_each(|bacs| {
            if let Some((
                account_id,
                name,
                bank_type,
                bank_holder_type,
                balance,
                available_balance,
            )) = id_to_account.get(bacs.account_id.peek())
            {
                accounts.push(BankAccount {
                    account_id: account_id.clone(),
                    account_name: name.clone(),
                    bank_type: *bank_type,
                    bank_holder_type: *bank_holder_type,
                    balance: balance.clone(),
                    available_balance: available_balance.clone(),
                    account_details: Some(BankAccountRoutingDetails::Bacs(
                        BankAccountBacsDetails {
                            account_number: bacs.account,
                            sort_code: bacs.sort_code,
                        },
                    )),
                    bank_name: institution_name.clone(),
                });
            }
        });

        numbers.international.into_iter().for_each(|sepa| {
            if let Some((
                account_id,
                name,
                bank_type,
                bank_holder_type,
                balance,
                available_balance,
            )) = id_to_account.get(sepa.account_id.peek())
            {
                accounts.push(BankAccount {
                    account_id: account_id.clone(),
                    account_name: name.clone(),
                    bank_type: *bank_type,
                    bank_holder_type: *bank_holder_type,
                    balance: balance.clone(),
                    available_balance: available_balance.clone(),
                    account_details: Some(BankAccountRoutingDetails::Sepa(
                        BankAccountSepaDetails {
                            iban: sepa.iban,
                            bic: sepa.bic,
                        },
                    )),
                    bank_name: institution_name.clone(),
                });
            }
        });

        Ok(Self {
            response: Ok(GetPaymentMethodResponseData {
                merchant_payment_method_id: None,
                connector_payment_method_id: Some(res.item.item_id),
                customer: None,
                payment_method_details: Some(PaymentMethodDetails::BankAccount(
                    BankAccountDetails { accounts },
                )),
                status_code: item.http_code,
            }),
            ..item.router_data
        })
    }
}

/// Map Plaid account subtype strings to the domain `BankType` enum.
fn plaid_subtype_to_bank_type(subtype: &str) -> Option<BankType> {
    match subtype {
        PLAID_SUBTYPE_CHECKING => Some(BankType::Checking),
        PLAID_SUBTYPE_SAVINGS => Some(BankType::Savings),
        _ => None,
    }
}

// =============================================================================
// ERROR RESPONSE
// =============================================================================

#[derive(Debug, Deserialize, Serialize)]
pub struct PlaidErrorResponse {
    pub error_type: Option<String>,
    pub error_code: Option<String>,
    pub error_message: Option<String>,
    pub display_message: Option<String>,
    pub request_id: Option<String>,
}

impl common_utils::errors::ErrorSwitch<ConnectorError> for PlaidErrorResponse {
    fn switch(&self) -> ConnectorError {
        ConnectorError::ResponseHandlingFailed {
            context: domain_types::errors::ResponseTransformationErrorContext {
                http_status_code: None,
                additional_context: self.error_message.clone(),
            },
        }
    }
}
