import logging
import re
import typing as t
from base64 import b64encode
from datetime import datetime, timezone
from urllib.parse import parse_qsl

import requests

from .. import prometheus, settings
from ..civicrm.repository import CivicrmRepositoryMock

logger = logging.getLogger(__package__)

# Define a constant to mark the time donate-neo replaced donate-paleo in production,
# to deal properly with IPN messages (see PaypalController.process_ipn below)
WEBHOOK_EPOCH = datetime.fromisoformat("20240908T15:00:00Z")


class PaypalController:
    def __init__(
        self,
        client_id: str,
        app_secret: str,
        sub_product_id: str,
        webhook_id: str,
        trace_webhooks: bool,
        civi: CivicrmRepositoryMock,
    ):
        self.__client_id = client_id
        self.__app_secret = app_secret
        self.__sub_product_id = sub_product_id
        self.__webhook_id = webhook_id
        self.__trace_webhooks = trace_webhooks
        self._civi = civi
        self.base_url = "https://api-m.sandbox.paypal.com"
        self.ipn_url = "https://ipnpb.sandbox.paypal.com/cgi-bin/webscr"
        if settings.APP_ENV == "prod":
            self.base_url = "https://api-m.paypal.com"
            self.ipn_url = "https://ipnpb.paypal.com/cgi-bin/webscr"

        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>."""

    # Paypal's API requires that currency values be passed with decimal precision.
    # Because both CiviCRM and Stripe want donation values in "number of cents,"
    #   we use this method to ensure incoming values have been reformatted to x.xx USD.
    # Visit https://developer.paypal.com/docs/api/orders/v2/ for more information;
    # navigate to Request Body Schema > purchase_units > amount > value
    def isPaymentPaypalFormatted(self, payment: str) -> bool:
        try:
            return bool(re.match(r"\d+\.\d{2}", payment))
        except TypeError:
            logger.error(
                "Paypal donation value must be in format xx.xx, but was malformed: %s",
                payment
            )
            return False

    # API reference:
    # https://developer.paypal.com/api/rest/authentication/
    async def generate_access_token(self) -> str:
        auth_token = b64encode(
            f"{self.__client_id}:{self.__app_secret}".encode()
        ).decode()
        try:
            response = requests.post(
                f"{self.base_url}/v1/oauth2/token",
                data={"grant_type": "client_credentials"},
                headers={"Authorization": f"Basic {auth_token}"},
            )
            response.raise_for_status()
            return t.cast(str, response.json()["access_token"])

        except requests.HTTPError:
            logger.error(
                "HTTP error %s encountered when generating access token: %s",
                response.status_code,
                response.reason
            )
            raise ValueError(self.serviceErrorDisplayMessage)

    # API reference:
    # https://developer.paypal.com/docs/api/orders/v2/
    async def create_order(self, payment_amount: str) -> dict[str, t.Any]:
        if not self.isPaymentPaypalFormatted(payment_amount):
            raise ValueError(self.serviceErrorDisplayMessage)

        access_token = await self.generate_access_token()
        try:
            response = requests.post(
                f"{self.base_url}/v2/checkout/orders",
                headers={
                    "Content-Type": "application/json",
                    "Authorization": f"Bearer {access_token}",
                },
                json={
                    "intent": "CAPTURE",
                    "purchase_units": [
                        {
                            "amount": {
                                "currency_code": "USD",
                                "value": payment_amount,
                            },
                        },
                    ],
                },
            )
            response.raise_for_status()
            return {"id": response.json()["id"]}
        except requests.HTTPError:
            logger.error(
                "HTTP error %s encountered when creating order: %s",
                response.status_code,
                response.reason
            )
            raise ValueError(self.serviceErrorDisplayMessage)

    # API reference:
    # https://developer.paypal.com/docs/api/orders/v2/#orders_capture
    async def capture_payment(self, order_id: str) -> dict[str, t.Any]:
        access_token = await self.generate_access_token()
        try:
            response = requests.post(
                f"{self.base_url}/v2/checkout/orders/{order_id}/capture",
                headers={
                    "Content-Type": "application/json",
                    "Authorization": f"Bearer {access_token}",
                },
            )
            response.raise_for_status()
            data: dict[str, t.Any] = response.json()
        except requests.HTTPError:
            logger.error(
                "HTTP error %s encountered when capturing payment: %s",
                response.status_code,
                response.reason
            )
            raise ValueError(self.serviceErrorDisplayMessage)

        return data

    # API reference:
    # https://developer.paypal.com/docs/api/catalog-products/v1/#products_get
    async def verify_product(self, product_id: str) -> bool:
        access_token = await self.generate_access_token()
        try:
            response = requests.get(
                f"{self.base_url}/v1/catalogs/products/{product_id}",
                headers={
                    "Content-Type": "application/json",
                    "Authorization": f"Bearer {access_token}",
                },
            )
            response.raise_for_status()
            return True
        except requests.HTTPError:
            match response.status_code:
                case 404:
                    return False
                case _:
                    logger.error(
                        "Unexpected HTTP error %s encountered when verifying product: %s",
                        response.status_code,
                        response.reason
                    )
                    raise ValueError(self.serviceErrorDisplayMessage)

    # API reference:
    # https://developer.paypal.com/docs/api/catalog-products/v1/#products_create
    async def get_or_create_product(self) -> dict[str, t.Any]:
        try:
            product_id = await self.verify_product(self.__sub_product_id)
        except Exception:
            raise ValueError(self.serviceErrorDisplayMessage)

        if product_id is True:
            return {"id": self.__sub_product_id}

        access_token = await self.generate_access_token()
        try:
            response = requests.post(
                f"{self.base_url}/v1/catalogs/products",
                headers={
                    "Content-Type": "application/json",
                    "Authorization": f"Bearer {access_token}",
                },
                json={
                    "id": self.__sub_product_id,
                    "type": "DIGITAL",
                    "name": "Monthly recurring donation",
                },
            )
            response.raise_for_status()
            data: dict[str, t.Any] = response.json()
            return {"id": data["id"]}

        except requests.HTTPError:
            logger.error(
                "HTTP error %s encountered when creating product: %s",
                response.status_code,
                response.reason
            )
            raise ValueError(self.serviceErrorDisplayMessage)

    # API reference:
    # https://developer.paypal.com/docs/api/subscriptions/v1/#plans_create
    async def create_subscription(
        self, payment_amount: str, id: str
    ) -> dict[str, t.Any]:
        if not self.isPaymentPaypalFormatted(payment_amount):
            raise ValueError(self.serviceErrorDisplayMessage)

        access_token = await self.generate_access_token()
        try:
            response = requests.post(
                f"{self.base_url}/v1/billing/plans",
                headers={
                    "Content-Type": "application/json",
                    "Authorization": f"Bearer {access_token}",
                },
                json={
                    "product_id": id,
                    "name": f"{payment_amount} recurring payment",
                    "billing_cycles": [
                        {
                            "tenure_type": "REGULAR",
                            "sequence": 1,
                            "frequency": {
                                "interval_unit": "MONTH",
                                "interval_count": 1,
                            },
                            "total_cycles": 0,
                            "pricing_scheme": {
                                "fixed_price": {
                                    "currency_code": "USD",
                                    "value": payment_amount,
                                },
                            },
                        },
                    ],
                    "payment_preferences": {
                        "auto_bill_outstanding": "true",
                        "payment_failure_threshold": 3,
                        "setup_fee_failure_action": "CONTINUE",
                        "setup_fee": {
                            "currency_code": "USD",
                            "value": 0,
                        },
                    },
                },
            )
            response.raise_for_status()
            data: dict[str, t.Any] = response.json()
            return {"id": data["id"]}

        except requests.HTTPError:
            logger.error(
                "HTTP error %s encountered when creating and billing subscription: %s",
                response.status_code,
                response.reason
            )
            raise ValueError(self.serviceErrorDisplayMessage)

    async def validate_ipn(self, params: bytes) -> bool:
        validation_result = "failure"

        # Paypal will validate IPN data if and only if we add a magic parameter to it.
        # This parameter may need to be first in the URL parameters for Paypal to validate it:
        # https://developer.paypal.com/api/nvp-soap/ipn/IPNIntro/#link-ipnmessagegenerationandflow
        # (Ironically, Paypal's own legacy Python example does not do this:)
        # https://github.com/paypal/ipn-code-samples/blob/master/python/paypal_ipn.py
        params_full = (b"cmd=_notify-validate&%s" % params)

        # Note that as of 10-2024, Paypal's own legacy IPN Simulator is still available for use,
        # but _does not generate IPN messages which can be validated_, even by the sandbox IPN
        # validator. If there is a need for IPN test data beyond that included in this repository,
        # due to the above, you now can only obtain sandbox IPN messages by logging into
        # Paypal's management interface for legacy development sandboxes and retrieving IPN
        # messages regarding test data from prior sandbox testing:
        # https://www.sandbox.paypal.com/merchantnotification/ipn/history
        # This is complicated by Paypal's baffling decision to restrict access to historical IPN
        # message data to messages sent within the past month.
        # Regardless, once obtained, this message payload can then be sent to our IPN endpoint
        # (/paypal-ipn) by crafting a custom POST request with the appropriate POST body -
        # tools like Postman (https://www.postman.com/) help here.
        # For local development, webhook.site can act as a middleman between Postman and your local
        # development environment. (This is true with webhook-related development as well.)
        # For reference, the unsuitable-for-testing IPN Simulator can be found here:
        # https://developer.paypal.com/dashboard/ipnSimulator

        try:
            headers = {
                "content-type": "application/x-www-form-urlencoded",
                "user-agent": "Python-IPN-Verification-Script"
            }
            response = requests.post(
                self.ipn_url, params=params_full, headers=headers, verify=True
            )
            response.raise_for_status()
            logger.info(
                "The response to our IPN validation request, %s, was sent with code %s.",
                response.text,
                response.status_code
            )
            if response.text == "VERIFIED":
                validation_result = "success"
                return True
            else:
                return False
        except requests.HTTPError:
            logger.error(
                "HTTP error %s encountered when attempting to verify IPN: %s",
                response.status_code,
                response.reason
            )
            raise ValueError(self.serviceErrorDisplayMessage)
        finally:
            # TODO: missing cohort here
            cohort = "unknown"
            prometheus.PaypalIPNValidation.labels(validation_result, cohort).inc()

    # Paypal IPN timestamps adhere to no particular timestamp but are, at least, formatted
    # reliably: "HH:MM:SS Mmm DD, YYYY PDT". Here, "PDT" is a hardcoded timezone abbreviation
    # (as per Paypal's documentation). Since datetime.strptime's support for parsing timezones
    # by abbreviation is intentionally meagre (as per discussion on Python's Github), and since
    # the message's timezone is *always* set to PDT, we simply convert it to a numeric
    # timestamp before handing off to strptime. Then we convert the datetime object to UTC, so
    # that the timestamp will match all the UTC timestamps from webhooks, and output to string
    # via datetime.strftime (again, matching the output pattern used when processing webhooks).
    # IPN timestamp info: https://developer.paypal.com/api/nvp-soap/ipn/IPNandPDTVariables/#payment-information  # noqa: E501
    # Relevant Python Github issue regarding strptime's %Z matching: https://github.com/python/cpython/issues/66571  # noqa: E501
    def ipn_time_to_datetime(self, ipn_time: str) -> datetime:
        # Replace timestamp whether or not it's daylight savings time in the US
        ipn_time = ipn_time.replace("PDT", "-0700").replace("PST", "-0800")
        return datetime.strptime(
            ipn_time,
            "%H:%M:%S %b %d, %Y %z"
        ).astimezone(
            timezone.utc
        )

    async def process_ipn(self, params: bytes) -> None:
        billing_result = "failure"
        # At last, we no longer need to preserve the specific ordering of the IPN message contents,
        # and so we convert it from a URL query bytestring to a dict of strings.
        # (Note that urllib.parse.parse_qs purports to return the dict directly, but it wraps all
        # querystring values in a list - e.g., {"foo": ["bar"]} rather than {"foo": "bar"} -
        # ergo making a dict out of parse_qsl's returned list is tidier in the end.)
        param_dict = dict(parse_qsl(
            params.decode("utf-8"),
            keep_blank_values=True
        ))
        # We can assemble the same payload of donation information from IPN messages
        # that we do from webhooks, so we pick and choose the particular information from it
        # which will allow us to hand this info off to the rest of the donation-processing
        # pipeline.
        # (IPN messages we care about always, and only, relate to ongoing subscriptions, so we can
        # hardcode the message and make some other assumptions about what's in `args`.)
        message = "Tor\\Donation\\RecurringContributionOngoing"

        # IPN messages can be generated for new donations as well as legacy donations. However,
        # all new donations (and all recurring donations started after donate-neo's launch)
        # are already tracked by webhook, so it's important to keep from sending CiviCRM duplicate
        # donation data. Since we only care about IPN messages regarding legacy recurring
        # donations, we throw out IPN messages regarding one-time donations or recurring donations
        # whose time_created value (which refers to the first donation made in a recurring series)
        # is later than donate-neo's launch date.
        if param_dict.get("txn_type") != "recurring_payment":
            logger.info(
                "Valid IPN received, but not one associated with a recurring payment."
            )
            return
        else:
            creation_time = self.ipn_time_to_datetime(param_dict["time_created"])
            if creation_time >= WEBHOOK_EPOCH:
                logger.info(
                    "Valid IPN for non-legacy payment received; ignoring in favor of webhook."
                )
                return

        timestamp = self.ipn_time_to_datetime(
            param_dict["payment_date"]
        ).strftime(
            "%Y-%m-%d %H:%M:%S"
        )

        args = {
            "payment_instrument_id": "PayPal",
            "receive_date": timestamp,
            "trxn_id": param_dict["recurring_payment_id"],
            "handshake_id": param_dict["recurring_payment_id"],
            "recurring_contribution_transaction_id": param_dict["recurring_payment_id"],
            "currency": param_dict["mc_currency"],
            "total_amount": param_dict["amount"],
            # The common states of "contribution_status_id", due to the manner in which
            # the donate-to-civi pipeline was originally constructed, match the values
            # of the IPN message's "payment_status" value.
            # See: https://developer.paypal.com/api/nvp-soap/ipn/IPNandPDTVariables/#payment-information  # noqa: E501
            "contribution_status_id": param_dict["payment_status"]
        }

        if args["contribution_status_id"] == "Completed":
            billing_result = "success"
        # TODO: missing cohort here
        cohort = "unknown"
        prometheus.DonationVendorTransactionCount.labels(
            billing_result, "recurring", "paypal", cohort,
        ).inc()

        # Ensure that donations are deduped before being handed off for processing.
        # In the event that PayPal sends both an IPN message and a webhook message about the
        # same legacy transaction, we need to use a deduping value available to both message
        # types; since `txn_id` is an IPN-exclusive value, we instead use `recurring_payment_id`.
        # Note that this ID is unique to this transaction series, and not this individual
        # transaction; since recurring transactions only bill once a month, and since we only
        # check for duplicate transaction messages for an hour after the first message, this
        # value is both unique enough and broadly-available enough for our purposes.
        if 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

    # Because incoming webhook data is CSRF-exempt and therefore more prone to
    # potential malfeasance, we verify all incoming requests to this route with
    # Paypal. Note that self.__webhook_id is not passed to us from the incoming
    # request, but instead generated on the Paypal developer dashboard and
    # stored with the rest of our private keys.
    # API reference:
    # https://developer.paypal.com/docs/api/webhooks/v1/#verify-webhook-signature
    async def validate_webhook(self, headers: dict, body: dict) -> dict:
        access_token = await self.generate_access_token()
        validation_result = "failure"
        try:
            response = requests.post(
                f"{self.base_url}/v1/notifications/verify-webhook-signature",
                headers={
                    "Content-Type": "application/json",
                    "Authorization": f"Bearer {access_token}",
                },
                json={
                    "transmission_id": headers["paypal-transmission-id"],
                    "transmission_time": headers["paypal-transmission-time"],
                    "cert_url": headers["paypal-cert-url"],
                    "auth_algo": headers["paypal-auth-algo"],
                    "transmission_sig": headers["paypal-transmission-sig"],
                    "webhook_id": self.__webhook_id,
                    "webhook_event": body,
                },
            )
            response.raise_for_status()
            data: dict[str, t.Any] = response.json()
            validation_result = "success"
            return data
        except requests.HTTPError:
            logger.error(
                "HTTP error %s encountered when validating webhook: %s",
                response.status_code,
                response.reason
            )
            raise ValueError(self.serviceErrorDisplayMessage)
        finally:
            # TODO: pass the right cohort here
            cohort = "unknown"
            prometheus.WebhookValidation.labels(validation_result, "paypal", cohort).inc()

    async def process_webhook(self, event: dict) -> None:
        # Log webhook in full, if appropriate
        if self.__trace_webhooks:
            logger.info(
                "Valid webhook received: %r",
                event
            )

        # An ISO 8601-formatted timestamp with a trailing Z simply indicates UTC.
        #   (Other timezones would be expressed as their offset from UTC: <time>±hh:mm,
        #   for example, rather than <time>Z.)
        # Ergo, we do not perform any offset relative to timezones, and merely convert the
        # timestamp to the format CiviCRM wants (Year-Month-Day Hour:Minute:Second).
        args = {
            "payment_instrument_id": "PayPal",
            "receive_date": datetime.fromisoformat(event["create_time"]).strftime(
                "%Y-%m-%d %H:%M:%S"
            ),
        }

        message = None
        # TODO: pass the right cohort here
        cohort = "unknown"
        match event["event_type"]:
            # One-time donation has succeeded
            case "PAYMENT.CAPTURE.COMPLETED":
                message = "Tor\\Donation\\OneTimeContribution"
                # The event resource ID is what PayPal uses to identify the transaction on the
                # non-developer transaction history pages, so it's helpful for CiviCRM to use it
                # as its ID, as then transactions can be rectified between Paypal and CiviCRM
                # more straightforwardly by humans.
                args["trxn_id"] = event["resource"]["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.
                # Paypal one-time donations make this straightforward - the ID is available on
                # both the order success data and the webhook confirmation message in that case.
                args["handshake_id"] = event[
                    "resource"
                ]["supplementary_data"]["related_ids"]["order_id"]
                args["currency"] = event["resource"]["amount"]["currency_code"]
                args["total_amount"] = event["resource"]["amount"]["value"]
                args["contribution_status_id"] = "Completed"
                prometheus.DonationVendorTransactionCount.labels(
                    "success", "single", "paypal", cohort
                ).inc()
            # One-time donation has failed
            # CiviCRM does not track these, but this information might be useful to us,
            # so we log it and prevent CiviCRM from hearing about it.
            case "PAYMENT.CAPTURE.DECLINED":
                logger.info(
                    "A one-time Paypal payment was attempted but failed, with ID: {}"
                    .format(
                        event["resource"]["supplementary_data"]["related_ids"]["order_id"]
                    )
                )
                prometheus.DonationVendorTransactionCount.labels(
                    "failure", "single", "paypal", cohort
                ).inc()
            # A subscription's donation has succeeded
            case "PAYMENT.SALE.COMPLETED":
                message = "Tor\\Donation\\RecurringContributionOngoing"
                args["trxn_id"] = event["resource"]["id"]
                # In the case of new Paypal subscriptions, the only ID string found both in the
                # post-donation success data _and_ the webhook confirming the payment went through
                # is the billing agreement ID. This works fine in practice, but it is the one-off
                # ID which lives somewhere other than its peers & which we accomodate differently.
                args["handshake_id"] = event["resource"]["billing_agreement_id"]
                args["recurring_contribution_transaction_id"] = event["resource"][
                    "billing_agreement_id"
                ]
                args["currency"] = event["resource"]["amount"]["currency"]
                args["total_amount"] = event["resource"]["amount"]["total"]
                args["contribution_status_id"] = "Completed"
                prometheus.DonationVendorTransactionCount.labels(
                    "success", "recurring", "paypal", cohort
                ).inc()
            # A subscription's donation has failed
            case "BILLING.SUBSCRIPTION.PAYMENT.FAILED":
                message = "Tor\\Donation\\RecurringContributionOngoing"
                args["trxn_id"] = event["id"]
                # Because this is a webhook message generated for a billing event rather than a
                # payment event, note that the ID we use to identify subscriptions has moved
                # from ["resource"]["billing_agreement_id"] to ["resource"]["id"].
                args["handshake_id"] = event["resource"]["id"]
                args["recurring_contribution_transaction_id"] = event["resource"]["id"]
                args["currency"] = event["resource"]["billing_info"][
                    "outstanding_balance"
                ]["currency_code"]
                args["total_amount"] = event["resource"]["billing_info"][
                    "outstanding_balance"
                ]["value"]
                args["contribution_status_id"] = "Failed"
                prometheus.DonationVendorTransactionCount.labels(
                    "failure", "recurring", "paypal", cohort
                ).inc()
            # We do not currently track other Paypal events
            case _:
                logger.info(
                    "A webhook event type not currently being tracked by CiviCRM has occured: {}"
                    .format(
                        event["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
