"""
edx_ledger models.
"""
from contextlib import contextmanager
from uuid import uuid4

from django.core.cache import cache as django_cache
from django.db import models
from django.db.models import Q
from django.db.models.functions import Coalesce
from django.db.transaction import atomic
from edx_django_utils.cache.utils import get_cache_key
from jsonfield.fields import JSONField
from model_utils.models import TimeStampedModel
from simple_history.models import HistoricalRecords

from openedx_ledger.utils import create_idempotency_key_for_ledger

LEDGER_LOCK_RESOURCE_NAME = 'ledger'


class UnitChoices:
    USD_CENTS = 'usd_cents'
    SEATS = 'seats'
    CHOICES = (
        (USD_CENTS, 'U.S. Dollar (Cents)'),
        (SEATS, 'Seats in a course'),
    )


class TransactionStateChoices:
    """
    Lifecycle states for a ledger transaction.

    CREATED
        Indicates that the transaction has only just been created, and should be the default state.

    PENDING
        Indicates that an attempt is being made to redeem the content in the target LMS.

    COMMITTED
        Indicates that the content has been redeemed, and a reference to the redemption result (often an enrollment ID)
        is stored in the reference_id field of the transaction.
    """

    CREATED = 'created'
    PENDING = 'pending'
    COMMITTED = 'committed'
    FAILED = 'failed'
    CHOICES = (
        (CREATED, 'Created'),
        (PENDING, 'Pending'),
        (COMMITTED, 'Committed'),
        (FAILED, 'Failed'),
    )


class AdjustmentReasonChoices:
    """
    Allowed choices for the ``Adjustment.reason`` field.
    """
    UNAUTHROZIED_ENROLLMENT = 'unauthorized_enrollment'
    POOR_CONTENT_FIT = 'poor_content_fit'
    TECHNICAL_CHALLENGES = 'technical_challenges'
    MISSED_REFUND_OR_DATE = 'missed_refund_or_date'
    GOOD_FAITH = 'good_faith'
    CHOICES = (
        (UNAUTHROZIED_ENROLLMENT, 'Unauthorized enrollment'),
        (POOR_CONTENT_FIT, 'Poor content fit'),
        (TECHNICAL_CHALLENGES, 'Technical challenges'),
        (MISSED_REFUND_OR_DATE, 'Missed refund or date'),
        (GOOD_FAITH, 'Good faith/Relationship building'),
    )


class LedgerLockAttemptFailed(Exception):
    """
    Raise when attempt to lock Ledger failed due to an already existing lock.
    """


class TimeStampedModelWithUuid(TimeStampedModel):
    """
    Base timestamped model adding a UUID field.
    """

    class Meta:
        """
        Metaclass for TimeStampedModelWithUuid.
        """

        abstract = True

    uuid = models.UUIDField(
        primary_key=True,
        default=uuid4,
        editable=False,
        unique=True,
    )


