use async_trait::async_trait;
use std::collections::HashMap;

use chrono::{DateTime, Utc};
use hmac::{Hmac, Mac};
use reqwest::Url;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use sha2::Sha256;

use crate::canonical::{
    CanonicalBillingDocument, CanonicalInvoice, CanonicalMoney, CanonicalPayment,
    CanonicalSubscription,
};
use crate::errors::BillingAdapterError;
use crate::providers::{
    BillingProviderAdapter, BillingProviderConnectionConfigDescriptor, BillingProviderDescriptor,
    BillingProviderRefetchProjection, BillingProviderRefetchRequest, BillingProviderResourceKind,
    BillingWebhookEventSummary, BillingWebhookProjection, config_field,
};
use crate::{
    AthenaInvoiceStatus, AthenaPaymentStatus, AthenaSubscriptionStatus, BillingConnectionMode,
    BillingProvider,
};

type HmacSha256 = Hmac<Sha256>;

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MollieCredentialKind {
    ApiKey,
    #[serde(alias = "advanced_access_token")]
    OrganizationAccessToken,
    OAuthAccessToken,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MollieConnectionConfig {
    pub api_base_url: String,
    pub credential_kind: MollieCredentialKind,
    pub api_key: String,
    pub mode: BillingConnectionMode,
    pub profile_id: Option<String>,
    pub signing_secrets: Vec<String>,
}

impl MollieConnectionConfig {
    pub fn validate(&self) -> Result<(), BillingAdapterError> {
        if self.api_key.trim().is_empty() {
            return Err(BillingAdapterError::InvalidConfig(
                "api_key must not be empty".to_string(),
            ));
        }
        if self.api_base_url.trim().is_empty() {
            return Err(BillingAdapterError::InvalidConfig(
                "api_base_url must not be empty".to_string(),
            ));
        }
        if matches!(
            self.credential_kind,
            MollieCredentialKind::OrganizationAccessToken | MollieCredentialKind::OAuthAccessToken
        ) && self
            .profile_id
            .as_deref()
            .is_none_or(|value| value.trim().is_empty())
        {
            return Err(BillingAdapterError::InvalidConfig(
                "profile_id is required for organization or OAuth Mollie credentials".to_string(),
            ));
        }
        Ok(())
    }

    fn needs_profile_id(&self) -> bool {
        self.profile_id.is_some()
            && matches!(
                self.credential_kind,
                MollieCredentialKind::OrganizationAccessToken
                    | MollieCredentialKind::OAuthAccessToken
            )
    }
}

pub struct MollieProviderAdapter;

pub const MOLLIE_PROVIDER_ADAPTER: MollieProviderAdapter = MollieProviderAdapter;

#[async_trait]
impl BillingProviderAdapter for MollieProviderAdapter {
    fn provider(&self) -> BillingProvider {
        BillingProvider::Mollie
    }

    fn descriptor(&self) -> BillingProviderDescriptor {
        BillingProviderDescriptor {
            provider: self.provider(),
            implementation_status: "implemented".to_string(),
            default_api_base_url: "https://api.mollie.com".to_string(),
            signature_header_name: Some("x-mollie-signature".to_string()),
            supported_webhook_envelope_kinds: vec!["classic".to_string(), "next_gen".to_string()],
            supported_webhook_event_types: vec![
                "payment.status_changed".to_string(),
                "payment.*".to_string(),
                "balance-transaction.created".to_string(),
                "payment-link.*".to_string(),
                "subscription.*".to_string(),
                "sales-invoice.*".to_string(),
            ],
            supported_credential_kinds: vec![
                "api_key".to_string(),
                "advanced_access_token".to_string(),
                "organization_access_token".to_string(),
                "oauth_access_token".to_string(),
            ],
            supports_webhook_ingestion: true,
            supports_canonical_projection: true,
            supports_reconcile: true,
            supported_reconcile_resource_kinds: vec![
                BillingProviderResourceKind::Payment.as_str().to_string(),
                BillingProviderResourceKind::PaymentLink.as_str().to_string(),
                BillingProviderResourceKind::Subscription.as_str().to_string(),
                BillingProviderResourceKind::Invoice.as_str().to_string(),
            ],
            connection_config: BillingProviderConnectionConfigDescriptor {
                default_credential_kind: "api_key".to_string(),
                fields: vec![
                    config_field(
                        "apiBaseUrl",
                        "string",
                        "Base URL for the Mollie API.",
                        true,
                    )
                    .with_default(Value::String("https://api.mollie.com".to_string())),
                    config_field(
                        "credentialKind",
                        "string",
                        "Mollie credential mode used for API requests.",
                        true,
                    )
                    .with_allowed_values(vec![
                        "api_key".to_string(),
                        "advanced_access_token".to_string(),
                        "organization_access_token".to_string(),
                        "oauth_access_token".to_string(),
                    ])
                    .with_default(Value::String("api_key".to_string()))
                    .with_derived_from("credentialKind"),
                    config_field(
                        "apiKey",
                        "string",
                        "Secret Mollie API token or access token for the selected credential kind.",
                        true,
                    )
                    .with_secret(true),
                    config_field(
                        "mode",
                        "string",
                        "Billing connection mode used when Athena normalizes provider requests.",
                        true,
                    )
                    .with_allowed_values(vec!["test".to_string(), "live".to_string()])
                    .with_derived_from("mode"),
                    config_field(
                        "profileId",
                        "string",
                        "Required for organization or OAuth Mollie credentials, and omitted for profile-bound API keys.",
                        false,
                    )
                    .with_derived_from("providerProfileId"),
                    config_field(
                        "signingSecrets",
                        "string_array",
                        "Ordered list of X-Mollie-Signature secrets accepted for next-gen webhook verification.",
                        true,
                    )
                    .with_default(Value::Array(Vec::new())),
                ],
            },
            integration: None,
        }
    }

    fn signature_header_name(&self) -> Option<&'static str> {
        Some("x-mollie-signature")
    }

    fn normalize_connection_config(
        &self,
        config: &mut serde_json::Map<String, Value>,
        mode: BillingConnectionMode,
        credential_kind: &str,
        provider_profile_id: Option<&str>,
    ) {
        normalize_connection_config(config, mode, credential_kind, provider_profile_id);
    }

    fn validate_connection_config(&self, config: &Value) -> Result<(), BillingAdapterError> {
        let config: MollieConnectionConfig = serde_json::from_value(config.clone())
            .map_err(|error| BillingAdapterError::InvalidConfig(error.to_string()))?;
        config.validate()
    }

    async fn project_webhook(
        &self,
        config: &Value,
        body: &[u8],
        signature_headers: &[String],
    ) -> Result<BillingWebhookProjection, BillingAdapterError> {
        let config: MollieConnectionConfig = serde_json::from_value(config.clone())
            .map_err(|error| BillingAdapterError::InvalidConfig(error.to_string()))?;
        let webhook = parse_webhook_envelope(body, signature_headers, &config.signing_secrets)?;
        let client = MollieClient::new(config)?;
        client.project_webhook(&webhook).await
    }

    async fn project_refetch(
        &self,
        config: &Value,
        request: &BillingProviderRefetchRequest,
    ) -> Result<BillingProviderRefetchProjection, BillingAdapterError> {
        let config: MollieConnectionConfig = serde_json::from_value(config.clone())
            .map_err(|error| BillingAdapterError::InvalidConfig(error.to_string()))?;
        let client = MollieClient::new(config)?;
        client.project_refetch(request).await
    }
}

