import json
import logging
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone

from async_stripe import stripe
from django.http import HttpRequest

from .. import prometheus
from ..civicrm.repository import CivicrmRepositoryMock

logger = logging.getLogger(__package__)


@dataclass
class StripeAddressData:
    city: str
    country: str
    line1: str
    line2: str
    postal_code: str
    state: str


@dataclass
class StripeCustomerData:
    name: str
    email: str
    address: StripeAddressData


@dataclass
class StripeShippingData:
    name: str
    address: StripeAddressData


@dataclass
class StripeMetadata:
    cohort: str = ""
    perk: str = ""
    perk_option: str = ""


@dataclass
class StripePaymentIntentData:
    amount: int
    receipt_email: str
    shipping: StripeShippingData
    metadata: StripeMetadata
    currency: str = "usd"
    automatic_payment_methods: dict[str, bool] = field(
        default_factory=lambda: {"enabled": True}
    )


class StripeController:
    def __init__(
        self,
        api_key: str,
        api_secret: str,
        webhook_secret: str,
        trace_webhooks: bool,
        civi: CivicrmRepositoryMock,
    ):
        self.__api_key = api_key
        self.__api_secret = api_secret
        self.__webhook_secret = webhook_secret
        self.__trace_webhooks = trace_webhooks
        self._civi = civi

        self.serviceErrorDisplayMessage = """An error occurred while processing your card.
Please wait and try your donation again later.
If this error persists, please reach out to us via one of the methods
<a href='https://gitlab.torproject.org/tpo/tpa/team/-/wikis/support'>listed here</a>."""

    def stripe_exception_handler(self, e: Exception) -> None:
        if isinstance(e, stripe.error.CardError):
            # Granular card error types delineated here:
            # https://docs.stripe.com/error-handling?lang=python#payment-errors
            if e.code == "card_declined":
                logger.error("%s: The card issuer declined the payment: %s", type(e), e)
            elif e.error.payment_intent.charges.data[0].outcome.type == "blocked":
                logger.error("%s: Stripe blocked this payment: %s", type(e), e)
            else:
                logging.error("%s: %s", type(e), e)
        else:
            logging.error("%s: %s", type(e), e)
        return

    # Note that we enable automatic_payment_methods; this is because we create
    # a payment intent only after the user has input their payment information,
    # which technically leverages "automatic payment methods" to thereafter
    # complete the transaction. (This does not set up a recurring payment.)
    async def create_payment_intent(
        self, payment_intent_data: StripePaymentIntentData
    ) -> stripe.PaymentIntent:
        stripe.api_key = self.__api_secret
        try:
            return await stripe.PaymentIntent.create(**asdict(payment_intent_data))
        except stripe.error.StripeError as e:
            self.stripe_exception_handler(e)
            raise ValueError(self.serviceErrorDisplayMessage)

    async def get_or_create_monthly_plan(self, price: int) -> stripe.Plan:
        stripe.api_key = self.__api_secret
        plan_id = f"web_donation_monthly_usd_{price}"
        # Stripe's SDK will throw an InvalidRequestError in the event that the
        # plan_id being passed to stripe.Plan.retrieve() has no matches.
        # In this event, we create a new plan using a service product.
        # (Other error types thrown by Stripe's SDK are handled as usual.)
        # API reference: https://docs.stripe.com/api/plans/create
        try:
            plan = await stripe.Plan.retrieve(plan_id)
            return plan
        except stripe.error.InvalidRequestError:
            try:
                plan = await stripe.Plan.create(
                    amount=price,
                    currency="usd",
                    interval="month",
                    product={
                        "name": plan_id,
                    },
                )
                return plan
            except stripe.error.StripeError as e:
                self.stripe_exception_handler(e)
                raise ValueError(self.serviceErrorDisplayMessage)
        except stripe.error.StripeError as e:
            self.stripe_exception_handler(e)
            raise ValueError(self.serviceErrorDisplayMessage)

    async def create_customer(
        self, customer_data: StripeCustomerData
    ) -> stripe.Customer:
        stripe.api_key = self.__api_secret
        try:
            return await stripe.Customer.create(**asdict(customer_data))
        except stripe.error.StripeError as e:
            self.stripe_exception_handler(e)
            raise ValueError(self.serviceErrorDisplayMessage)

    async def create_subscription(
        self, customer_id: str, price_id: str, metadata: StripeMetadata
    ) -> stripe.Subscription:
        stripe.api_key = self.__api_secret
        try:
            return await stripe.Subscription.create(
                customer=customer_id,
                items=[{"price": price_id}],
                payment_behavior="default_incomplete",
                payment_settings={"save_default_payment_method": "on_subscription"},
                expand=["latest_invoice.payment_intent"],
                metadata=asdict(metadata),
            )
        except stripe.error.StripeError as e:
            self.stripe_exception_handler(e)
            raise ValueError(self.serviceErrorDisplayMessage)

    async def validate_webhook(self, request: HttpRequest) -> None:
        payload = request.body
        validation_result = "failure"
        cohort = "unknown"
        try:
            sig_header = request.META["HTTP_STRIPE_SIGNATURE"]
            stripe.Webhook.construct_event(payload, sig_header, self.__webhook_secret)
            validation_result = "success"
            event = json.loads(payload)
            try:
                cohort = event["data"]["object"]["metadata"].get("cohort", "undefined")
            except KeyError as e:
                warn_msg = "unable to unpack metadata object from Stripe webhook payload: %s"
                logging.warning(warn_msg, e)
                cohort = "undefined"
        except KeyError as e:
            logger.error("Stripe HTTP signature not present: %s", str(e))
            raise
        except ValueError as e:
            logger.error("Error parsing payload: %s", str(e))
            raise
        except stripe.error.SignatureVerificationError as e:
            logger.error("Error verifying webhook signature: %s", str(e))
            raise ValueError
        finally:
            prometheus.WebhookValidation.labels(validation_result, "stripe", cohort).inc()
        return

    # Any event generated by Stripe can be sent to our webhook endpoint so we can track it.
    # Other events can be perused here: https://docs.stripe.com/api/events/types
    async def process_webhook(self, request: HttpRequest) -> None:
        payload = request.body
        event = json.loads(payload)

        # Log webhook in full, if appropriate
        if self.__trace_webhooks:
            logger.info(
                "Valid webhook receieved: %r",
                event
            )

        receipt_time = datetime.fromtimestamp(
            event["created"],
            timezone.utc
        ).strftime("%Y-%m-%d %H:%M:%S")
        args = {
            "currency": "USD",
            "payment_instrument_id": "Credit Card",
            "receive_date": receipt_time,
        }

        # Not all events have ["data"]["object"]["id"], but we want it for all that do.
        if "id" in event["data"]["object"]:
            args["trxn_id"] = event["data"]["object"]["id"]
            # ["handshake_id"] is used by civi.report_donation() to match webhook messages to
            # recent donation form data, which is how we map donations to users in the CRM.
            # Stripe trivially allows for transaction IDs to be identified the same way by
            # the same data, so we can handshake with event["data"]["object"]["id"] in all cases.
            args["handshake_id"] = args["trxn_id"]

        # Not all events have ["data"]["object"]["invoice"], but for those that do,
        # we want to use it as a flag that signals "Subscription" or "not Subscription"
        # in our match statement below. This is because both one-time donations and
        # subscriptions generate "payment_intent.succeeded" and "payment_intent.payment_failed"
        # events, but only subscriptions generate another, different webhook event mappable back
        # to its original donation form data ("invoice.payment_succeeded", etc).
        # Therefore, we use the flag `isSubscription` to ensure that subscription-related
        # payment_intent actions are filtered out of messages generating CiviCRM messages
        # (so that subscription-related invoice actions can generate them instead).

        # According to Stripe support themselves, ["data"]["object"]["invoice"] is specifically
        # the portion of the payment_intent webhook which is intended to be used for this
        # purpose (though this is not currently noted as such in their documentation).
        # Transactions created through Payment Intent do not seem to generate an invoice ID,
        # whereas payments made as part of a subscription's life cycle include one, and that
        # ID can be further used to retrieve a full invoice body with further information,
        # including specifically which part of the subscription lifecycle the payment is part of.
        # More here: https://docs.stripe.com/api/invoices/object#invoice_object-billing_reason
        isSubscription = False
        if (
            event["data"]["object"].get("invoice") is not None
            and event["data"]["object"]["invoice"].startswith("in_")
        ):
            isSubscription = True

        message = None

        try:
            cohort = event["data"]["object"]["metadata"].get("cohort", "undefined")
        except KeyError as e:
            logging.warning("unable to unpack metadata object from Stripe webhook payload: %s", e)

        match event["type"]:
            # One-time donation has succeeded
            case "payment_intent.succeeded":
                # ...and we're sure it's not a recurring donation
                if isSubscription is False:
                    message = "Tor\\Donation\\OneTimeContribution"
                    args["contribution_status_id"] = "Completed"
                    args["total_amount"] = event["data"]["object"]["amount"]
                    prometheus.DonationVendorTransactionCount.labels(
                        "success", "single", "stripe", cohort
                    ).inc()
            # If a one-time donation has failed...
            case "payment_intent.payment_failed":
                # ...and we're sure it's not a recurring donation?
                # CiviCRM does not track these, but this information might be useful to us,
                # so we log it and prevent CiviCRM from hearing about it.
                if isSubscription is False:
                    logger.info(
                        "A one-time Stripe payment was attempted but failed, with ID: {}"
                        .format(
                            args["trxn_id"]
                        )
                    )
                    prometheus.DonationVendorTransactionCount.labels(
                        "failure", "single", "stripe", cohort
                    ).inc()
            # A subscription payment donation has succeeded
            case "invoice.payment_succeeded":
                message = "Tor\\Donation\\RecurringContributionOngoing"
                args["contribution_status_id"] = "Completed"
                args["total_amount"] = event["data"]["object"]["amount_paid"]
                args[
                    "recurring_contribution_transaction_id"
                ] = event['data']['object']['subscription']
                prometheus.DonationVendorTransactionCount.labels(
                    "success", "recurring", "stripe", cohort
                ).inc()
            # A subscription payment donation has failed
            case "invoice.payment_failed":
                message = "Tor\\Donation\\RecurringContributionOngoing"
                args["contribution_status_id"] = "Failed"
                args["total_amount"] = event["data"]["object"]["amount_paid"]
                args[
                    "recurring_contribution_transaction_id"
                ] = event['data']['object']['subscription']
                prometheus.DonationVendorTransactionCount.labels(
                    "failure", "recurring", "stripe", cohort
                ).inc()
            # We do not currently track other Stripe events
            case _:
                logger.info(
                    "A webhook event type not currently being tracked by CiviCRM has occured: {}"
                    .format(
                        event["type"]
                    )
                )

        # We want to ensure that webhook events important to us are only recorded once,
        # so we ensure that only events of a certain type are sent to CiviCRM,
        # and that we only send them once.
        if message and await self._civi.donation_exists(message, args["trxn_id"]) is False:
            try:
                await self._civi.report_donation(message, args)
            except ValueError:
                logger.error(
                    "An error occured when attempting to report this donation: %r",
                    args
                )
                raise