class Ledger(TimeStampedModelWithUuid):
    """
    A ledger to which you can add or remove value, associated with a single subsidy plan.

    All value quantities associated with this ledger uniformly share the same unit, established in the `unit` field of
    the ledger.  This enables simple balancing using `sum()` functions, helping to prevent bugs caused by unit
    conversions, and improving performance on the critical balance() method.

    .. no_pii:
    """
    idempotency_key = models.CharField(
        max_length=255,  # https://docs.djangoproject.com/en/3.2/ref/databases/#mysql-character-fields
        blank=True,
        null=False,
        unique=True,
        help_text=(
            'An idempotency key is a unique value generated by the client of the Ledger API '
            'which the Ledger and Transaction APIs use to recognize subsequent retries '
            'of the same redemption (i.e. a Transaction that leads to the fulfillment of '
            'an enrollment or entitlement for a particular user id in a particular content key.'
            'We suggest incorporating V4 UUIDs along with sufficiently unique or random '
            'values representing the desired redemption to avoid unintended collisions.'
            'Utility methods are provided in the ``utils.py`` module to help clients generate appropriate '
            'idempotency keys.'
        ),
    )
    unit = models.CharField(
        max_length=255,
        blank=False,
        null=False,
        choices=UnitChoices.CHOICES,
        default=UnitChoices.USD_CENTS,
        db_index=True,
        help_text=(
            'The unit in which Transaction quantities related to this Ledger instance are denominated.'
        ),
    )
    metadata = JSONField(
        blank=True,
        null=True,
        help_text=(
            'Any additionaly metadata that a client may want to associate with this Ledger instance.'
        )
    )
    history = HistoricalRecords()

    def subset_balance(self, transactions_queryset):
        """
        Calculate the current balance of the ledger, optionally on a subset of transactions.

        WARNING: The queryset must be a strict subset of self.transactions.  If not, then the return value will
        represent only the set intersection of the given transactions_queryset and self.transactions.

        Args:
            transaction_subset (queryset of openedx_ledger.models.Transaction):
                Transactions to evaluate for the balance calculation.

        Returns:
            int: The total balance of all or a subset of transactions in this ledger.  Possibly a negative value if the
            only transactions evaluated are ones that represent un-reversed enrollment fulfillments.
        """
        with atomic():
            transactions_queryset = transactions_queryset.filter(ledger=self).select_related('reversal')
            agg = transactions_queryset.annotate(
                row_total=Coalesce(models.Sum('quantity'), 0) + Coalesce(models.Sum('reversal__quantity'), 0)
            )
            agg = agg.aggregate(total_quantity=Coalesce(models.Sum('row_total'), 0))
            return agg['total_quantity']

    def balance(self, committed_only=False):
        """
        Calculate the current balance of the ledger.

        TODO: propagate committed_only down into the reversals too.

        Args:
            committed_only (boolean): If true, computes balance only for `committed` transations.  Defaults to False.

        Returns:
            int: The total balance of non-failed transactions in this ledger.
        """
        if committed_only:
            queryset = Transaction.objects.filter(ledger=self, state=TransactionStateChoices.COMMITTED)
        else:
            queryset = Transaction.objects.filter(ledger=self).exclude(state=TransactionStateChoices.FAILED)
        return self.subset_balance(queryset)

    def total_deposits(self):
        """
        Calculate and return the total value added to this ledger.

        Note that this not only factors actual deposits, but also all adjustments which essentially represent
        ad-hoc manipulations of the total value added.

        IMPORTANT NOTE: Until ENT-9132 is completed (backfill initial deposits), this method will be inaccurate in prod.
        Do not call this method until completing ENT-9132!
        """
        # Custom filter that searches for anything that looks and smells like a deposit, including actual deposits.
        deposit_esque_filter = Q(deposit__isnull=False) | Q(adjustment__isnull=False)
        # Limit results to just deposits committed to this ledger.
        queryset = Transaction.objects.filter(
            deposit_esque_filter,
            ledger=self,
            state=TransactionStateChoices.COMMITTED,
        )
        # Finally, subsidy_blance further factors in reversals.
        return self.subset_balance(queryset)

    @property
    def lock_resource_key(self) -> str:
        return get_cache_key(resource=LEDGER_LOCK_RESOURCE_NAME, uuid=self.uuid)

    def acquire_lock(self) -> str:
        """
        Acquire an exclusive lock on this Ledger instance.

        Memcached devs recommend using add() for locking instead of get()+set(), which rules out TieredCache which only
        exposes get()+set() from django cache.  See: https://github.com/memcached/memcached/issues/163

        Returns:
            str: lock ID if a Ledger lock was successfully acquired, None otherwise.
        """
        lock_id = uuid4()
        if django_cache.add(self.lock_resource_key, lock_id):
            return lock_id
        else:
            return None

    def release_lock(self) -> None:
        """
        Release an exclusive lock on this Ledger instance.
        """
        django_cache.delete(self.lock_resource_key)

    @contextmanager
    def lock(self):
        """
        Context manager for locking this Ledger instance.

        Raises:
            openedx_ledger.models.LedgerLockAttemptFailed:
                Raises this if there's another distributed process locking this Ledger.
        """
        lock_id = self.acquire_lock()
        if not lock_id:
            raise LedgerLockAttemptFailed(f"Failed to acquire lock <{lock_id}> on Ledger {str(self)}.")
        try:
            yield lock_id
        finally:
            self.release_lock()

    def save(self, *args, **kwags):
        """
        Sets the idempotency_key for the ledger if it is currently null.
        """
        if not self.idempotency_key:
            self.idempotency_key = create_idempotency_key_for_ledger()
        super().save(*args, **kwags)

    def __str__(self):
        """
        Return string representation of this ledger, visible in logs, django admin, etc.
        """
        return f'<Ledger uuid={self.uuid},\nidempotency_key={self.idempotency_key}>'