pub fn normalize_connection_config(
    config: &mut serde_json::Map<String, Value>,
    mode: BillingConnectionMode,
    credential_kind: &str,
    provider_profile_id: Option<&str>,
) {
    let needs_profile_id = matches!(
        credential_kind.trim(),
        "organization_access_token" | "advanced_access_token" | "oauth_access_token"
    );
    config.insert(
        "apiBaseUrl".to_string(),
        config
            .get("apiBaseUrl")
            .cloned()
            .unwrap_or_else(|| Value::String("https://api.mollie.com".to_string())),
    );
    config.insert("mode".to_string(), Value::String(mode.as_str().to_string()));
    config.insert(
        "credentialKind".to_string(),
        Value::String(credential_kind.to_string()),
    );
    if needs_profile_id {
        if let Some(profile_id) = provider_profile_id.filter(|value| !value.trim().is_empty()) {
            config.insert(
                "profileId".to_string(),
                Value::String(profile_id.to_string()),
            );
        } else {
            config.remove("profileId");
        }
    } else {
        config.remove("profileId");
    }
    config
        .entry("signingSecrets".to_string())
        .or_insert_with(|| Value::Array(Vec::new()));
}

#[derive(Debug, Clone)]
pub struct MollieClient {
    http: reqwest::Client,
    config: MollieConnectionConfig,
}

impl MollieClient {
    pub fn new(config: MollieConnectionConfig) -> Result<Self, BillingAdapterError> {
        config.validate()?;
        Ok(Self {
            http: reqwest::Client::new(),
            config,
        })
    }

    fn request_url(
        &self,
        path: &str,
        mut query: Vec<(&str, String)>,
    ) -> Result<Url, BillingAdapterError> {
        if self.config.mode == BillingConnectionMode::Test
            && self.config.credential_kind != MollieCredentialKind::ApiKey
        {
            query.push(("testmode", "true".to_string()));
        }
        if self.config.needs_profile_id() {
            if let Some(profile_id) = self.config.profile_id.as_ref() {
                query.push(("profileId", profile_id.clone()));
            }
        }

        let base = self.config.api_base_url.trim_end_matches('/');
        let mut url = Url::parse(&format!("{base}/{}", path.trim_start_matches('/')))
            .map_err(|error| BillingAdapterError::InvalidConfig(error.to_string()))?;
        if !query.is_empty() {
            url.query_pairs_mut().extend_pairs(query);
        }
        Ok(url)
    }

    async fn get_json<T: for<'de> Deserialize<'de>>(
        &self,
        path: &str,
        query: Vec<(&str, String)>,
    ) -> Result<T, BillingAdapterError> {
        let url = self.request_url(path, query)?;
        let response = self
            .http
            .get(url)
            .bearer_auth(&self.config.api_key)
            .send()
            .await
            .map_err(|error| BillingAdapterError::Http(error.to_string()))?;
        let response = response
            .error_for_status()
            .map_err(|error| BillingAdapterError::Http(error.to_string()))?;
        response
            .json::<T>()
            .await
            .map_err(|error| BillingAdapterError::Parse(error.to_string()))
    }

    pub async fn get_payment(
        &self,
        payment_id: &str,
    ) -> Result<MolliePayment, BillingAdapterError> {
        self.get_json(
            &format!("/v2/payments/{payment_id}"),
            vec![("embed", "refunds,chargebacks".to_string())],
        )
        .await
    }

    pub async fn get_payment_link(
        &self,
        payment_link_id: &str,
    ) -> Result<MolliePaymentLink, BillingAdapterError> {
        self.get_json(&format!("/v2/payment-links/{payment_link_id}"), Vec::new())
            .await
    }

    pub async fn get_payment_link_payments(
        &self,
        payment_link_id: &str,
    ) -> Result<Vec<MolliePayment>, BillingAdapterError> {
        let response: MollieListResponse<MolliePayment> = self
            .get_json(
                &format!("/v2/payment-links/{payment_link_id}/payments"),
                Vec::new(),
            )
            .await?;
        Ok(response
            .embedded
            .and_then(|embedded| embedded.items)
            .unwrap_or_default())
    }

    pub async fn get_subscription(
        &self,
        customer_id: &str,
        subscription_id: &str,
    ) -> Result<MollieSubscription, BillingAdapterError> {
        self.get_json(
            &format!("/v2/customers/{customer_id}/subscriptions/{subscription_id}"),
            Vec::new(),
        )
        .await
    }

    async fn list_all_subscriptions_page(
        &self,
        from: Option<&str>,
    ) -> Result<(Vec<MollieSubscription>, Option<String>), BillingAdapterError> {
        let mut query = vec![("limit", "250".to_string())];
        if let Some(from) = from.map(str::trim).filter(|value| !value.is_empty()) {
            query.push(("from", from.to_string()));
        }

        let payload = self.get_json::<Value>("/v2/subscriptions", query).await?;
        let subscriptions = payload
            .get("_embedded")
            .and_then(|embedded| embedded.get("subscriptions"))
            .cloned()
            .unwrap_or_else(|| Value::Array(Vec::new()));
        let subscriptions = serde_json::from_value::<Vec<MollieSubscription>>(subscriptions)
            .map_err(|error| BillingAdapterError::Parse(error.to_string()))?;
        let next = payload
            .get("_links")
            .and_then(|links| links.get("next"))
            .and_then(|next| next.get("href"))
            .and_then(Value::as_str)
            .and_then(subscription_cursor_from_href);
        Ok((subscriptions, next))
    }

