"""The billing lifecycle. Views and the archive command go through here, never through the
models directly — a Due whose amount_paid disagrees with its payments is a wrong invoice.
"""

from datetime import date, timedelta
from decimal import Decimal

from django.db import transaction
from django.db.models import DateField, OuterRef, Subquery, Sum
from django.utils import timezone

from billing.models import ZERO, Due, DuePayment, Plan, Subscription, add_months
from billing.services import BillingError
from billing.services.invoices import issue_invoice


def subscribe(club, plan: Plan, *, start: date | None = None, auto_archive: bool = True, auto_renew: bool = True) -> Subscription:
    """Put a club on a plan and open its first period."""
    subscription, _created = Subscription.objects.update_or_create(club=club, defaults={"plan": plan, "auto_archive": auto_archive, "auto_renew": auto_renew})
    open_period(club, start=start)

    return subscription


@transaction.atomic
def start_trial(club, trial_plan: Plan, *, post_trial_plan: Plan, start: date | None = None, auto_renew: bool = True, auto_archive: bool = True) -> Due:
    """Put a club on a trial that switches itself to ``post_trial_plan`` the moment the trial
    period is renewed -- see open_period()'s trial-conversion check.

    The trial's length is the trial plan's own ``duration_months``: a 1-month and a 3-month
    trial are two plans, not one plan plus a number passed at the call site.

    Only for a club with no subscription yet -- converting an existing paying subscription
    into a trial is a different, deliberately unsupported operation for now.
    """
    if getattr(club, "subscription", None) is not None:
        raise BillingError(f"{club} is already subscribed -- use Change plan instead.")

    start = start or next_period_start(club)
    trial_end = add_months(start, trial_plan.duration_months) - timedelta(days=1)

    Subscription.objects.create(club=club, plan=trial_plan, trial_ends_at=trial_end, post_trial_plan=post_trial_plan, auto_renew=auto_renew, auto_archive=auto_archive)

    return open_period(club, start=start, period_end=trial_end, is_trial=True)


def next_period_start(club, today: date | None = None) -> date:
    """Where the club's next period begins.

    The day after the last one ended — not today. A club that pays two months late has still
    used those two months, and restarting the clock at the payment date would quietly gift
    them away. Callers can override; that is what the start field on the renew form is for.
    """
    today = today or timezone.localdate()
    last = club.dues.exclude(status=Due.Status.CANCELLED).order_by("-period_end").first()

    return last.period_end + timedelta(days=1) if last else today


@transaction.atomic
def open_period(club, *, start: date | None = None, plan: Plan | None = None, period_end: date | None = None, is_trial: bool = False) -> Due:
    """Issue the next due for a club, snapshotting the plan and the price of the day."""
    subscription = getattr(club, "subscription", None)
    if plan is None:
        if subscription is None:
            raise BillingError(f"{club} has no plan: put it on a subscription before billing it.")
        # A trial that has run its course: swap onto the pre-selected plan before billing
        # the next period, rather than silently renewing the trial plan forever. Checked
        # here (not in renew()) so it fires whether this period was opened by the renewal
        # command or by a platform admin clicking "Open period"/"Reactivate" by hand --
        # both call open_period() directly.
        if subscription.trial_ends_at is not None and (start or next_period_start(club)) > subscription.trial_ends_at:
            subscription.plan = subscription.post_trial_plan
            subscription.trial_ends_at = None
            subscription.post_trial_plan = None
            subscription.save(update_fields=["plan", "trial_ends_at", "post_trial_plan"])
        plan = subscription.plan

    start = start or next_period_start(club)

    amount = plan.price_on(start)
    if amount is None:
        raise BillingError(f"{plan} has no price in force on {start:%d %b %Y}. Add one before opening the period.")

    if club.dues.filter(period_start=start).exists():
        raise BillingError(f"{club} is already billed for a period starting {start:%d %b %Y}.")

    due = Due.objects.create(club=club, plan=plan, amount=amount, period_start=start, period_end=period_end, is_trial=is_trial)
    if amount == ZERO:
        # Nothing is actually owed -- left at the default UNPAID, this would eventually
        # trip is_overdue() and get a free club archived for non-payment of nothing.
        due.status = Due.Status.PAID
        due.paid_at = timezone.now()
        due.save(update_fields=["status", "paid_at"])
    issue_invoice(due)  # every period is billable the moment it opens

    return due


@transaction.atomic
def record_payment(due: Due, amount: Decimal, *, method=DuePayment.Method.BANK_TRANSFER, reference: str = "", paid_at=None, note: str = "", user=None) -> DuePayment:
    """Log money against a due and re-derive its status from the payments."""
    if due.status in (Due.Status.WAIVED, Due.Status.CANCELLED):
        raise BillingError(f"This period is {due.get_status_display()}; it cannot take a payment.")
    if amount <= ZERO:
        raise BillingError("A payment must be for a positive amount.")

    payment = DuePayment.objects.create(due=due, amount=amount, method=method, reference=reference, paid_at=paid_at or timezone.now(), note=note, recorded_by=user)
    _resettle(due)

    return payment


