import logging
import itertools
import time
from datetime import timedelta, datetime
from celery import shared_task
from django.db.models import Q, F
from django.db import transaction
from django.conf import settings
from django.utils import timezone
from django.template.loader import get_template
from django.core.mail import send_mass_mail, send_mail
from constance import config as site_config
import django_lcore
import lcoreapi

from ccvpn.common import parse_integer_list
from lambdainst.models import User, VPNUser

ROOT_URL = settings.ROOT_URL
SITE_NAME = settings.TICKETS_SITE_NAME

logger = logging.getLogger(__name__)


@shared_task(autoretry_for=(Exception,), default_retry_delay=60 * 60)
def push_all_users():
    count = 0
    for u in User.objects.all():
        # skip 'empty' accounts
        if u.vpnuser.expiration is None:
            continue

        logger.debug("pushing user #%d %r", user.id, user)

        django_lcore.sync_user(u.vpnuser, fail_silently=False)
        count += 1
    logger.info("pushed %d users", count)


@shared_task(autoretry_for=(Exception,), max_retries=10, retry_backoff=True)
def push_user(user_id):
    user = User.objects.get(id=user_id)
    logger.info("pushing user #%d %r", user.id, user)
    django_lcore.sync_user(user.vpnuser, fail_silently=False)


@shared_task
def notify_vpn_auth_fails():
    """
    due to the high number of regular authentication fails, we'll start sending
    a periodic reminder to accounts with high rates.
    it could be a misconfigured client or a payment failure

    3 years, >0 payments:  7 marked,  883 skipped
    6 years, >0 payments: 11 marked, 1582 skipped
    3 years, incl 0 paym:  9 marked, 3590 skipped
    6 years, incl 0 paym: 14 marked, 6313 skipped [12m25s]
    """

    logger.info("searching for recent openvpn auth failures")

    api = django_lcore.api
    from_email = settings.DEFAULT_FROM_EMAIL
    FETCH_EVENTS_PER_USER = 90
    MIN_EXP = timezone.now() - timedelta(days=6 * 365)
    MIN_EVENTS = 5  # that threshold is automatically negative
    MAX_FAILS = 15  # that threshold is automatically positive
    MIN_DT_SINCE_LAST = timedelta(days=1)
    NOTIFY_EVERY = timedelta(days=180)  # users notified recently should be ignored

    def find_fails_for_user(u):
        """ returns true if there are 'many' recent fails """
        path = api.info["current_instance"] + "/users/" + u.username + "/auth-events/"
        try:
            r = api.get(path)
        except lcoreapi.APINotFoundError:
            return False
        assert r["object"] == "list", repr(r)
        items = list(itertools.islice(r.list_iter(), FETCH_EVENTS_PER_USER))

        # split the dataset, errors are ignored
        fails = [i for i in items if i["auth_status"] == "fail"]
        ok = [i for i in items if i["auth_status"] == "ok"]

        # do we have enough failures
        if len(fails) == 0:
            return False
        if len(fails) < MIN_EVENTS:
            logger.debug(
                f"#{u.id} '{u.username}': too few fails ({len(fails)} < {MIN_EVENTS})"
            )
            return False

        # are they recent
        dates = [e["event_date"] for e in fails]
        first = min(dates)
        last = max(dates)
        dt_fails = last - first
        dt_last_fail = datetime.utcnow() - last
        if dt_last_fail > MIN_DT_SINCE_LAST:
            logger.debug(
                f"#{u.id} '{u.username}': no recent enough failure ({dt_last_fail} ago > {MIN_DT_SINCE_LAST} ago)"
            )
            return False

        # ~ end of checks. now we filter positive matches ~

        # do we have only fails in the recent events?
        if len(fails) > MAX_FAILS and len(ok) == 0:
            logger.info(f"#{u.id} '{u.username}': {len(fails)} fails and 0 ok")
            return True

        # do we have many fails per hour?
        fails_per_hour = len(fails) / (dt_fails.total_seconds() / 3600.0)
        if fails_per_hour > 1:
            logger.info(
                f"#{u.id} '{u.username}': fail/hour is high ({fails_per_hour:.2f} > 1)"
            )
            return True

        # otherwise there are some of both. let's check the ratio
        fail_ratio = len(fails) / len(ok)
        if fail_ratio > 1:  # more fails than oks
            logger.info(
                f"#{u.id} '{u.username}': fail ratio is high ({fail_ratio:.2f} > 1)"
            )
            return True
        else:
            logger.debug(
                f"#{u.id} '{u.username}': fail ratio is low ({fail_ratio:.2f} < 1)"
            )
            return False

    def notify_user(user):
        if user.vpnuser.notify_vpn_auth_fail is not True:
            logger.warning("this user should not be sent a notice")
            return

        ctx = dict(user=user, url=ROOT_URL)
        text = get_template("lambdainst/mail_vpn_auth_fails.txt").render(ctx)
        send_mail("CCrypto VPN Authentication Errors", text, from_email, [user.email])
        logger.debug("sending vpn auth fails notify to %s", user.email)

        user.vpnuser.last_vpn_auth_fail_notice = timezone.now()
        user.vpnuser.save()

    def examine_users():
        skipped = 0
        marked = 0

        users = User.objects
        # skip accounts that opt out of this
        users = users.exclude(vpnuser__notify_vpn_auth_fail=False)
        # skip accounts with no email address
        users = users.exclude(email__exact="")
        # skip accounts where exp was never set
        users = users.exclude(vpnuser__expiration=None)
        # skip accounts that expired a long time ago
        users = users.exclude(vpnuser__expiration__lt=MIN_EXP)
        # skip accounts that already received a notice recently
        users = users.exclude(
            vpnuser__last_vpn_auth_fail_notice__gt=timezone.now() - NOTIFY_EVERY
        )
        # require at least one payment
        # users = users.filter(payment__status='confirmed').annotate(Count('payment')).filter(payment__count__gt=0)

        users = users.order_by("id")

        for u in users:
            if find_fails_for_user(u):
                notify_user(u)
                marked += 1
            else:
                skipped += 1
            time.sleep(0.100)

        logger.debug("auth fails: notified %d, reviewed %d", marked, skipped)

    examine_users()