class BaseTransaction(TimeStampedModelWithUuid):
    """
    Base class for all models that resemble transactions.
    """

    class Meta:
        """
        Metaclass for BaseTransaction.
        """

        abstract = True

    idempotency_key = models.CharField(
        max_length=255,
        blank=False,
        null=False,
        db_index=True,
        help_text=(
            "An idempotency key is a unique value generated by the client of the Ledger API "
            "which the Ledger and Transaction APIs use to recognize subsequent retries "
            "of the same redemption (i.e. a Transaction that leads to the fulfillment of "
            "an enrollment or entitlement for a particular user id in a particular content key)."
            "We suggest incorporating V4 UUIDs along with sufficiently unique or random "
            "values representing the desired redemption to avoid unintended collisions."
            "In particular, a Transaction's idempotency_key should incorporate it's corresponding "
            "ledger's idempotency_key."
            "Utility methods are provided in the ``utils.py`` module to help clients generate appropriate "
            "idempotency keys."
        ),
    )
    quantity = models.BigIntegerField(
        null=False,
        blank=False,
        help_text=(
            "How many units (as defined in the associated Ledger instance) this Transaction represents."
        ),
    )
    metadata = JSONField(
        blank=True,
        null=True,
        help_text=(
            "Any additionaly metadata that a client may want to associate with this Transaction."
        ),
    )
    state = models.CharField(
        max_length=255,
        blank=False,
        null=False,
        choices=TransactionStateChoices.CHOICES,
        default=TransactionStateChoices.CREATED,
        db_index=True,
        help_text=(
            "The current state of the Transaction. One of: "
            f"{[choice[1] for choice in TransactionStateChoices.CHOICES]}"
        ),
    )


class Transaction(BaseTransaction):
    """
    Represents value moving in or out of the ledger.

    Transactions (and reversals) are immutable after entering the committed state.  This immutability helps maintain a
    complete and robust history of value changes to the ledger, a trait which we rely on to calculate the ledger
    balance.

    Relatedly, we intentionally avoid persisting aggregates, reinforcing the transactions themselves as the
    only source of truth.

    .. no_pii:
    """

    class Meta:
        """
        Metaclass for Transaction.
        """

        unique_together = [('ledger', 'idempotency_key')]
        index_together = [
            ('ledger', 'lms_user_id', 'content_key'),
            ('ledger', 'content_key'),
            ('ledger', 'subsidy_access_policy_uuid'),
        ]

    ledger = models.ForeignKey(
        Ledger,
        related_name='transactions',
        null=True,
        on_delete=models.SET_NULL,
        help_text=(
            "The Ledger instance with which this Transaction is associated."
        )
    )
    lms_user_id = models.IntegerField(
        null=True,
        blank=True,
        db_index=True,
        help_text=(
            "The id of the Open edX LMS user record with which this Transaction is associated."
        ),
    )
    lms_user_email = models.CharField(
        max_length=255,
        blank=True,
        null=True,
        db_index=True,
        help_text=(
            "The email of the Open edX LMS user record with which this Transaction is associated."
            "The email is captured at the time the Transaction is created and may not be up to date."
        )
    )
    content_key = models.CharField(
        max_length=255,
        blank=True,
        null=True,
        db_index=True,
        help_text=(
            "The globally unique content identifier.  Joinable with ContentMetadata.content_key in enterprise-catalog."
        )
    )
    parent_content_key = models.CharField(
        max_length=255,
        blank=True,
        null=True,
        db_index=True,
        help_text=(
            "Identifier for the parent of the content_key.  Also should be joinable with ContentMetadata.content_key "
            "in enterprise-catalog. In practice, this is the course key corresponding to a course run key."
        )
    )
    content_title = models.CharField(
        max_length=255,
        blank=True,
        null=True,
        db_index=True,
        help_text=(
            "The title of the content associated with this Transaction."
            "The title is captured at the time the Transaction is created and may not be up to date."
        )
    )
    fulfillment_identifier = models.CharField(
        max_length=255,
        blank=True,
        null=True,
        db_index=True,
        help_text=(
            "The UUID identifier of the subsidized enrollment record for a learner generated durning the enrollment"
            "call made to enterprise/edx platform e.g. a LearnerCreditEnterpriseCourseEnrollment UUID."
        ),
    )
    subsidy_access_policy_uuid = models.UUIDField(
        blank=True,
        null=True,
        help_text="A reference to the subsidy access policy which was used to create a transaction for the content."
    )
    history = HistoricalRecords()

    def get_reversal(self):
        """
        Convenience method for fetching this transaction's related
        reversal, or None if no such reversal exists.
        """
        try:
            return self.reversal  # pylint: disable=no-member
        except Reversal.DoesNotExist:
            return None

    def get_adjustment(self):
        """
        Convenience method for fetching the ``Adjustment`` which
        created this transaction, if one exists.  If one does not exist,
        returns ``None``.
        """
        try:
            return self.adjustment  # pylint: disable=no-member
        except Adjustment.DoesNotExist:
            return None


