from calendar import monthrange
from datetime import date
from decimal import Decimal

from django.core.exceptions import ValidationError
from django.db import transaction
from django.utils import timezone

from apps.audit.services import log_event
from apps.organization.services import get_effective_policy
from .calculations import allocate_recovery, build_installment_amounts, money
from .models import LoanAccount, LoanInstallment, LoanRecovery


def _add_months(first_day, months):
    month_index = (first_day.month - 1) + months
    year = first_day.year + month_index // 12
    month = month_index % 12 + 1
    day = min(first_day.day, monthrange(year, month)[1])
    return date(year, month, day)


def _month_start(value):
    return date(value.year, value.month, 1)


@transaction.atomic
def create_loan_from_approved_request(
    employee_request,
    *,
    sanctioned_amount,
    installment_count,
    recovery_start_month,
    user=None,
):
    """Create exactly one pending facility from an approved employee request."""
    request_type = str(employee_request.request_type).upper()
    if request_type not in {"LOAN", "SALARY_ADVANCE"}:
        raise ValidationError("This request is not a loan or salary advance request.")

    sanctioned_amount = money(sanctioned_amount)
    installment_count = int(installment_count or 1)
    if sanctioned_amount <= 0:
        raise ValidationError("Sanctioned amount must be greater than zero.")
    if installment_count <= 0:
        raise ValidationError("Installment count must be greater than zero.")
    if not recovery_start_month:
        raise ValidationError("Recovery start month is required.")

    existing = LoanAccount.objects.select_for_update().filter(request_reference=employee_request.reference_no).first()
    if existing:
        return existing

    installment_amount = build_installment_amounts(sanctioned_amount, installment_count)[0]
    requested_amount = Decimal(str((employee_request.payload or {}).get("requested_amount") or 0))
    if requested_amount <= 0:
        raise ValidationError("Requested amount must be greater than zero.")
    if sanctioned_amount > requested_amount:
        raise ValidationError("Sanctioned amount cannot exceed the employee requested amount without a revised request.")
    loan = LoanAccount.objects.create(
        employee=employee_request.employee,
        loan_type=LoanAccount.LoanType.LOAN if request_type == "LOAN" else LoanAccount.LoanType.SALARY_ADVANCE,
        request_reference=employee_request.reference_no,
        requested_amount=requested_amount,
        sanctioned_amount=sanctioned_amount,
        installment_amount=installment_amount,
        installment_count=installment_count,
        start_month=_month_start(recovery_start_month),
        sanctioned_at=timezone.now(),
        outstanding_balance=sanctioned_amount,
        status=LoanAccount.Status.PENDING_DISBURSEMENT,
        reason=employee_request.details,
    )
    log_event(
        user=user,
        action="SANCTION",
        module="loans",
        object_id=loan.pk,
        object_repr=f"{loan.employee} / {loan.sanctioned_amount}",
        new_values={
            "request_reference": employee_request.reference_no,
            "sanctioned_amount": str(loan.sanctioned_amount),
            "installment_count": loan.installment_count,
            "start_month": str(loan.start_month),
            "status": loan.status,
        },
    )
    return loan