@shared_task
def notify_account_expiration():
    """ Notify users near the end of their subscription """
    from_email = settings.DEFAULT_FROM_EMAIL

    for v in parse_integer_list(site_config.NOTIFY_DAYS_BEFORE):
        emails = []
        users = list(get_next_expirations(v))
        logger.info("sending -%d day notification to %d users", v, len(users))

        with transaction.atomic():
            for u in users:
                # Ignore users with active subscriptions
                # They will get notified only if it gets cancelled (payments
                # processors will cancel after a few failed payments)
                if u.get_subscription():
                    continue

                ctx = dict(
                    site_name=SITE_NAME, user=u.user, exp=u.expiration, url=ROOT_URL
                )
                text = get_template("lambdainst/mail_expire_soon.txt").render(ctx)
                emails.append(
                    ("CCrypto VPN Expiration", text, from_email, [u.user.email])
                )
                logger.debug("sending -%d days notify to %s" % (v, u.user.email))

                u.last_expiry_notice = timezone.now()
                u.save()

            send_mass_mail(emails)


def get_next_expirations(days=3):
    """ Gets users whose subscription will expire in some days """

    limit_date = timezone.now() + timedelta(days=days)

    users = VPNUser.objects.exclude(user__email__exact="")

    users = users.filter(expiration__gt=timezone.now())  # Not expired
    users = users.filter(expiration__lt=limit_date)  # Expire in a few days

    # Make sure we dont send the notice twice
    users = users.filter(
        Q(last_expiry_notice__isnull=True)
        | Q(expiration__gt=F("last_expiry_notice") + timedelta(days=days))
    )
    return users