@transaction.atomic
def remove_payment(payment: DuePayment) -> None:
    """Undo a mis-keyed payment, then re-derive the due from what is left."""
    due = payment.due
    payment.delete()
    _resettle(due)


def _resettle(due: Due) -> None:
    """Recompute amount_paid and status from the payments on record.

    Summed from the payments rather than incremented: an increment drifts the moment a
    payment is edited or deleted, and the drift is invisible — the number still looks like
    money.
    """
    paid = due.payments.aggregate(total=Sum("amount"))["total"] or ZERO

    due.amount_paid = paid
    if paid >= due.amount:
        due.status = Due.Status.PAID
        due.paid_at = due.payments.order_by("-paid_at").first().paid_at
    elif paid > ZERO:
        due.status = Due.Status.PARTIAL
        due.paid_at = None
    else:
        due.status = Due.Status.UNPAID
        due.paid_at = None
    due.save(update_fields=["amount_paid", "status", "paid_at", "modified"])


@transaction.atomic
def waive(due: Due, *, note: str = "") -> Due:
    """Write a period off. It stops owing, and stops counting towards archiving."""
    if due.payments.exists():
        raise BillingError("This period has payments against it; remove them before waiving it.")

    due.status = Due.Status.WAIVED
    due.save(update_fields=["status", "modified"])

    return due


def owing_dues():
    return Due.objects.filter(status__in=Due.OWING)


def dues_in_grace(today: date | None = None):
    """Period started, unpaid, not yet archivable.

    Bounded below by ``period_start``, not ``period_end``: grace now runs from the start of
    the period, so a due is in grace *during* the period it covers, not after it.
    """
    today = today or timezone.localdate()

    return owing_dues().filter(period_start__lte=today, grace_until__gte=today)


def dues_overdue(today: date | None = None):
    """Past grace: these are the clubs the archive command would take down."""
    today = today or timezone.localdate()

    return owing_dues().filter(grace_until__lt=today)


def archivable_clubs(today: date | None = None):
    """Clubs the archive command would act on: overdue, still live, and opted in.

    A club with auto_archive off is deliberately spared — that flag is how you keep a club
    you are negotiating with from being switched off overnight.
    """
    return dues_overdue(today).filter(club__archived_at__isnull=True, club__subscription__auto_archive=True).select_related("club", "plan").order_by("club__name")


@transaction.atomic
def reactivate(club, *, start: date | None = None) -> Due:
    """Bring an archived club back and bill it again.

    The new period defaults to continuing from the last one, so a lapsed year is still owed.
    Pass ``start`` to forgive the gap and begin today instead.
    """
    club.restore()

    return open_period(club, start=start)


def subscriptions_due_for_renewal(today: date | None = None, lead_days: int | None = None):
    """Clubs whose next period should be issued now.

    Each plan sets its own ``renewal_lead_days``: a single global lead is silently annual-only,
    and on a 1-month plan a 30-day lead would issue the next period before the current one had
    started. ``lead_days`` overrides every plan's own value — that is what makes a rehearsal or
    a backfill possible, and it is what the command's --lead-days flag passes.

    The per-plan comparison is done in Python rather than SQL. The function already
    materialised its result as a list, and date arithmetic against a field value is not
    portably expressible across SQLite and Postgres; at platform scale (tens to low hundreds of
    clubs) this is one query plus a list walk.

    Idempotent by construction: a club that has just been renewed has a latest period ending a
    full duration out, which is past its horizon, so it cannot be picked up twice. Running the
    job twice a day is harmless.

    A subscription with no period at all (its only due was cancelled) counts too — a club on a
    plan and billed for nothing is the leak this whole job exists to close.
    """
    today = today or timezone.localdate()

    latest_period_end = Subquery(
        Due.objects.filter(club=OuterRef("club")).exclude(status=Due.Status.CANCELLED).order_by("-period_end").values("period_end")[:1],
        output_field=DateField(),
    )

    subscriptions = Subscription.objects.filter(auto_renew=True, club__archived_at__isnull=True).select_related("club", "plan").annotate(latest_period_end=latest_period_end).order_by("club__name")

    def is_due(subscription) -> bool:
        if subscription.latest_period_end is None:
            return True
        lead = subscription.plan.renewal_lead_days if lead_days is None else lead_days

        return subscription.latest_period_end <= today + timedelta(days=lead)

    return [subscription for subscription in subscriptions if is_due(subscription)]


def renew(subscription: Subscription) -> Due:
    """Open the club's next period, continuing from the last one."""
    return open_period(subscription.club)