class ExternalFulfillmentProvider(TimeStampedModel):
    """
    Model of external fulfillment providers. This is used to track the external systems that are used to fulfill
    transactions.

    .. no_pii:
    """

    class Meta:
        """
        Metaclass for ExternalFulfillmentProvider.
        """

        verbose_name = 'External Fulfillment provider'
        verbose_name_plural = 'External fulfillment providers'

    name = models.CharField(
        max_length=255,
        help_text="The name of the external reference type.",
    )
    slug = models.SlugField(
        max_length=32,
        unique=True,
        help_text=(
            "The slug of the external reference type. This is typically the slugified name of the system that the "
            "reference is associated with."
        )
    )

    def __str__(self):
        """
        Return string representation of this external fulfillment provider, visible in logs, django admin, etc.
        """
        msg = (
            f'<ExternalFulfillmentProvider\n'
            f'name={self.name}\n'
            f'slug={self.slug}>'
        )
        return msg


class ExternalTransactionReference(TimeStampedModel):
    """
    Model of references to transactions from an external system and their associated Transaction objects.

    .. no_pii:
    """

    class Meta:
        """
        Metaclass for ExternalTransactionReference.
        """

        unique_together = [('external_reference_id', 'external_fulfillment_provider')]
        verbose_name = 'External Transaction Reference'

    transaction = models.ForeignKey(
        Transaction,
        related_name='external_reference',
        null=True,
        on_delete=models.CASCADE,
        help_text=(
            "The Transaction to which this external reference is associated."
        ),
    )
    external_reference_id = models.CharField(
        max_length=255,
        blank=False,
        null=False,
        primary_key=True,
        db_index=True,
        help_text=(
            "The identifier for the external reference operation. This is typically the name of the system that the "
            "reference is associated with."
        ),
    )
    external_fulfillment_provider = models.ForeignKey(
        ExternalFulfillmentProvider,
        related_name='reference',
        null=False,
        blank=False,
        on_delete=models.CASCADE,
        help_text=(
            "The provider (source) of the external reference."
        )
    )

    def __str__(self):
        """
        Return string representation of this external reference, visible in logs, django admin, etc.
        """
        msg = (
            f'<ExternalTransactionReference\n'
            f'external_reference_id={self.external_reference_id}\n'
            f'external_fulfillment_provider={self.external_fulfillment_provider}\n'
        )
        if self.transaction:
            msg += f'associated with {self.transaction}\n'
        msg += '>'
        return msg


class Reversal(BaseTransaction):
    """
    Represents a reversal of some or all of a transaction, but no more.

    .. no_pii:
    """

    class Meta:
        """
        Metaclass for Reversal.
        """

        unique_together = [('transaction', 'idempotency_key')]

    transaction = models.OneToOneField(
        Transaction,
        related_name='reversal',
        null=True,
        on_delete=models.SET_NULL,
        help_text=(
            "The Transaction instance which is reversed by this Reversal instance."
        ),
    )
    history = HistoricalRecords()
    # Reversal quantities should always have the opposite sign of the transaction (i.e. negative)
    # We have to enforce this somehow...


