import datetime
import logging
import random
import uuid
from typing import Optional

from django.conf import settings
from django.contrib.postgres.fields.ranges import IntegerRangeField
from django.db import models
from django.db.models import Exists, ExpressionWrapper, F, OuterRef, Q, Sum, Value
from django.db.models.functions import Coalesce, Lower, Upper
from django.utils import timezone
from model_utils.managers import InheritanceManager
from model_utils.models import TimeStampedModel

from hub20.apps.blockchain.models import BaseEthereumAccount, Chain, Transaction
from hub20.apps.ethereum_money.models import (
    EthereumToken,
    EthereumTokenAmountField,
    EthereumTokenValueModel,
)
from hub20.apps.raiden.models import Payment as RaidenPaymentEvent, Raiden

from ..choices import DEPOSIT_STATUS
from ..settings import app_settings

logger = logging.getLogger(__name__)


def generate_payment_order_id():
    # Default payment identifier generated by Raiden's web UI is based on unix
    # time. We would like to make the distinction between payment ids
    # generated by default and those generated by us.

    LOWER_BOUND = 2 ** 48  # Enough to take us to the year 10889.
    UPPER_BOUND = 2 ** 53 - 1  # Javascript can not handle numbers bigger than 2^53 - 1

    return random.randint(LOWER_BOUND, UPPER_BOUND)


def calculate_raiden_payment_window():
    return datetime.timedelta(seconds=app_settings.Payment.raiden_route_lifetime)


class DepositQuerySet(models.QuerySet):
    def expired(self, block_number: Optional[int] = None, at: Optional[datetime.datetime] = None):
        return self.without_blockchain_route(block_number=block_number).without_raiden_route(at=at)

    def open(self, block_number: Optional[int] = None, at: Optional[datetime.datetime] = None):
        exists_blockchain_route = self.__class__.get_blockchain_window_query(
            block_number=block_number
        )
        exists_raiden_route = self.__class__.get_raiden_window_query(at=at)

        return self.filter(exists_blockchain_route | exists_raiden_route)

    def with_blockchain_route(self, block_number: Optional[int] = None):
        exists_route = self.__class__.get_blockchain_window_query(block_number=block_number)
        return self.filter(exists_route)

    def with_raiden_route(self, at: Optional[datetime.datetime] = None):
        exists_route = self.__class__.get_raiden_window_query(at=at)
        return self.filter(exists_route)

    def without_blockchain_route(self, block_number: Optional[int] = None):
        exists_route = self.__class__.get_blockchain_window_query(block_number=block_number)
        return self.filter(~exists_route)

    def without_raiden_route(self, at: Optional[datetime.datetime] = None):
        exists_route = self.__class__.get_raiden_window_query(at=at)
        return self.filter(~exists_route)

    @classmethod
    def get_blockchain_window_query(cls, block_number: Optional[int] = None) -> Exists:
        qs = BlockchainPaymentRoute.objects.available(block_number=block_number)
        return Exists(qs.filter(deposit=OuterRef("pk")))

    @classmethod
    def get_raiden_window_query(cls, at: Optional[datetime.datetime] = None) -> Exists:
        return Exists(RaidenPaymentRoute.objects.available(at=at).filter(deposit=OuterRef("pk")))


class PaymentOrderQuerySet(DepositQuerySet):
    def unpaid(self):
        q_no_payment = Q(total_paid__isnull=True)
        q_low_payment = Q(total_paid__lt=F("amount"))

        return self.annotate(total_paid=Sum("routes__payment__amount")).filter(
            q_no_payment | q_low_payment
        )

    def paid(self):
        return self.annotate(total_paid=Sum("routes__payment__amount")).filter(
            total_paid__gte=F("amount")
        )


class BlockchainRouteQuerySet(models.QuerySet):
    def with_provider(self) -> models.QuerySet:
        return self.filter(chain__providers__is_active=True)

    def with_expiration(self) -> models.QuerySet:
        return self.annotate(
            start_block=Lower("payment_window"), expiration_block=Upper("payment_window")
        )

    def with_payment_amounts(self) -> models.QuerySet:
        return self.annotate(
            currency=F("payment__currency"),
            total_paid=Coalesce(
                Sum("payment__amount"), Value(0), output_field=EthereumTokenAmountField()
            ),
            total_confirmed=Coalesce(
                Sum("payment__amount", filter=Q(payment__confirmation__isnull=False)),
                Value(0),
                output_field=EthereumTokenAmountField(),
            ),
        )

    def expired(self, block_number: Optional[int] = None) -> models.QuerySet:
        at_block = block_number if block_number is not None else F("chain__highest_block")
        return self.filter(expiration_block__lt=at_block)

    def used(self) -> models.QuerySet:
        return self.with_payment_amounts().filter(
            total_paid__gte=F("deposit__amount"), currency=F("deposit__currency")
        )

    def available(self, block_number: Optional[int] = None) -> models.QuerySet:
        qs = self.with_expiration()
        at_block = block_number if block_number is not None else F("chain__highest_block")

        return qs.filter(start_block__lte=at_block, expiration_block__gte=at_block)

    def open(self, block_number: Optional[int] = None) -> models.QuerySet:
        at_block = block_number if block_number is not None else F("chain__highest_block")

        no_defined_amount = Q(deposit__paymentorder__isnull=True)

        confirmed = Q(total_confirmed__gte=F("deposit__paymentorder__amount")) & Q(
            currency=F("deposit__currency")
        )
        expired = Q(expiration_block__lt=at_block)

        return (
            self.with_expiration()
            .exclude(expired)
            .with_payment_amounts()
            .filter(no_defined_amount | ~confirmed)
        )


