import json
import logging
import secrets
import typing as t
from json import JSONDecodeError
from time import time as timestamp
from typing import cast

from django.conf import settings
from django.contrib import messages
from django.http import HttpRequest, HttpResponseRedirect
from django.shortcuts import redirect

from .. import prometheus
from ..redis import RedisController
from ..utils import WithSyncProtocol, get_client_ip, with_sync
from .models import Perk

logger = logging.getLogger(__package__)


class CivicrmRepositoryProtocol(t.Protocol):
    """Protocol for CiviCRM repository implementations.

    Django isn't entirely async-ready yet, but we're using async anyway to provide a smooth
    transition path. Sync views can call these methods with `asgiref.sync.async_to_sync`.
    """

    async def get_active_perks(self) -> list[Perk]: ...

    async def get_yec_total(self) -> int: ...

    async def is_yec(self) -> bool: ...

    async def minimum_donation(self) -> int: ...

    async def get_perk_by_id(self, perk_id: str) -> t.Optional[Perk]: ...

    async def handle_donation_form_data(
        self, request: HttpRequest, donationInfo: dict, transactionId: str
    ) -> None: ...

    async def queue_donation(self, transactionId: str, donationInfo: dict) -> None: ...

    async def retrieve_donation(self, transactionId: str) -> t.Union[str, None]: ...

    async def report_donation(self, message: str, args: dict) -> None: ...

    async def report_action(self, message: str, args: dict) -> None: ...

    async def newsletter_signup_key(self) -> str: ...

    async def newsletter_signup(self, donation_form: dict) -> str: ...

    async def confirm_subscription(self, request: HttpRequest) -> HttpResponseRedirect: ...

    async def count_subscription_request(self) -> None: ...

    async def count_subscription_confirm(self) -> None: ...

    async def subscription_request(self, subscriptionInfo: dict) -> None: ...

    async def mailing_action(self, request: HttpRequest, action: str) -> HttpResponseRedirect: ...

    async def note_donation(self, key: str, val: str) -> None: ...

    async def donation_exists(self, type: str, id: str) -> bool: ...

    async def donation_transaction_counter(
        self, message: str, status: str, cohort: str
    ) -> None: ...