@transaction.atomic
def disburse_loan(loan, *, disbursement_date, payment_reference="", note="", user=None):
    loan = LoanAccount.objects.select_for_update().get(pk=loan.pk)
    if loan.status != LoanAccount.Status.PENDING_DISBURSEMENT:
        raise ValidationError("Only pending-disbursement facilities can be disbursed.")
    if loan.sanctioned_amount <= 0 or loan.installment_count <= 0:
        raise ValidationError("Sanction amount and installment setup must be valid.")
    if not disbursement_date:
        raise ValidationError("Disbursement date is required.")

    policy = get_effective_policy(loan.employee.company, disbursement_date)
    disbursement_month = _month_start(disbursement_date)
    minimum_start = disbursement_month
    if policy.loan_recovery_start_rule == policy.LoanRecoveryStartRule.NEXT_MONTH:
        minimum_start = _add_months(disbursement_month, 1)
    if _month_start(loan.start_month) < minimum_start:
        raise ValidationError(
            f"Recovery start month {loan.start_month:%b %Y} is earlier than the company policy allows after "
            f"disbursement on {disbursement_date}. Amend the approved recovery start month before disbursement."
        )

    loan.disbursement_date = disbursement_date
    loan.disbursement_reference = (payment_reference or "").strip()
    loan.outstanding_balance = loan.sanctioned_amount
    loan.status = LoanAccount.Status.ACTIVE
    if note:
        loan.reason = (loan.reason + "\n" + note).strip()
    loan.save(update_fields=["disbursement_date", "disbursement_reference", "outstanding_balance", "status", "reason", "updated_at"])

    amounts = build_installment_amounts(loan.sanctioned_amount, loan.installment_count)
    for index, due_amount in enumerate(amounts):
        LoanInstallment.objects.update_or_create(
            loan=loan,
            due_month=_add_months(_month_start(loan.start_month), index),
            defaults={"due_amount": due_amount},
        )

    log_event(
        user=user,
        action="DISBURSE",
        module="loans",
        object_id=loan.pk,
        object_repr=f"{loan.employee} / {loan.sanctioned_amount}",
        new_values={
            "status": loan.status,
            "disbursement_date": str(disbursement_date),
            "payment_reference": loan.disbursement_reference,
            "installments_created": len(amounts),
        },
        reason=note,
    )
    return loan


@transaction.atomic
def cancel_pending_loan(loan, *, reason, user=None):
    loan = LoanAccount.objects.select_for_update().get(pk=loan.pk)
    if loan.status != LoanAccount.Status.PENDING_DISBURSEMENT:
        raise ValidationError("Only a pending-disbursement facility may be cancelled.")
    if not (reason or "").strip():
        raise ValidationError("Cancellation reason is required.")
    loan.status = LoanAccount.Status.CANCELLED
    loan.reason = (loan.reason + "\nCancelled: " + reason.strip()).strip()
    loan.save(update_fields=["status", "reason", "updated_at"])
    log_event(user=user, action="CANCEL", module="loans", object_id=loan.pk, object_repr=str(loan.employee), new_values={"status": loan.status}, reason=reason)
    return loan


def scheduled_recovery_for_period(employee, *, year, month):
    """Return unpaid installments due on or before this payroll month.

    Arrears are included so a missed payroll recovery is not silently lost.
    """
    period_end = date(year, month, monthrange(year, month)[1])
    installments = LoanInstallment.objects.select_related("loan").filter(
        loan__employee=employee,
        loan__status=LoanAccount.Status.ACTIVE,
        due_month__lte=period_end,
    ).order_by("due_month", "pk")
    total = Decimal("0.00")
    for item in installments:
        unpaid = money(item.due_amount - item.paid_amount)
        if unpaid > 0:
            total += unpaid
    active_outstanding = sum(
        (loan.outstanding_balance for loan in LoanAccount.objects.filter(employee=employee, status=LoanAccount.Status.ACTIVE)),
        Decimal("0.00"),
    )
    return money(min(total, active_outstanding))


def _eligible_installments(employee, period_end):
    return list(
        LoanInstallment.objects.select_for_update().select_related("loan").filter(
            loan__employee=employee,
            loan__status=LoanAccount.Status.ACTIVE,
            due_month__lte=period_end,
        ).order_by("due_month", "pk")
    )