class RaidenRouteQuerySet(models.QuerySet):
    def with_expiration(self) -> models.QuerySet:
        return self.annotate(
            expiration_time=ExpressionWrapper(
                F("created") + F("payment_window"), output_field=models.DateTimeField()
            )
        )

    def expired(self, at: Optional[datetime.datetime] = None) -> models.QuerySet:
        date_value = at or timezone.now()
        return self.with_expiration().filter(expiration_time__lt=date_value)

    def available(self, at: Optional[datetime.datetime] = None) -> models.QuerySet:
        date_value = at or timezone.now()
        return self.with_expiration().filter(
            created__lte=date_value, expiration_time__gte=date_value
        )


class Deposit(TimeStampedModel):
    STATUS = DEPOSIT_STATUS

    id = models.UUIDField(default=uuid.uuid4, primary_key=True)
    user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
    session_key = models.SlugField(null=True)
    currency = models.ForeignKey(EthereumToken, on_delete=models.PROTECT)
    objects = DepositQuerySet.as_manager()

    @property
    def payments(self):
        return Payment.objects.filter(route__deposit=self).select_subclasses()

    @property
    def confirmed_payments(self):
        return self.payments.filter(confirmation__isnull=False)

    @property
    def total_transferred(self):
        return self.payments.aggregate(total=Sum("amount")).get("total") or 0

    @property
    def total_confirmed(self):
        return self.confirmed_payments.aggregate(total=Sum("amount")).get("total") or 0

    @property
    def is_expired(self):
        return all([route.is_expired for route in self.routes.select_subclasses()])

    @property
    def status(self):
        return self.STATUS.expired if self.is_expired else self.STATUS.open


class PaymentOrder(Deposit, EthereumTokenValueModel):
    objects = PaymentOrderQuerySet.as_manager()

    @property
    def due_amount(self):
        return max(0, self.amount - self.total_transferred)

    @property
    def is_paid(self):
        return self.due_amount <= 0

    @property
    def is_confirmed(self):
        return self.is_paid and self.total_confirmed >= self.amount

    @property
    def is_expired(self):
        return all([route.is_expired for route in self.routes.select_subclasses()])

    @property
    def status(self):
        if self.is_confirmed:
            return self.STATUS.confirmed
        elif self.is_paid:
            return self.STATUS.paid
        elif self.is_expired:
            return self.STATUS.expired
        else:
            return self.STATUS.open


class PaymentRoute(TimeStampedModel):
    NAME: Optional[str] = None

    deposit = models.ForeignKey(Deposit, on_delete=models.CASCADE, related_name="routes")
    objects = InheritanceManager()

    @property
    def name(self):
        return self.get_route_name()

    def get_route_name(self):
        if not self.NAME:
            route = PaymentRoute.objects.get_subclass(id=self.id)
            return route.NAME
        return self.NAME


class InternalPaymentRoute(PaymentRoute):
    NAME = "internal"


class BlockchainPaymentRoute(PaymentRoute):
    NAME = "blockchain"

    account = models.ForeignKey(
        BaseEthereumAccount, on_delete=models.CASCADE, related_name="blockchain_routes"
    )
    payment_window = IntegerRangeField()
    chain = models.ForeignKey(Chain, on_delete=models.CASCADE)
    objects = BlockchainRouteQuerySet.as_manager()

    @property
    def start_block_number(self):
        return self.payment_window.lower

    @property
    def expiration_block_number(self):
        return self.payment_window.upper

    @property
    def is_expired(self):
        return self.chain.highest_block > self.expiration_block_number

    @staticmethod
    def calculate_payment_window(chain):
        if not chain.synced:
            raise ValueError("Chain is not synced")

        current = chain.highest_block
        return (current, current + app_settings.Payment.blockchain_route_lifetime)


class RaidenPaymentRoute(PaymentRoute):
    NAME = "raiden"

    payment_window = models.DurationField(default=calculate_raiden_payment_window)
    raiden = models.ForeignKey(Raiden, on_delete=models.CASCADE, related_name="payment_routes")
    identifier = models.BigIntegerField(default=generate_payment_order_id, unique=True)

    objects = RaidenRouteQuerySet.as_manager()

    @property
    def is_expired(self):
        return self.expiration_time < timezone.now()

    @property
    def expiration_time(self):
        return self.created + self.payment_window

    @staticmethod
    def calculate_payment_window():
        return calculate_raiden_payment_window()

    class Meta:
        unique_together = ("raiden", "identifier")


class Payment(TimeStampedModel, EthereumTokenValueModel):
    id = models.UUIDField(primary_key=True, default=uuid.uuid4)
    route = models.ForeignKey(PaymentRoute, on_delete=models.PROTECT)
    objects = InheritanceManager()

    @property
    def is_confirmed(self):
        return hasattr(self, "confirmation")


class InternalPayment(Payment):
    memo = models.TextField(null=True, blank=True)

    @property
    def identifier(self):
        return str(self.id)


class BlockchainPayment(Payment):
    transaction = models.OneToOneField(Transaction, unique=True, on_delete=models.CASCADE)

    @property
    def identifier(self):
        return str(self.transaction.hash)


class RaidenPayment(Payment):
    payment = models.OneToOneField(RaidenPaymentEvent, unique=True, on_delete=models.CASCADE)

    @property
    def identifier(self):
        return f"{self.payment.identifier}-{self.id}"


class PaymentConfirmation(TimeStampedModel):
    payment = models.OneToOneField(Payment, on_delete=models.CASCADE, related_name="confirmation")


__all__ = [
    "Deposit",
    "PaymentOrder",
    "PaymentRoute",
    "InternalPaymentRoute",
    "BlockchainPaymentRoute",
    "RaidenPaymentRoute",
    "Payment",
    "InternalPayment",
    "BlockchainPayment",
    "RaidenPayment",
    "PaymentConfirmation",
]