class CivicrmRepositoryMock(CivicrmRepositoryProtocol, metaclass=WithSyncProtocol):
    def __init__(
        self,
        redis: RedisController,
        yec_total: int,
        is_yec: bool,
        minimum_donation: int,
    ):
        def load_perks_from_file() -> list[Perk]:
            perk_list = []
            with open('./perks.json') as f:
                perks_from_json = json.load(f)
            for perk in perks_from_json["perks"]:
                perk_list.append(Perk(
                    id=perk["id"],
                    name=perk["product_id.name"],
                    desc=perk["product_id.description"],
                    single_price=perk["product_id.price"],
                    monthly_price=perk["product_id.Swag.Minimum_Recurring_Amount"],
                    image_data=perk["product_id.imageForDonateNeo"],
                    image_alt_text=perk["product_id.Swag.Image_alt_text"],
                    options=perk["product_id.optionsForDonateNeo"],
                ))
            return perk_list

        self._redis = redis
        self._yec_total = yec_total
        self._is_yec = is_yec
        self._minimum_donation = minimum_donation
        self.env = settings.APP_ENV
        self._active_perks = load_perks_from_file()

    @with_sync
    async def get_active_perks(self) -> list[Perk]:
        return self._active_perks

    # TODO: determine why mypy complains about get_active_perks_sync
    def get_active_perks_synced(self) -> list[Perk]:
        return self._active_perks

    @with_sync
    async def get_yec_total(self) -> int:
        return self._yec_total

    @with_sync
    async def is_yec(self) -> bool:
        return self._is_yec

    @with_sync
    async def minimum_donation(self) -> int:
        return self._minimum_donation

    @with_sync
    async def get_perk_by_id(self, perk_id: str) -> t.Optional[Perk]:
        for perk in await self.get_active_perks():
            if perk.id == perk_id:
                return perk
        return None

    @with_sync
    async def handle_donation_form_data(
        self,
        request: HttpRequest,
        donationInfo: dict,
        transactionId: str
    ) -> None:
        # Clean up form data before sending to CiviCRM.
        # Note that here, as elsewhere, we prune donation_amount as, when it is present, it
        # gives us the same information as custom_donation. We could prune custom_donation as well,
        # as CiviCRM ignores it in favor of verified webhook values, but it's helpful for logging.
        for key_to_remove in [
            "csrfmiddlewaretoken",
            "altcha",
            "payload",
            "recurring",
            "single_or_monthly",
        ]:
            if key_to_remove in donationInfo:
                donationInfo.pop(key_to_remove)

        # Attach user's IP for logging purposes in case payment validation fails vendor-side
        donationInfo["ip"] = get_client_ip(request.META)

        # Provide deterministic key for Civicrm-side perk option retrieval
        if donationInfo["perk"] != "no_gift":
            perk_option = ""
            for key, value in donationInfo.items():
                if key.startswith("perk_option--" + donationInfo["perk"]):
                    perk_option = value
            if perk_option:
                donationInfo["perk_option"] = perk_option

        try:
            await self.queue_donation(transactionId, donationInfo)
        except ValueError:
            if self.env == "prod":
                logger.error(
                    "Couldn't queue donation to be verified by webhook, with ID %s, for %s, at %s",
                    transactionId,
                    donationInfo["custom_donation"],
                    timestamp()
                )
            else:
                logger.error(
                    "Couldn't queue donation to be verified by webhook with given values: %s %r",
                    transactionId,
                    donationInfo
                )
            raise
        return

    # When a donation is made, we queue that donation's form data before it is sent to CiviCRM
    # in order to ensure the payment it's associated with is confirmed by its payment gateway
    # (to prevent fraud).
    # queue_donation() accepts a transactionId, which is an identifier unique to the donation which
    # can be paired with a matching identifier in a message sent to our webhook endpoint by the
    # aforementioned payment gateway (Stripe or Paypal).
    # It also accepts donationInfo, a dict containing a subset of form data from that donation
    # relevant to CiviCRM.
    # We queue this data in Redis and set it to expire after an hour.
    @with_sync
    async def queue_donation(self, transactionId: str, donationInfo: dict) -> None:
        key = "donation:" + transactionId
        self._redis.connection.set(key, json.dumps(donationInfo))
        self._redis.connection.expire(key, 3600)
        if self.env == "prod":
            logger.info(
                "Queued a donation to be verified by webhook, with ID %s, for %s, at %s",
                transactionId,
                donationInfo["custom_donation"],
                timestamp()
            )
        else:
            logger.info(
                "Queued a donation to be verified by webhook: %s %r",
                key,
                donationInfo
            )

    # Later, when a payment gateway does send our webhook endpoint a message confirming that a
    # given transaction is valid and has been processed, retrieve_donation() is called, and
    # passed that aforementioned shared identifier.
    @with_sync
    async def retrieve_donation(self, transactionId: str) -> t.Union[str, None]:
        donation = self._redis.connection.getdel(
            "donation:" + transactionId
        )
        # Redis' GETDEL returns Union[Awaitable[Any], Any], but since we're trying to retrieve a
        # string from Redis if one exists, we'd like to return a str or None. This is complicated
        # by Redis' tendency to occasionally, but not always, return a bytestring representation
        # of the string we sent it. Ergo, we explicitly handle the None case, explicitly handle
        # the bytestring case, and then cast the resultant payload as a str to soothe Mypy's
        # that it might secretly be an int or bool.
        if donation is None:
            logger.warn(
                "Tried to verify donation via webhook, but couldn't find form submission data: %s",
                transactionId
            )
            return None
        else:
            logger.info("Verified a donation id %s via webhook", transactionId)
            if isinstance(donation, bytes):
                donation = donation.decode("utf-8")
            return t.cast(str, donation)

    # report_donation() is called by both Stripe and Paypal's webhook handlers, once an
    # incoming webhook report has been verified and deemed relevant to CiviCRM. When that
    # is so, those webhook handlers prepare the two things that tordonate's implementation of
    # Resque needs to queue a donation-related report with CiviCRM:
    #   1. A message, which maps roughly to a method on CiviCRM's end
    #   2. Arguments passed to that method.
    # While these two pieces of data could technically be fed directly to
    # tordonate.redis.resque_send(), report_donation() is called with them first,
    # as it has two important jobs to do first:
    #   1. Deduplicate the message - both payment gateways place this onus upon you
    #   2. Retrieve and attach donation form data, when webhook messages relate to form submissions
    @with_sync
    async def report_donation(self, message: str, args: dict) -> None:
        # Donations should only be reported to CiviCRM if they haven't been reported yet; ergo,
        # we mark receipt of confirmed donations to dedupe any further webhook notifs from vendor
        await self.note_donation(message + ":" + args["trxn_id"], args["receive_date"])
        # We then retrieve related donation form data, if any, to attach to the report.
        # Note that we do this with args["handshake_id"] rather than ["trxn_id"] - this is because
        # the individual transaction ID available after a user's donation does not map to the
        # webhook message intended to serve as the confirmation of a subscription's billing.
        # Therefore, we use a specific ID to perform this identity handshake which is usually,
        # but not always, ["trxn_id"], & is occasionally ["recurring_contribution_transaction_id"].
        donation = await self.retrieve_donation(args["handshake_id"])
        if donation is not None:
            args["donation"] = json.loads(donation)
            # If we have form data, and the donation was unsuccessful, then this is a
            # recent donation which was initially accepted, but ultimately rejected, by
            # the payment processor.
            # This most commonly occurs when the card data is suspected to have been stolen.
            # Ergo, we log failed transactions with the user's IP to track and stop abuse.
            if args["contribution_status_id"] == "Failed":
                logger.error(
                    "User made donation, but payment processor declined the transaction; IP address %s",  # noqa: E501
                    args["donation"]["ip"]
                )
        # Hand off confirmed donation info to Resque handler for delivery to CiviCRM
        try:
            await self._redis.resque_send(message, args)
            logger.info(
                "Successful CiviCRM donation record %s sent, with ID %s for %s at %s",
                message,
                args.get("trxn_id"),
                args.get("total_amount"),
                timestamp(),
            )
            if self.env != "prod":
                logger.debug("Successful CiviCRM donation record sent, args dump: %r", args)
            # TODO: pass the right cohort here
            cohort = "unknown"
            await self.donation_transaction_counter(
                message, args["contribution_status_id"], cohort
            )
        except ValueError:
            logger.error(
                "Failed to send CiviCRM donation record %s with ID %s for %s at %s",
                message,
                args.get("trxn_id"),
                args.get("total_amount"),
                timestamp()
            )
            if self.env != "prod":
                logger.debug("CiviCRM donation reporting error args dump: %r", args)
            raise

    # For other actions which CiviCRM wants to know about - chiefly newsletter-related actions -
    # we don't need to do anything fancy, we just pass the message and args along to Redis.
    @with_sync
    async def report_action(self, message: str, args: dict) -> None:
        # Hand off action info to Resque handler for delivery to CiviCRM
        try:
            await self._redis.resque_send(message, args)
            logger.info("Sent a message to CiviCRM: %s", message)
            logger.debug("Successful CiviCRM message reporting args dump: %r", args)
        except ValueError:
            logger.error("Error sending message to CiviCRM: %s", message)
            logger.debug("CiviCRM message reporting error args dump: %r", args)
            raise

    # Generate a sufficiently-unique alphanumerical key with which to store
    # the data associated with a signup intent
    @with_sync
    async def newsletter_signup_key(self) -> str:
        return "subscription_request:" + secrets.token_urlsafe(32)

    @with_sync
    async def newsletter_signup(self, donation_form: dict) -> str:
        # Generate key for Redis storage of the signup intent data, set it in Redis,
        # and set its expiration time to an hour from now
        key = await self.newsletter_signup_key()
        res = self._redis.connection.set(key, json.dumps(donation_form))
        # Set the signup intent request to expire after one day
        self._redis.connection.expire(key, 24*60*60)
        # Send the user's email address and the signup-intent key to CiviCRM
        # via Resque so CiviCRM can generate a "confirm your subscription" email
        # which links back to the appropriate route with the appropriate key
        await self._redis.resque_send("Tor\\Subscription\\SendConfirm", {
            "email": donation_form["email_address"],
            "token": key,
        })
        if self.env == "prod":
            logger.info(
                "Sent newsletter signup confirmation to CiviCRM: Used token %s at time %s",
                key,
                timestamp()
            )
        else:
            logger.info(
                "Sent newsletter signup confirmation for %s to CiviCRM: Used token %s at time %s",
                donation_form["email_address"],
                key,
                timestamp()
            )
        # Iterate the "subscription requests" counter
        await self.count_subscription_request()
        return str(res)

    @with_sync
    async def confirm_subscription(self, request: HttpRequest) -> HttpResponseRedirect:
        # Reject subscription confirmations without a token
        if request.GET.get("token") is None:
            messages.error(request, "No valid token found in URL.")
            logger.error(
                "No valid token found in newsletter subscription URL; confirmation failed."
            )
        else:
            # Retrieve the data at the provided key, deleting it from Redis if it exists.
            sub_req = self._redis.connection.getdel(
                "subscription_request:" + request.GET.get("token")
            )
            # If it doesn't exist, handle it so we don't throw a KeyError.
            if sub_req is None:
                messages.error(
                    request,
                    "{} {}".format(
                        "Sorry, your previous subscription request has expired.",
                        "Feel free to resubmit the form and try again."
                    )
                )
                logger.error(
                    "Given subscription token didn't match any stored requests."
                )
            else:
                try:
                    # Unpack stored subscription request's data back into a Dict.
                    # (We cast sub_req to str here because, when we add this data to Redis
                    # in newsletter_signup(), we set the value of the key-value pair with
                    # json.dumps(), which always returns a str. However, redis.getdel() returns
                    # Union[Awaitable[Any], Any], and so mypy will complain unless we make
                    # sub_req's type more explicit.)
                    args = json.loads(cast(str, sub_req))
                    # Reject subscription confirmations without necessary subscription info
                    if "email_address" not in args:
                        messages.error(request, "Signup information incomplete.")
                        logger.error("Signup information incomplete; confirmation failed.")
                except JSONDecodeError:
                    messages.error(
                        request,
                        "{} {}".format(
                            "Something unexpected happened with your subscription request.",
                            "Please resubmit the form and try again."
                        )
                    )
                    logger.error(
                        "Subscription request received but stored data was not valid JSON."
                    )

        # If errors encountered, redirect user to standalone subscription form
        if len(messages.get_messages(request)):
            logger.error(
                "Since subscription confirmation failed, redirecting user to /subscribe/."
            )
            return redirect("/subscribe/")
        else:
            await self.report_action(
                "Tor\\Mailing\\Subscribe",
                args
            )
            # Iterate the "subscriptions confirmed" counter
            await self.count_subscription_confirm()
            return redirect("/subscribed/")

    @with_sync
    async def count_subscription_request(self) -> None:
        res = self._redis.connection.incr(self.env + "_subscription_request_count")
        logger.info("Iterated subscription request counter: %s", res)

    @with_sync
    async def count_subscription_confirm(self) -> None:
        res = self._redis.connection.incr(self.env + "_subscription_confirmed_count")
        logger.info("Iterated subscription confirmation counter: %s", res)

    @with_sync
    async def mailing_action(self, request: HttpRequest, action: str) -> HttpResponseRedirect:
        errors = False
        # Define the valid mailing actions and reject traffic which doesn't attempt one of them
        actions = [
            'optout',
            'resubscribe',
            'unsubscribe',
        ]
        if action not in actions:
            logger.error(
                "Mailing action URL was requested but no action was provided; request discarded."
            )
            raise KeyError("No valid action found in URL.")

        # Pull necessary args out of URL parameters and reject requests which don't contain them
        args = {
            "hash": request.GET.get("h"),
            "job_id": request.GET.get("jid"),
            "method": action,
            "queue_id": request.GET.get("qid"),
        }

        for arg in args:
            if args[arg] is None:
                errors = True
                messages.error(request, "No value for {} found in URL parameters".format(arg))

        if errors is False:
            await self.report_action(
                "Tor\\Mailing\\SubscriptionChange",
                args
            )
            return redirect("/" + action + "/")
        else:
            logger.error(
                """Mailing action URL was requested but not all necessary URL params were present;
redirecting user to /subscribe/"""
            )
            return redirect("/subscribe/")

    @with_sync
    async def note_donation(self, key: str, val: str) -> None:
        self._redis.connection.set(key, val)
        # Webhooks should be done spamming dupe messages after five minutes;
        # keeping this time down keeps the shared Redis pool cleaner
        self._redis.connection.expire(key, 300)
        logger.debug("Noted donation message from webhook for deduping: %s %s", key, val)

    @with_sync
    async def donation_exists(self, type: str, id: str) -> bool:
        if self._redis.connection.get(type + ":" + id):
            logger.debug(
                "Checked for pre-existing donation with this ID, and found one: %s %s", type, id)
            return True
        logger.debug(
            "Checked for pre-existing donation with this ID, but didn't find anything: %s %s",
            type,
            id
        )
        return False

    # Ensure donations, successful or no, are recorded in Prometheus
    @with_sync
    async def donation_transaction_counter(self, message: str, status: str, cohort: str) -> None:
        match message:
            case "Tor\\Donation\\OneTimeContribution":
                prometheus.DonationTransactionCount.labels("single", status, cohort).inc()
            case "Tor\\Donation\\RecurringContributionOngoing":
                prometheus.DonationTransactionCount.labels("recurring", status, cohort).inc()