@transaction.atomic
def post_payroll_recoveries(period, *, user=None):
    """Post loan deductions only when payroll is locked.

    Recalculation itself does not touch outstanding balances. This prevents
    duplicate financial postings when Draft payroll is rebuilt multiple times.
    """
    from apps.payroll.models import PayrollRecord

    period_end = date(period.year, period.month, monthrange(period.year, period.month)[1])
    posted_total = Decimal("0.00")
    for payroll in PayrollRecord.objects.select_for_update().select_related("employee").filter(period=period, loan_recovery__gt=0):
        installments = _eligible_installments(payroll.employee, period_end)
        unpaid = [(item.pk, money(item.due_amount - item.paid_amount)) for item in installments]
        allocations = allocate_recovery(unpaid, payroll.loan_recovery)
        allocated_total = money(sum((amount for _, amount in allocations), Decimal("0.00")))
        if allocated_total != money(payroll.loan_recovery):
            raise ValidationError(
                f"Loan recovery mismatch for {payroll.employee}: payroll deducts {payroll.loan_recovery} "
                f"but only {allocated_total} is currently recoverable. Rebuild payroll before locking."
            )
        by_id = {item.pk: item for item in installments}
        for installment_id, amount in allocations:
            installment = by_id[installment_id]
            loan = LoanAccount.objects.select_for_update().get(pk=installment.loan_id)
            key = f"PAYROLL:{payroll.pk}:INSTALLMENT:{installment.pk}"
            recovery, created = LoanRecovery.objects.select_for_update().get_or_create(
                idempotency_key=key,
                defaults={
                    "loan": loan,
                    "installment": installment,
                    "payroll_record": payroll,
                    "amount": amount,
                },
            )
            if not created and not recovery.is_reversed:
                continue
            if not created:
                recovery.amount = amount
                recovery.is_reversed = False
                recovery.reversed_at = None
                recovery.reversed_by = None
                recovery.save(update_fields=["amount", "is_reversed", "reversed_at", "reversed_by", "updated_at"])

            installment.paid_amount = money(installment.paid_amount + amount)
            policy = get_effective_policy(period.company, date(period.year, period.month, 1))
            installment.payroll_reference = f"{policy.payroll_reference_prefix}-{period.year}{period.month:02d}-{payroll.pk}"
            if installment.paid_amount >= installment.due_amount:
                installment.paid_amount = installment.due_amount
                installment.status = LoanInstallment.Status.PAID
            else:
                installment.status = LoanInstallment.Status.PARTIAL
            installment.save(update_fields=["paid_amount", "payroll_reference", "status", "updated_at"])

            loan.outstanding_balance = money(max(Decimal("0.00"), loan.outstanding_balance - amount))
            if loan.outstanding_balance <= 0:
                loan.outstanding_balance = Decimal("0.00")
                loan.status = LoanAccount.Status.CLOSED
            loan.save(update_fields=["outstanding_balance", "status", "updated_at"])
            posted_total += amount

    log_event(
        user=user,
        action="POST_RECOVERY",
        module="loans",
        object_id=period.pk,
        object_repr=f"Payroll {period.year}-{period.month:02d}",
        new_values={"posted_total": str(money(posted_total))},
    )
    return money(posted_total)