class Adjustment(TimeStampedModelWithUuid):
    """
    Represents some adjustment to the balance of a ledger via
    a transaction (with quantity > 0) and some audit fields.

    .. no_pii:
    """
    ledger = models.ForeignKey(
        Ledger,
        related_name='adjustments',
        null=False,
        on_delete=models.CASCADE,
        help_text=(
            "The Ledger instance with which this Adjustment is associated."
        )
    )
    adjustment_quantity = models.BigIntegerField(
        null=False,
        blank=False,
        help_text=(
            "How many units to adjust for in the related transaction."
        ),
    )
    transaction = models.OneToOneField(
        Transaction,
        related_name='adjustment',
        unique=True,
        null=False,
        on_delete=models.CASCADE,
        help_text=(
            "The Transaction instance which adjusts the balance of the relevant ledger."
        ),
    )
    transaction_of_interest = models.OneToOneField(
        Transaction,
        related_name='adjustment_of_interest',
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        help_text=(
            "Any transaction of interest w.r.t. the reason for being of this adjustment. "
            "For example, the transaction of interest may point to some transaction record "
            "for which the enrolling user is unsatisfied, but for which we cannot issue a reversal "
            "due to business rules."
        ),
    )
    reason = models.CharField(
        max_length=255,
        blank=False,
        null=False,
        choices=AdjustmentReasonChoices.CHOICES,
        default=AdjustmentReasonChoices.TECHNICAL_CHALLENGES,
        db_index=True,
        help_text=(
            'The primary reason for the existence of this adjustment.'
        ),
    )
    notes = models.TextField(
        blank=True,
        null=True,
        help_text=(
            'Any additional context you have for the existence of this adjustment.'
        ),
    )
    history = HistoricalRecords()


class SalesContractReferenceProvider(TimeStampedModel):
    """
    All possible providers for sales contracts.

    Typically your sales contracts are recorded within one centralized system-of-record (e.g. SalesForce), in which case
    this table may only ever have one single record to describe that provider.

    .. no_pii:
    """

    class Meta:
        """
        Metaclass for SalesContractReferenceProvider.
        """

        verbose_name = 'Sales Contract Reference Provider'

    name = models.CharField(
        max_length=255,
        help_text="The pretty name of the contract reference provider.",
    )
    slug = models.SlugField(
        max_length=32,
        unique=True,
        help_text="A unique slug for this sales contract reference provider.",
    )

    def __str__(self):
        """
        Return string representation of this external fulfillment provider, visible in logs, django admin, etc.
        """
        msg = (
            f'<SalesContractReferenceProvider\n'
            f'name={self.name}\n'
            f'slug={self.slug}>'
        )
        return msg


class Deposit(TimeStampedModelWithUuid):
    """
    Represents a deposit into to the balance of a ledger via a transaction (with quantity > 0).

    .. no_pii:
    """
    ledger = models.ForeignKey(
        Ledger,
        related_name='deposits',
        null=False,
        on_delete=models.CASCADE,
        help_text=(
            "The Ledger instance with which this deposit is associated."
        )
    )
    # This field is non-contributing, and should NOT be factored into balance calculations. It should only be used to
    # accept input from an admin form and feed the transaction creation.
    desired_deposit_quantity = models.BigIntegerField(
        null=False,
        blank=False,
        help_text=(
            "How many units to deposit via related transaction."
        ),
    )
    transaction = models.OneToOneField(
        Transaction,
        related_name='deposit',
        unique=True,
        null=False,
        on_delete=models.CASCADE,
        help_text=(
            "The Transaction instance which represents this deposit and influences the balance of the relevant ledger."
        ),
    )
    sales_contract_reference_id = models.CharField(
        max_length=255,
        # Don't require this field because it's often inherited from the parent subsidy which also has blank/null=True.
        blank=True,
        null=True,
        db_index=True,
        help_text=(
            "The reference ID for the specific sales contract which beget this deposit. The source system which ",
            "stores the sales object is identified by the sales_contract_reference_provider field.",
        ),
    )
    sales_contract_reference_provider = models.ForeignKey(
        SalesContractReferenceProvider,
        related_name='deposits',
        null=True,
        blank=True,
        on_delete=models.CASCADE,
        help_text=(
            "The system providing the source of truth for the sales contract which beget this deposit."
        )
    )
    history = HistoricalRecords()