    pub async fn find_subscription(
        &self,
        subscription_id: &str,
    ) -> Result<MollieSubscription, BillingAdapterError> {
        let subscription_id = subscription_id.trim();
        if subscription_id.is_empty() {
            return Err(BillingAdapterError::InvalidConfig(
                "subscription_id must not be empty".to_string(),
            ));
        }

        let mut from = None;
        for _ in 0..20 {
            let (subscriptions, next) = self.list_all_subscriptions_page(from.as_deref()).await?;
            if let Some(subscription) = subscriptions
                .into_iter()
                .find(|subscription| subscription.id == subscription_id)
            {
                return Ok(subscription);
            }
            match next {
                Some(cursor) => from = Some(cursor),
                None => break,
            }
        }

        Err(BillingAdapterError::Http(format!(
            "subscription '{subscription_id}' was not found"
        )))
    }

    pub async fn get_sales_invoice(
        &self,
        sales_invoice_id: &str,
    ) -> Result<MollieSalesInvoice, BillingAdapterError> {
        self.get_json(
            &format!("/v2/sales-invoices/{sales_invoice_id}"),
            Vec::new(),
        )
        .await
    }

    pub async fn project_webhook(
        &self,
        webhook: &MollieWebhookEnvelope,
    ) -> Result<BillingWebhookProjection, BillingAdapterError> {
        let event = webhook_event_summary(webhook);
        let payload = webhook_payload_value(webhook)?;
        let document = match webhook {
            MollieWebhookEnvelope::Classic(classic) => {
                let payment = self.get_payment(&classic.id).await?;
                Some(CanonicalBillingDocument::Payment(project_payment(
                    &payment, None,
                )))
            }
            MollieWebhookEnvelope::NextGen(event) => match event.r#type.split('.').next() {
                Some("payment") => {
                    let payment =
                        if let Some(payment) = event.embedded_entity_as::<MolliePayment>()? {
                            payment
                        } else {
                            self.get_payment(&event.entity_id).await?
                        };
                    Some(CanonicalBillingDocument::Payment(project_payment(
                        &payment, None,
                    )))
                }
                Some("balance-transaction") => {
                    let payment_id = event
                        .embedded_entity_as::<MollieBalanceTransaction>()?
                        .and_then(|transaction| {
                            transaction.context.and_then(|context| context.payment_id)
                        });
                    match payment_id {
                        Some(payment_id) => {
                            let payment = self.get_payment(&payment_id).await?;
                            Some(CanonicalBillingDocument::Payment(project_payment(
                                &payment, None,
                            )))
                        }
                        None => None,
                    }
                }
                Some("payment-link") => {
                    let payment_link = if let Some(payment_link) =
                        event.embedded_entity_as::<MolliePaymentLink>()?
                    {
                        payment_link
                    } else {
                        self.get_payment_link(&event.entity_id).await?
                    };
                    let payments = self.get_payment_link_payments(&event.entity_id).await?;
                    select_payment_link_payment(&payments).map(|payment| {
                        CanonicalBillingDocument::Payment(project_payment(
                            payment.payment(),
                            Some(&payment_link),
                        ))
                    })
                }
                Some("subscription") => {
                    let subscription = if let Some(subscription) =
                        event.embedded_entity_as::<MollieSubscription>()?
                    {
                        subscription
                    } else {
                        self.find_subscription(&event.entity_id).await?
                    };
                    Some(CanonicalBillingDocument::Subscription(
                        project_subscription(&subscription),
                    ))
                }
                Some("sales-invoice") => {
                    let invoice =
                        if let Some(invoice) = event.embedded_entity_as::<MollieSalesInvoice>()? {
                            invoice
                        } else {
                            self.get_sales_invoice(&event.entity_id).await?
                        };
                    Some(CanonicalBillingDocument::Invoice(project_sales_invoice(
                        &invoice,
                    )))
                }
                _ => None,
            },
        };

        Ok(BillingWebhookProjection {
            event,
            payload,
            document,
        })
    }

    pub async fn project_refetch(
        &self,
        request: &BillingProviderRefetchRequest,
    ) -> Result<BillingProviderRefetchProjection, BillingAdapterError> {
        let provider_ref = request.provider_ref.trim();
        if provider_ref.is_empty() {
            return Err(BillingAdapterError::InvalidConfig(
                "provider_ref must not be empty".to_string(),
            ));
        }

        match request.resource_kind {
            BillingProviderResourceKind::Payment => {
                let payment = self.get_payment(provider_ref).await?;
                Ok(BillingProviderRefetchProjection {
                    resource_kind: request.resource_kind,
                    provider_ref: provider_ref.to_string(),
                    payload: serde_json::to_value(&payment)
                        .map_err(|error| BillingAdapterError::Parse(error.to_string()))?,
                    document: CanonicalBillingDocument::Payment(project_payment(&payment, None)),
                })
            }
            BillingProviderResourceKind::PaymentLink => {
                let payment_link = self.get_payment_link(provider_ref).await?;
                let payments = self.get_payment_link_payments(provider_ref).await?;
                let payment = select_payment_link_payment(&payments).ok_or_else(|| {
                    BillingAdapterError::Http(format!(
                        "payment link '{provider_ref}' returned no payments"
                    ))
                })?;

                Ok(BillingProviderRefetchProjection {
                    resource_kind: request.resource_kind,
                    provider_ref: provider_ref.to_string(),
                    payload: json!({
                        "paymentLink": payment_link,
                        "payments": payments,
                    }),
                    document: CanonicalBillingDocument::Payment(project_payment(
                        payment.payment(),
                        Some(&payment_link),
                    )),
                })
            }
            BillingProviderResourceKind::Subscription => {
                let subscription = match request
                    .provider_customer_ref
                    .as_deref()
                    .map(str::trim)
                    .filter(|value| !value.is_empty())
                {
                    Some(customer_id) => self.get_subscription(customer_id, provider_ref).await?,
                    None => self.find_subscription(provider_ref).await?,
                };
                Ok(BillingProviderRefetchProjection {
                    resource_kind: request.resource_kind,
                    provider_ref: provider_ref.to_string(),
                    payload: serde_json::to_value(&subscription)
                        .map_err(|error| BillingAdapterError::Parse(error.to_string()))?,
                    document: CanonicalBillingDocument::Subscription(project_subscription(
                        &subscription,
                    )),
                })
            }
            BillingProviderResourceKind::Invoice => {
                let invoice = self.get_sales_invoice(provider_ref).await?;
                Ok(BillingProviderRefetchProjection {
                    resource_kind: request.resource_kind,
                    provider_ref: provider_ref.to_string(),
                    payload: serde_json::to_value(&invoice)
                        .map_err(|error| BillingAdapterError::Parse(error.to_string()))?,
                    document: CanonicalBillingDocument::Invoice(project_sales_invoice(&invoice)),
                })
            }
        }
    }
}