@transaction.atomic
def reverse_payroll_recoveries(period, *, user=None):
    recoveries = list(
        LoanRecovery.objects.select_for_update().select_related("loan", "installment", "payroll_record").filter(
            payroll_record__period=period,
            is_reversed=False,
        )
    )
    loan_ids = {item.loan_id for item in recoveries}
    if loan_ids:
        from django.db.models import Q
        later_postings = LoanRecovery.objects.filter(loan_id__in=loan_ids, is_reversed=False).filter(
            Q(payroll_record__period__year__gt=period.year)
            | Q(payroll_record__period__year=period.year, payroll_record__period__month__gt=period.month)
        )
        if later_postings.exists():
            raise ValidationError("A later payroll has already posted recovery for this loan. Unlock/reverse the later payroll first.")
    reversed_total = Decimal("0.00")
    for recovery in recoveries:
        installment = LoanInstallment.objects.select_for_update().get(pk=recovery.installment_id)
        loan = LoanAccount.objects.select_for_update().get(pk=recovery.loan_id)
        amount = money(recovery.amount)

        installment.paid_amount = money(max(Decimal("0.00"), installment.paid_amount - amount))
        installment.status = LoanInstallment.Status.DUE if installment.paid_amount <= 0 else LoanInstallment.Status.PARTIAL
        if installment.paid_amount <= 0:
            installment.payroll_reference = ""
        installment.save(update_fields=["paid_amount", "status", "payroll_reference", "updated_at"])

        loan.outstanding_balance = money(min(loan.sanctioned_amount, loan.outstanding_balance + amount))
        if loan.status == LoanAccount.Status.CLOSED:
            loan.status = LoanAccount.Status.ACTIVE
        loan.save(update_fields=["outstanding_balance", "status", "updated_at"])

        recovery.is_reversed = True
        recovery.reversed_at = timezone.now()
        recovery.reversed_by = user
        recovery.save(update_fields=["is_reversed", "reversed_at", "reversed_by", "updated_at"])
        reversed_total += amount

    log_event(
        user=user,
        action="REVERSE_RECOVERY",
        module="loans",
        object_id=period.pk,
        object_repr=f"Payroll {period.year}-{period.month:02d}",
        new_values={"reversed_total": str(money(reversed_total))},
    )
    return money(reversed_total)


@transaction.atomic
def settle_employee_loans_via_exit(final_settlement, *, user=None):
    """Post final-settlement recovery and close the corresponding loan ledgers exactly once."""
    from .models import ExitLoanSettlement
    employee = final_settlement.exit_case.employee
    loans = list(LoanAccount.objects.select_for_update().filter(employee=employee, status=LoanAccount.Status.ACTIVE, outstanding_balance__gt=0))
    posted = Decimal("0.00")
    for loan in loans:
        amount = money(loan.outstanding_balance)
        installments = list(LoanInstallment.objects.select_for_update().filter(loan=loan).order_by("due_month", "pk"))
        unpaid_installment_total = money(sum(
            (max(Decimal("0.00"), money(item.due_amount - item.paid_amount)) for item in installments),
            Decimal("0.00"),
        ))
        if unpaid_installment_total != amount:
            raise ValidationError(
                f"Loan ledger mismatch for {loan}: outstanding balance is {amount}, but unpaid installments total "
                f"{unpaid_installment_total}. Reconcile the loan ledger before posting final settlement."
            )
        key = f"EXIT:{final_settlement.pk}:LOAN:{loan.pk}"
        entry, created = ExitLoanSettlement.objects.get_or_create(
            idempotency_key=key,
            defaults={"loan": loan, "final_settlement": final_settlement, "amount": amount, "posted_by": user},
        )
        if not created:
            continue
        remaining = amount
        for installment in installments:
            if remaining <= 0:
                break
            due = money(installment.due_amount - installment.paid_amount)
            if due <= 0:
                continue
            allocated = min(due, remaining)
            installment.paid_amount = money(installment.paid_amount + allocated)
            installment.status = LoanInstallment.Status.PAID if installment.paid_amount >= installment.due_amount else LoanInstallment.Status.PARTIAL
            policy = get_effective_policy(employee.company, final_settlement.exit_case.final_working_day)
            installment.payroll_reference = f"{policy.loan_reference_prefix}-EXIT-FSET-{final_settlement.pk}"
            installment.save(update_fields=["paid_amount", "status", "payroll_reference", "updated_at"])
            remaining = money(remaining - allocated)
        loan.outstanding_balance = Decimal("0.00")
        loan.status = LoanAccount.Status.CLOSED
        loan.save(update_fields=["outstanding_balance", "status", "updated_at"])
        posted += amount
    log_event(user=user, action="EXIT_LOAN_SETTLEMENT", module="loans", object_id=final_settlement.pk, object_repr=str(employee), new_values={"recovered": str(money(posted)), "loan_count": len(loans)})
    return money(posted)