#[derive(Debug, Clone, PartialEq)]
pub enum MollieWebhookEnvelope {
    Classic(MollieClassicWebhook),
    NextGen(MollieNextGenWebhookEvent),
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MollieClassicWebhook {
    pub id: String,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MollieNextGenWebhookEvent {
    pub resource: String,
    pub id: String,
    #[serde(rename = "type")]
    pub r#type: String,
    #[serde(rename = "entityId")]
    pub entity_id: String,
    #[serde(rename = "createdAt")]
    pub created_at: DateTime<Utc>,
    #[serde(default, rename = "_embedded")]
    pub embedded: Option<MollieEmbeddedEntity>,
    #[serde(default, rename = "_links")]
    pub links: HashMap<String, MollieLink>,
}

impl MollieNextGenWebhookEvent {
    pub fn embedded_entity_as<T: for<'de> Deserialize<'de>>(
        &self,
    ) -> Result<Option<T>, BillingAdapterError> {
        let Some(embedded) = self.embedded.as_ref() else {
            return Ok(None);
        };
        let Some(entity) = embedded.entity.as_ref() else {
            return Ok(None);
        };
        serde_json::from_value(entity.clone())
            .map(Some)
            .map_err(|error| BillingAdapterError::Parse(error.to_string()))
    }
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct MollieEmbeddedEntity {
    #[serde(default)]
    pub entity: Option<Value>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MollieLink {
    pub href: String,
    #[serde(default)]
    pub r#type: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct MollieAmount {
    pub currency: String,
    pub value: String,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MollieBalanceTransactionContext {
    #[serde(default)]
    pub payment_id: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MollieBalanceTransaction {
    pub id: String,
    #[serde(default)]
    pub context: Option<MollieBalanceTransactionContext>,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MolliePayment {
    pub id: String,
    pub status: String,
    pub description: Option<String>,
    pub amount: MollieAmount,
    #[serde(default)]
    pub amount_refunded: Option<MollieAmount>,
    #[serde(default)]
    pub metadata: Value,
    #[serde(default)]
    pub profile_id: Option<String>,
    #[serde(default)]
    pub customer_id: Option<String>,
    #[serde(default)]
    pub subscription_id: Option<String>,
    #[serde(default)]
    pub paid_at: Option<DateTime<Utc>>,
    #[serde(default)]
    pub created_at: Option<DateTime<Utc>>,
    #[serde(default, rename = "_links")]
    pub links: HashMap<String, MollieLink>,
    #[serde(flatten)]
    pub raw: Value,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MolliePaymentLink {
    pub id: String,
    #[serde(default)]
    pub profile_id: Option<String>,
    pub description: Option<String>,
    pub amount: MollieAmount,
    #[serde(default)]
    pub archived: Option<bool>,
    #[serde(default)]
    pub reusable: Option<bool>,
    #[serde(default)]
    pub metadata: Value,
    #[serde(default)]
    pub created_at: Option<DateTime<Utc>>,
    #[serde(flatten)]
    pub raw: Value,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MollieSubscription {
    pub id: String,
    pub status: String,
    pub customer_id: String,
    #[serde(default)]
    pub profile_id: Option<String>,
    #[serde(default)]
    pub amount: Option<MollieAmount>,
    #[serde(default)]
    pub interval: Option<String>,
    pub description: Option<String>,
    #[serde(default)]
    pub metadata: Value,
    #[serde(default)]
    pub next_payment_date: Option<String>,
    #[serde(default)]
    pub created_at: Option<DateTime<Utc>>,
    #[serde(default)]
    pub canceled_at: Option<DateTime<Utc>>,
    #[serde(flatten)]
    pub raw: Value,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MollieSalesInvoice {
    pub id: String,
    pub status: String,
    #[serde(default)]
    pub profile_id: Option<String>,
    #[serde(default)]
    pub customer_id: Option<String>,
    #[serde(default)]
    pub total_amount: Option<MollieAmount>,
    #[serde(default)]
    pub amount_paid: Option<MollieAmount>,
    pub description: Option<String>,
    #[serde(default)]
    pub metadata: Value,
    #[serde(default)]
    pub issued_at: Option<DateTime<Utc>>,
    #[serde(default)]
    pub paid_at: Option<DateTime<Utc>>,
    #[serde(default)]
    pub created_at: Option<DateTime<Utc>>,
    #[serde(flatten)]
    pub raw: Value,
}

#[derive(Debug, Clone, PartialEq)]
enum MolliePaymentLinkSelection<'a> {
    Preferred(&'a MolliePayment),
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(bound(
    serialize = "T: Serialize",
    deserialize = "T: serde::de::DeserializeOwned"
))]
pub struct MollieListResponse<T> {
    #[serde(default, rename = "_embedded")]
    pub embedded: Option<MollieEmbeddedList<T>>,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(bound(
    serialize = "T: Serialize",
    deserialize = "T: serde::de::DeserializeOwned"
))]
pub struct MollieEmbeddedList<T> {
    #[serde(default, rename = "payments")]
    pub items: Option<Vec<T>>,
}

impl<'a> MolliePaymentLinkSelection<'a> {
    fn payment(self) -> &'a MolliePayment {
        match self {
            Self::Preferred(payment) => payment,
        }
    }
}

fn select_payment_link_payment(
    payments: &[MolliePayment],
) -> Option<MolliePaymentLinkSelection<'_>> {
    payments
        .iter()
        .find(|payment| {
            matches!(
                map_mollie_payment_status(&payment.status, payment.amount_refunded.as_ref()),
                AthenaPaymentStatus::Paid | AthenaPaymentStatus::Refunded
            )
        })
        .or_else(|| payments.first())
        .map(MolliePaymentLinkSelection::Preferred)
}

pub fn parse_classic_webhook(body: &[u8]) -> Result<MollieClassicWebhook, BillingAdapterError> {
    serde_urlencoded::from_bytes(body)
        .map_err(|error| BillingAdapterError::Parse(error.to_string()))
}

fn subscription_cursor_from_href(href: &str) -> Option<String> {
    let url = Url::parse(href).ok()?;
    url.query_pairs()
        .find_map(|(key, value)| (key == "from").then(|| value.into_owned()))
}

pub fn parse_next_gen_webhook(
    body: &[u8],
) -> Result<MollieNextGenWebhookEvent, BillingAdapterError> {
    serde_json::from_slice(body).map_err(|error| BillingAdapterError::Parse(error.to_string()))
}

pub fn verify_next_gen_signature(
    body: &[u8],
    signature_headers: &[String],
    signing_secrets: &[String],
) -> Result<(), BillingAdapterError> {
    if signature_headers.is_empty() {
        return Err(BillingAdapterError::WebhookVerification(
            "missing X-Mollie-Signature header".to_string(),
        ));
    }
    if signing_secrets.is_empty() {
        return Err(BillingAdapterError::WebhookVerification(
            "no signing secrets configured".to_string(),
        ));
    }

    let normalized_signatures = signature_headers
        .iter()
        .map(|signature| signature.trim())
        .filter_map(|signature| signature.strip_prefix("sha256=").or(Some(signature)))
        .collect::<Vec<_>>();

    for secret in signing_secrets {
        for provided_signature in &normalized_signatures {
            let mut mac = HmacSha256::new_from_slice(secret.as_bytes())
                .map_err(|error| BillingAdapterError::WebhookVerification(error.to_string()))?;
            mac.update(body);
            let calculated = format!("{:x}", mac.finalize().into_bytes());
            if calculated.eq_ignore_ascii_case(provided_signature) {
                return Ok(());
            }
        }
    }

    Err(BillingAdapterError::WebhookVerification(
        "no provided X-Mollie-Signature matched configured signing secrets".to_string(),
    ))
}

pub fn parse_webhook_envelope(
    body: &[u8],
    signature_headers: &[String],
    signing_secrets: &[String],
) -> Result<MollieWebhookEnvelope, BillingAdapterError> {
    if signature_headers.is_empty() {
        if looks_like_next_gen_webhook(body) {
            return Err(BillingAdapterError::WebhookVerification(
                "missing X-Mollie-Signature header".to_string(),
            ));
        }
        return parse_classic_webhook(body).map(MollieWebhookEnvelope::Classic);
    }
    verify_next_gen_signature(body, signature_headers, signing_secrets)?;
    parse_next_gen_webhook(body).map(MollieWebhookEnvelope::NextGen)
}

fn looks_like_next_gen_webhook(body: &[u8]) -> bool {
    parse_next_gen_webhook(body).is_ok()
}

pub fn webhook_event_summary(webhook: &MollieWebhookEnvelope) -> BillingWebhookEventSummary {
    match webhook {
        MollieWebhookEnvelope::Classic(classic) => BillingWebhookEventSummary {
            provider: BillingProvider::Mollie,
            envelope_kind: "classic".to_string(),
            verification_mode: "classic_unsigned".to_string(),
            signature_header_name: None,
            resource: Some("payment".to_string()),
            event_id: None,
            event_type: Some("payment.status_changed".to_string()),
            entity_id: Some(classic.id.clone()),
            created_at: None,
        },
        MollieWebhookEnvelope::NextGen(event) => BillingWebhookEventSummary {
            provider: BillingProvider::Mollie,
            envelope_kind: "next_gen".to_string(),
            verification_mode: "next_gen_signature".to_string(),
            signature_header_name: Some("x-mollie-signature".to_string()),
            resource: Some(event.resource.clone()),
            event_id: Some(event.id.clone()),
            event_type: Some(event.r#type.clone()),
            entity_id: Some(event.entity_id.clone()),
            created_at: Some(event.created_at),
        },
    }
}

pub fn webhook_payload_value(
    webhook: &MollieWebhookEnvelope,
) -> Result<Value, BillingAdapterError> {
    match webhook {
        MollieWebhookEnvelope::Classic(classic) => serde_json::to_value(classic)
            .map_err(|error| BillingAdapterError::Parse(error.to_string())),
        MollieWebhookEnvelope::NextGen(event) => serde_json::to_value(event)
            .map_err(|error| BillingAdapterError::Parse(error.to_string())),
    }
}

pub fn project_payment(
    payment: &MolliePayment,
    payment_link: Option<&MolliePaymentLink>,
) -> CanonicalPayment {
    CanonicalPayment {
        provider: BillingProvider::Mollie,
        provider_payment_id: payment.id.clone(),
        provider_customer_id: payment.customer_id.clone(),
        provider_profile_id: payment.profile_id.clone(),
        provider_subscription_id: payment.subscription_id.clone(),
        provider_payment_link_id: payment_link.map(|link| link.id.clone()),
        status: map_mollie_payment_status(&payment.status, payment.amount_refunded.as_ref()),
        amount: project_money(&payment.amount),
        amount_refunded: payment.amount_refunded.as_ref().map(project_money),
        description: payment
            .description
            .clone()
            .or_else(|| payment_link.and_then(|link| link.description.clone())),
        metadata: first_non_null(&[
            &payment.metadata,
            &payment_link
                .map(|link| &link.metadata)
                .unwrap_or(&Value::Null),
        ]),
        paid_at: payment.paid_at,
        created_at: payment
            .created_at
            .or_else(|| payment_link.and_then(|link| link.created_at)),
        raw: json!({
            "payment": payment.raw,
            "paymentLink": payment_link.map(|link| &link.raw),
        }),
    }
}

pub fn project_subscription(subscription: &MollieSubscription) -> CanonicalSubscription {
    CanonicalSubscription {
        provider: BillingProvider::Mollie,
        provider_subscription_id: subscription.id.clone(),
        provider_customer_id: subscription.customer_id.clone(),
        provider_profile_id: subscription.profile_id.clone(),
        status: map_mollie_subscription_status(&subscription.status),
        amount: subscription.amount.as_ref().map(project_money),
        currency: subscription
            .amount
            .as_ref()
            .map(|amount| amount.currency.clone()),
        interval: subscription.interval.clone(),
        description: subscription.description.clone(),
        metadata: subscription.metadata.clone(),
        next_payment_date: subscription.next_payment_date.clone(),
        created_at: subscription.created_at,
        canceled_at: subscription.canceled_at,
        raw: subscription.raw.clone(),
    }
}

pub fn project_sales_invoice(invoice: &MollieSalesInvoice) -> CanonicalInvoice {
    CanonicalInvoice {
        provider: BillingProvider::Mollie,
        provider_invoice_id: invoice.id.clone(),
        provider_profile_id: invoice.profile_id.clone(),
        provider_customer_id: invoice.customer_id.clone(),
        status: map_mollie_invoice_status(&invoice.status),
        amount: invoice.total_amount.as_ref().map(project_money),
        amount_paid: invoice.amount_paid.as_ref().map(project_money),
        description: invoice.description.clone(),
        metadata: invoice.metadata.clone(),
        issued_at: invoice.issued_at,
        paid_at: invoice.paid_at,
        created_at: invoice.created_at,
        raw: invoice.raw.clone(),
    }
}

fn project_money(amount: &MollieAmount) -> CanonicalMoney {
    CanonicalMoney {
        currency: amount.currency.clone(),
        value: amount.value.clone(),
    }
}

fn first_non_null(values: &[&Value]) -> Value {
    values
        .iter()
        .find(|value| !value.is_null())
        .cloned()
        .cloned()
        .unwrap_or(Value::Null)
}

pub fn map_mollie_payment_status(
    status: &str,
    amount_refunded: Option<&MollieAmount>,
) -> AthenaPaymentStatus {
    if amount_refunded.is_some() {
        return AthenaPaymentStatus::Refunded;
    }
    match status {
        "paid" => AthenaPaymentStatus::Paid,
        "authorized" => AthenaPaymentStatus::Authorized,
        "failed" => AthenaPaymentStatus::Failed,
        "canceled" | "expired" => AthenaPaymentStatus::Canceled,
        _ => AthenaPaymentStatus::Pending,
    }
}

pub fn map_mollie_subscription_status(status: &str) -> AthenaSubscriptionStatus {
    match status {
        "active" => AthenaSubscriptionStatus::Active,
        "canceled" | "completed" => AthenaSubscriptionStatus::Canceled,
        "suspended" => AthenaSubscriptionStatus::PastDue,
        "pending" => AthenaSubscriptionStatus::Trialing,
        _ => AthenaSubscriptionStatus::Paused,
    }
}

pub fn map_mollie_invoice_status(status: &str) -> AthenaInvoiceStatus {
    match status {
        "paid" => AthenaInvoiceStatus::Paid,
        "issued" | "open" => AthenaInvoiceStatus::Open,
        "canceled" => AthenaInvoiceStatus::Void,
        _ => AthenaInvoiceStatus::Draft,
    }
}

#[cfg(test)]
mod tests {
    use super::{
        BillingAdapterError, MollieAmount, MollieClassicWebhook, MollieClient,
        MollieConnectionConfig, MollieCredentialKind, MolliePayment, MolliePaymentLink,
        MolliePaymentLinkSelection, MollieWebhookEnvelope, map_mollie_invoice_status,
        map_mollie_payment_status, map_mollie_subscription_status, normalize_connection_config,
        parse_classic_webhook, parse_webhook_envelope, project_payment,
        select_payment_link_payment, subscription_cursor_from_href, verify_next_gen_signature,
        webhook_event_summary, webhook_payload_value,
    };
    use crate::{
        AthenaInvoiceStatus, AthenaPaymentStatus, AthenaSubscriptionStatus, BillingConnectionMode,
        canonical::CanonicalBillingDocument,
    };
    use chrono::Utc;
    use hmac::{Hmac, Mac};
    use serde_json::json;
    use sha2::Sha256;
    use std::collections::HashMap;
    use tokio::io::{AsyncReadExt, AsyncWriteExt};
    use tokio::net::TcpListener;

    async fn start_single_response_http_server(
        response_body: &'static str,
    ) -> (String, tokio::task::JoinHandle<()>) {
        let listener = TcpListener::bind("127.0.0.1:0")
            .await
            .expect("listener should bind");
        let address = listener
            .local_addr()
            .expect("listener should expose local address");
        let handle = tokio::spawn(async move {
            let (mut stream, _) = listener.accept().await.expect("request should arrive");
            let mut buffer = [0_u8; 4096];
            let _ = stream.read(&mut buffer).await;
            let response = format!(
                "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
                response_body.len(),
                response_body
            );
            stream
                .write_all(response.as_bytes())
                .await
                .expect("response should be written");
        });
        (format!("http://{}", address), handle)
    }

    #[test]
    fn profile_aware_request_config_only_requires_profile_for_org_scopes() {
        let config = MollieConnectionConfig {
            api_base_url: "https://api.mollie.com".to_string(),
            credential_kind: MollieCredentialKind::OrganizationAccessToken,
            api_key: "token".to_string(),
            mode: BillingConnectionMode::Test,
            profile_id: Some("pfl_123".to_string()),
            signing_secrets: vec!["secret".to_string()],
        };
        assert!(config.validate().is_ok());
        assert!(config.needs_profile_id());
    }

    #[test]
    fn org_scoped_credentials_require_profile_id() {
        let config = MollieConnectionConfig {
            api_base_url: "https://api.mollie.com".to_string(),
            credential_kind: MollieCredentialKind::OrganizationAccessToken,
            api_key: "token".to_string(),
            mode: BillingConnectionMode::Test,
            profile_id: None,
            signing_secrets: vec!["secret".to_string()],
        };
        assert!(matches!(
            config.validate(),
            Err(BillingAdapterError::InvalidConfig(_))
        ));
    }

    #[test]
    fn normalize_connection_config_sets_runtime_defaults() {
        let mut config = serde_json::Map::new();
        config.insert("apiKey".to_string(), json!("secret"));
        normalize_connection_config(
            &mut config,
            BillingConnectionMode::Test,
            "organization_access_token",
            Some("pfl_123"),
        );

        assert_eq!(config["apiBaseUrl"], "https://api.mollie.com");
        assert_eq!(config["mode"], "test");
        assert_eq!(config["credentialKind"], "organization_access_token");
        assert_eq!(config["profileId"], "pfl_123");
        assert_eq!(config["signingSecrets"], json!([]));
    }

    #[test]
    fn normalize_connection_config_omits_profile_id_for_api_keys() {
        let mut config = serde_json::Map::new();
        config.insert("apiKey".to_string(), json!("secret"));
        normalize_connection_config(
            &mut config,
            BillingConnectionMode::Test,
            "api_key",
            Some("pfl_123"),
        );

        assert_eq!(config["apiBaseUrl"], "https://api.mollie.com");
        assert_eq!(config["mode"], "test");
        assert_eq!(config["credentialKind"], "api_key");
        assert!(config.get("profileId").is_none());
        assert_eq!(config["signingSecrets"], json!([]));
    }

    #[test]
    fn advanced_access_token_alias_deserializes_to_org_scoped_kind() {
        let config: MollieConnectionConfig = serde_json::from_value(json!({
            "apiBaseUrl": "https://api.mollie.com",
            "credentialKind": "advanced_access_token",
            "apiKey": "token",
            "mode": "test",
            "profileId": "pfl_123",
            "signingSecrets": []
        }))
        .expect("advanced access token alias should deserialize");

        assert_eq!(
            config.credential_kind,
            MollieCredentialKind::OrganizationAccessToken
        );
        assert!(config.validate().is_ok());
    }

    #[test]
    fn classic_webhook_payload_parses_form_body() {
        let parsed = parse_classic_webhook(b"id=tr_123").unwrap();
        assert_eq!(
            parsed,
            MollieClassicWebhook {
                id: "tr_123".to_string()
            }
        );
    }

    #[test]
    fn next_gen_webhook_signature_accepts_sha256_prefix() {
        let body = br#"{"resource":"event","id":"event_123","type":"payment-link.paid","entityId":"pl_123","createdAt":"2024-12-16T15:57:04Z"}"#;
        let secret = "super-secret";
        let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).unwrap();
        mac.update(body);
        let signature = format!("sha256={:x}", mac.finalize().into_bytes());

        verify_next_gen_signature(body, &[signature], &[secret.to_string()]).unwrap();
    }

    #[test]
    fn next_gen_webhook_parsing_rejects_bad_signature() {
        let body = br#"{"resource":"event","id":"event_123","type":"payment-link.paid","entityId":"pl_123","createdAt":"2024-12-16T15:57:04Z"}"#;
        let error =
            parse_webhook_envelope(body, &["sha256=nope".to_string()], &["secret".to_string()])
                .unwrap_err();
        assert!(matches!(error, BillingAdapterError::WebhookVerification(_)));
    }

    #[test]
    fn unsigned_next_gen_webhook_requires_signature_header() {
        let body = br#"{"resource":"event","id":"event_123","type":"payment-link.paid","entityId":"pl_123","createdAt":"2024-12-16T15:57:04Z"}"#;
        let error = parse_webhook_envelope(body, &[], &["secret".to_string()]).unwrap_err();
        assert!(matches!(
            error,
            BillingAdapterError::WebhookVerification(message)
                if message == "missing X-Mollie-Signature header"
        ));
    }

    #[test]
    fn subscription_cursor_is_extracted_from_next_link() {
        assert_eq!(
            subscription_cursor_from_href(
                "https://api.mollie.com/v2/subscriptions?from=sub_456&limit=250"
            )
            .as_deref(),
            Some("sub_456")
        );
        assert!(subscription_cursor_from_href("not-a-url").is_none());
    }

    #[test]
    fn payment_projection_carries_payment_link_context() {
        let payment = MolliePayment {
            id: "tr_123".to_string(),
            status: "paid".to_string(),
            description: None,
            amount: MollieAmount {
                currency: "EUR".to_string(),
                value: "24.95".to_string(),
            },
            amount_refunded: None,
            metadata: json!({"orderId":"123"}),
            profile_id: Some("pfl_123".to_string()),
            customer_id: Some("cst_123".to_string()),
            subscription_id: Some("sub_123".to_string()),
            paid_at: Some(Utc::now()),
            created_at: Some(Utc::now()),
            links: HashMap::new(),
            raw: json!({"id":"tr_123","status":"paid"}),
        };
        let payment_link = MolliePaymentLink {
            id: "pl_123".to_string(),
            profile_id: Some("pfl_123".to_string()),
            description: Some("Bicycle tires".to_string()),
            amount: MollieAmount {
                currency: "EUR".to_string(),
                value: "24.95".to_string(),
            },
            archived: Some(false),
            reusable: Some(true),
            metadata: json!({"campaign":"spring"}),
            created_at: Some(Utc::now()),
            raw: json!({"id":"pl_123"}),
        };

        let projected = project_payment(&payment, Some(&payment_link));
        assert_eq!(
            projected.provider_payment_link_id.as_deref(),
            Some("pl_123")
        );
        assert_eq!(
            projected.provider_subscription_id.as_deref(),
            Some("sub_123")
        );
        assert_eq!(projected.description.as_deref(), Some("Bicycle tires"));
        assert_eq!(projected.status, AthenaPaymentStatus::Paid);
    }

    #[test]
    fn payment_link_selection_prefers_paid_or_refunded_payments() {
        let pending = MolliePayment {
            id: "tr_pending".to_string(),
            status: "open".to_string(),
            description: None,
            amount: MollieAmount {
                currency: "EUR".to_string(),
                value: "10.00".to_string(),
            },
            amount_refunded: None,
            metadata: json!({}),
            profile_id: None,
            customer_id: None,
            subscription_id: None,
            paid_at: None,
            created_at: None,
            links: HashMap::new(),
            raw: json!({}),
        };
        let paid = MolliePayment {
            id: "tr_paid".to_string(),
            status: "paid".to_string(),
            description: None,
            amount: MollieAmount {
                currency: "EUR".to_string(),
                value: "12.00".to_string(),
            },
            amount_refunded: None,
            metadata: json!({}),
            profile_id: None,
            customer_id: None,
            subscription_id: None,
            paid_at: None,
            created_at: None,
            links: HashMap::new(),
            raw: json!({}),
        };

        let payments = [pending, paid];
        let selected = select_payment_link_payment(&payments).expect("selection");
        assert!(matches!(
            selected,
            MolliePaymentLinkSelection::Preferred(payment) if payment.id == "tr_paid"
        ));
    }

    #[test]
    fn payment_link_selection_falls_back_to_first_payment_or_none() {
        let first = MolliePayment {
            id: "tr_first".to_string(),
            status: "open".to_string(),
            description: None,
            amount: MollieAmount {
                currency: "EUR".to_string(),
                value: "10.00".to_string(),
            },
            amount_refunded: None,
            metadata: json!({}),
            profile_id: None,
            customer_id: None,
            subscription_id: None,
            paid_at: None,
            created_at: None,
            links: HashMap::new(),
            raw: json!({}),
        };
        let second = MolliePayment {
            id: "tr_second".to_string(),
            status: "failed".to_string(),
            description: None,
            amount: MollieAmount {
                currency: "EUR".to_string(),
                value: "12.00".to_string(),
            },
            amount_refunded: None,
            metadata: json!({}),
            profile_id: None,
            customer_id: None,
            subscription_id: None,
            paid_at: None,
            created_at: None,
            links: HashMap::new(),
            raw: json!({}),
        };

        let payments = [first, second];
        let selected = select_payment_link_payment(&payments).expect("fallback");
        assert_eq!(selected.payment().id, "tr_first");
        assert!(select_payment_link_payment(&[]).is_none());
    }

    #[tokio::test]
    async fn next_gen_payment_event_projects_canonical_payment_from_embedded_entity() {
        let client = MollieClient::new(MollieConnectionConfig {
            api_base_url: "https://api.mollie.com".to_string(),
            credential_kind: MollieCredentialKind::ApiKey,
            api_key: "live_test".to_string(),
            mode: BillingConnectionMode::Live,
            profile_id: Some("pfl_123".to_string()),
            signing_secrets: vec!["secret".to_string()],
        })
        .expect("client should be constructed");
        let webhook = serde_json::from_str(
            r#"{
                "resource":"event",
                "id":"event_123",
                "type":"payment.paid",
                "entityId":"tr_123",
                "createdAt":"2024-12-16T15:57:04Z",
                "_embedded":{
                    "entity":{
                        "id":"tr_123",
                        "status":"paid",
                        "description":"Embedded payment",
                        "amount":{"currency":"EUR","value":"24.95"},
                        "metadata":{"origin":"next-gen"},
                        "profileId":"pfl_123",
                        "customerId":"cst_123",
                        "subscriptionId":"sub_123",
                        "paidAt":"2024-12-16T15:57:04Z",
                        "createdAt":"2024-12-16T15:50:00Z"
                    }
                }
            }"#,
        )
        .expect("next-gen payment webhook should parse");

        let projection = client
            .project_webhook(&MollieWebhookEnvelope::NextGen(webhook))
            .await
            .expect("next-gen payment event should project");

        match projection.document {
            Some(CanonicalBillingDocument::Payment(payment)) => {
                assert_eq!(payment.provider_payment_id, "tr_123");
                assert_eq!(payment.status, AthenaPaymentStatus::Paid);
                assert_eq!(payment.provider_customer_id.as_deref(), Some("cst_123"));
                assert_eq!(payment.provider_subscription_id.as_deref(), Some("sub_123"));
                assert_eq!(payment.description.as_deref(), Some("Embedded payment"));
            }
            other => panic!("expected canonical payment projection, got {other:?}"),
        }
    }

    #[tokio::test]
    async fn next_gen_subscription_event_projects_canonical_subscription_from_embedded_entity() {
        let client = MollieClient::new(MollieConnectionConfig {
            api_base_url: "https://api.mollie.com".to_string(),
            credential_kind: MollieCredentialKind::ApiKey,
            api_key: "live_test".to_string(),
            mode: BillingConnectionMode::Live,
            profile_id: Some("pfl_123".to_string()),
            signing_secrets: vec!["secret".to_string()],
        })
        .expect("client should be constructed");
        let webhook = serde_json::from_str(
            r#"{
                "resource":"event",
                "id":"event_456",
                "type":"subscription.updated",
                "entityId":"sub_123",
                "createdAt":"2024-12-16T15:57:04Z",
                "_embedded":{
                    "entity":{
                        "id":"sub_123",
                        "status":"active",
                        "customerId":"cst_123",
                        "profileId":"pfl_123",
                        "amount":{"currency":"EUR","value":"9.95"},
                        "interval":"1 month",
                        "description":"Monthly plan",
                        "metadata":{"origin":"next-gen"},
                        "nextPaymentDate":"2025-01-16"
                    }
                }
            }"#,
        )
        .expect("next-gen subscription webhook should parse");

        let projection = client
            .project_webhook(&MollieWebhookEnvelope::NextGen(webhook))
            .await
            .expect("next-gen subscription event should project");

        match projection.document {
            Some(CanonicalBillingDocument::Subscription(subscription)) => {
                assert_eq!(subscription.provider_subscription_id, "sub_123");
                assert_eq!(subscription.provider_customer_id, "cst_123");
                assert_eq!(subscription.status, AthenaSubscriptionStatus::Active);
                assert_eq!(subscription.interval.as_deref(), Some("1 month"));
                assert_eq!(subscription.description.as_deref(), Some("Monthly plan"));
            }
            other => panic!("expected canonical subscription projection, got {other:?}"),
        }
    }

    #[tokio::test]
    async fn balance_transaction_event_projects_canonical_payment_from_context_payment_id() {
        let (api_base_url, server) = start_single_response_http_server(
            r#"{
                "id":"tr_context_123",
                "status":"paid",
                "description":"Fetched from balance transaction",
                "amount":{"currency":"EUR","value":"907.50"},
                "metadata":{"origin":"balance-transaction"},
                "profileId":"pfl_123",
                "customerId":"cst_123",
                "paidAt":"2026-07-02T07:04:19Z",
                "createdAt":"2026-07-02T07:04:16Z"
            }"#,
        )
        .await;
        let client = MollieClient::new(MollieConnectionConfig {
            api_base_url,
            credential_kind: MollieCredentialKind::ApiKey,
            api_key: "live_test".to_string(),
            mode: BillingConnectionMode::Live,
            profile_id: Some("pfl_123".to_string()),
            signing_secrets: vec!["secret".to_string()],
        })
        .expect("client should be constructed");
        let webhook = serde_json::from_str(
            r#"{
                "resource":"event",
                "id":"event_bt_123",
                "type":"balance-transaction.created",
                "entityId":"baltr_123",
                "createdAt":"2026-07-02T07:04:19Z",
                "_embedded":{
                    "entity":{
                        "id":"baltr_123",
                        "resource":"balance-transaction",
                        "context":{
                            "paymentId":"tr_context_123"
                        }
                    }
                }
            }"#,
        )
        .expect("balance transaction webhook should parse");

        let projection = client
            .project_webhook(&MollieWebhookEnvelope::NextGen(webhook))
            .await
            .expect("balance transaction should project");

        match projection.document {
            Some(CanonicalBillingDocument::Payment(payment)) => {
                assert_eq!(payment.provider_payment_id, "tr_context_123");
                assert_eq!(payment.status, AthenaPaymentStatus::Paid);
                assert_eq!(
                    payment.description.as_deref(),
                    Some("Fetched from balance transaction")
                );
                assert_eq!(payment.provider_customer_id.as_deref(), Some("cst_123"));
            }
            other => panic!("expected canonical payment projection, got {other:?}"),
        }

        server.await.expect("mock server should complete");
    }

    #[test]
    fn status_mappings_follow_canonical_contract() {
        assert_eq!(
            map_mollie_payment_status("authorized", None),
            AthenaPaymentStatus::Authorized
        );
        assert_eq!(
            map_mollie_payment_status(
                "paid",
                Some(&MollieAmount {
                    currency: "EUR".to_string(),
                    value: "1.00".to_string()
                })
            ),
            AthenaPaymentStatus::Refunded
        );
        assert_eq!(
            map_mollie_subscription_status("suspended"),
            AthenaSubscriptionStatus::PastDue
        );
        assert_eq!(
            map_mollie_invoice_status("issued"),
            AthenaInvoiceStatus::Open
        );
    }

    #[test]
    fn webhook_summary_and_payload_track_classic_and_next_gen_shapes() {
        let classic = super::MollieWebhookEnvelope::Classic(MollieClassicWebhook {
            id: "tr_123".to_string(),
        });
        let next_gen = parse_webhook_envelope(
            br#"{"resource":"event","id":"event_123","type":"payment-link.paid","entityId":"pl_123","createdAt":"2024-12-16T15:57:04Z"}"#,
            &["sha256=6d86ebd6440ec7bbaf71807f4d1d3889a7da84f9f80699083a2f585d9a0fb3d8".to_string()],
            &["secret".to_string()],
        );

        let classic_summary = webhook_event_summary(&classic);
        assert_eq!(classic_summary.envelope_kind, "classic");
        assert_eq!(
            classic_summary.event_type.as_deref(),
            Some("payment.status_changed")
        );
        assert_eq!(webhook_payload_value(&classic).unwrap()["id"], "tr_123");

        assert!(matches!(
            next_gen,
            Err(BillingAdapterError::WebhookVerification(_))
        ));
    }
}
