from collections import defaultdict
from decimal import Decimal

from django.core.exceptions import ValidationError
from django.db import transaction
from django.db.models import Sum
from django.utils import timezone

from shop_management.models.data.inventory import InventoryBatch, StockBalance, StockMovement
from shop_management.models.data.purchase import Purchase
from shop_management.services.inventory import issue_stock_batch, receive_stock
from shop_management.services.automatic_journals import create_system_journal, void_system_journal


def _purchase_number(purchase):
    prefix = f"{purchase.shop.name[:3].upper()}-PUR-{purchase.purchase_date.year}-"
    last = Purchase.objects.filter(shop_id=purchase.shop_id, purchase_number__startswith=prefix).order_by(
        "-purchase_number"
    ).values_list("purchase_number", flat=True).first()
    sequence = int(last.rsplit("-", 1)[-1]) + 1 if last else 1
    return f"{prefix}{sequence:06d}"


def _validate_purchase(purchase, items):
    errors = {}
    subtotal = sum(
        (Decimal(str(item.quantity or 0)) * Decimal(str(item.buy_price or 0)) for item in items),
        Decimal("0"),
    )
    discount = Decimal(str(purchase.amount or 0))
    if discount < 0:
        errors["amount"] = "Discount cannot be negative."
    elif not purchase._is_stock_transfer_purchase() and discount > subtotal:
        errors["amount"] = "Discount cannot exceed the purchase subtotal."
    if not purchase.supplier_id:
        errors["supplier"] = "Supplier is required before posting."
    if not purchase.warehouse_id:
        errors["warehouse"] = "Warehouse is required before posting."
    elif purchase.warehouse.shop_id != purchase.shop_id:
        errors["warehouse"] = "Warehouse must belong to the purchase branch."
    if not purchase.currency_id:
        errors["currency"] = "Currency is required before posting."
    if purchase.currency_id and purchase.currency.shop_id != purchase.shop_id:
        errors["currency"] = "Currency must belong to the purchase branch."
    if not items:
        errors["items"] = "At least one purchase item is required."
    for index, item in enumerate(items):
        row = {}
        if not item.product_id:
            row["product"] = "Product is required."
        elif item.product.shop_id != purchase.shop_id:
            row["product"] = "Product must belong to the purchase branch."
        elif not item.product.default_unit_id:
            row["unit"] = (
                "This product has no default unit. Open Products, edit the product, "
                "and select its default unit before posting."
            )
        elif item.unit_id != item.product.default_unit_id:
            row["unit"] = "Purchase unit must match the product's default unit."
        if item.product_id and item.product.requires_batch and not item.batch_number.strip():
            row["batch_number"] = "Batch number is required for this product."
        if item.product_id and item.product.requires_expiry and not item.expire_date:
            row["expire_date"] = "Expiry date is required for this product."
        if item.manufacture_date and item.expire_date and item.expire_date < item.manufacture_date:
            row["expire_date"] = "Expiry date cannot be before manufacture date."
        if Decimal(str(item.quantity or 0)) <= 0:
            row["quantity"] = "Quantity must be greater than zero."
        if Decimal(str(item.buy_price or 0)) < 0:
            row["buy_price"] = "Unit cost cannot be negative."
        if row:
            errors[f"items[{index}]"] = [
                f"{field}: {message}" for field, message in row.items()
            ]
    if errors:
        raise ValidationError(errors)


def _purchase_journal(purchase, items, user):
    total = sum(
        (Decimal(str(item.quantity)) * Decimal(str(item.buy_price)) for item in items),
        Decimal("0"),
    ) - Decimal(str(purchase.amount or 0))
    create_system_journal(
        shop=purchase.shop,
        entry_date=purchase.purchase_date,
        source_type="PURCHASE",
        source_id=purchase.pk,
        description=f"Purchase {purchase.purchase_number}",
        currency=purchase.currency,
        exchange_rate=purchase.exchange_rate_used or 1,
        user=user,
        lines=[
            {"code": "1500", "side": "debit", "amount": total, "supplier": purchase.supplier},
            {"code": "2100", "side": "credit", "amount": total, "supplier": purchase.supplier},
        ],
    )


def _receipt_matches_purchase(purchase, items, movements):
    """Return true only when existing immutable receipts exactly cover the draft."""
    expected = defaultdict(lambda: [Decimal("0"), Decimal("0")])
    for item in items:
        batch_number = item.batch_number.strip() or "NO-BATCH"
        batch = InventoryBatch.objects.filter(
            product_id=item.product_id, batch_number=batch_number
        ).first()
        if not batch or batch.manufacture_date != item.manufacture_date or batch.expiry_date != item.expire_date:
            return False
        key = (purchase.warehouse_id, item.product_id, batch.pk)
        quantity = Decimal(str(item.quantity)).quantize(Decimal("0.0001"))
        cost = Decimal(str(item.buy_price)).quantize(Decimal("0.000001"))
        expected[key][0] += quantity
        expected[key][1] += quantity * cost

    received = defaultdict(lambda: [Decimal("0"), Decimal("0")])
    for movement in movements:
        if movement.quantity_out != 0 or movement.currency_id != purchase.currency_id:
            return False
        key = (movement.warehouse_id, movement.product_id, movement.batch_id)
        received[key][0] += movement.quantity_in
        received[key][1] += movement.quantity_in * movement.unit_cost

    if set(received) != set(expected):
        return False
    return all(
        received[key][0] == expected[key][0]
        and received[key][1].quantize(Decimal("0.000001"))
        == expected[key][1].quantize(Decimal("0.000001"))
        for key in expected
    )


@transaction.atomic
def post_purchase(purchase_id, *, user):
    purchase = Purchase.objects.select_for_update().select_related(
        "shop", "supplier", "warehouse", "currency"
    ).get(pk=purchase_id)
    if not purchase.uses_inventory_engine:
        raise ValidationError("This legacy purchase cannot be posted into the new engine automatically.")
    if purchase.document_status != Purchase.DocumentStatus.DRAFT:
        raise ValidationError("Only draft purchases can be posted.")
    items = list(purchase.purchase_items.filter(is_deleted=False).select_related("product", "unit"))
    existing_receipts = list(StockMovement.objects.filter(
        reference_type="Purchase",
        reference_id=str(purchase.pk),
        movement_type=StockMovement.MovementType.PURCHASE,
    ).select_related("batch"))
    _validate_purchase(purchase, items)
    if existing_receipts:
        if not _receipt_matches_purchase(purchase, items, existing_receipts):
            raise ValidationError(
                "This purchase has incomplete or mismatched stock receipts. No additional stock was received. "
                "An administrator must reconcile its inventory ledger before posting."
            )
        if not purchase.purchase_number:
            purchase.purchase_number = _purchase_number(purchase)
        purchase.document_status = Purchase.DocumentStatus.POSTED
        purchase.posted_at = purchase.posted_at or timezone.now()
        purchase.posted_by = purchase.posted_by or user
        purchase.save(update_fields=[
            "purchase_number", "document_status", "posted_at", "posted_by", "updated_at"
        ])
        _purchase_journal(purchase, items, user)
        return purchase
    if not purchase.purchase_number:
        purchase.purchase_number = _purchase_number(purchase)
    for item in items:
        batch_number = item.batch_number.strip() or "NO-BATCH"
        batch, created = InventoryBatch.objects.get_or_create(
            product=item.product, batch_number=batch_number,
            defaults={
                "manufacture_date": item.manufacture_date,
                "expiry_date": item.expire_date,
                "created_by": user,
                "notes": f"Created by purchase {purchase.purchase_number}",
            },
        )
        if not created and (
            batch.manufacture_date != item.manufacture_date
            or batch.expiry_date != item.expire_date
        ):
            raise ValidationError(
                {"items": f"Batch {batch_number} already exists with different dates."}
            )
        receive_stock(
            warehouse=purchase.warehouse, product=item.product, batch=batch,
            quantity=item.quantity, unit_cost=item.buy_price, currency=purchase.currency,
            movement_type=StockMovement.MovementType.PURCHASE,
            reference_type="Purchase", reference_id=purchase.pk,
            remarks=purchase.notes, created_by=user,
        )
    purchase.document_status = Purchase.DocumentStatus.POSTED
    purchase.posted_at = timezone.now()
    purchase.posted_by = user
    purchase.save(update_fields=[
        "purchase_number", "document_status", "posted_at", "posted_by", "updated_at"
    ])
    _purchase_journal(purchase, items, user)
    return purchase


@transaction.atomic
def void_purchase(purchase_id, *, user, reason):
    purchase = Purchase.objects.select_for_update().get(pk=purchase_id)
    if not purchase.uses_inventory_engine or purchase.document_status != Purchase.DocumentStatus.POSTED:
        raise ValidationError("Only posted new-engine purchases can be cancelled.")
    if not str(reason or "").strip():
        raise ValidationError({"void_reason": "A cancellation reason is required."})
    receipt_qs = StockMovement.objects.filter(
        reference_type="Purchase",
        reference_id=str(purchase.pk),
        movement_type=StockMovement.MovementType.PURCHASE,
    )
    receipt_rows = list(
        receipt_qs.values(
            "warehouse_id", "product_id", "product__name",
            "batch_id", "batch__batch_number",
        ).annotate(quantity=Sum("quantity_in"))
    )
    if not receipt_rows:
        raise ValidationError(
            "This posted purchase has no receipt movements. Cancellation was "
            "stopped; run the inventory ledger audit."
        )

    # Calculate only the quantity which still needs reversing. This makes a retry
    # safe after a recoverable partial cancellation and understands the historical
    # compensating repairs created by the inventory audit workflow.
    required = {}
    for row in receipt_rows:
        key = (row["warehouse_id"], row["product_id"], row["batch_id"])
        prior_reversed = StockMovement.objects.filter(
            warehouse_id=key[0], product_id=key[1], batch_id=key[2],
            reference_type__in=("PurchaseVoid", "PurchaseVoidCompletion"),
            reference_id=str(purchase.pk),
            movement_type=StockMovement.MovementType.VOID_REVERSAL,
        ).aggregate(total=Sum("quantity_out"))["total"] or Decimal("0")
        prior_repaired = StockMovement.objects.filter(
            warehouse_id=key[0], product_id=key[1], batch_id=key[2],
            reference_type="PurchaseVoidRepair",
            reference_id=str(purchase.pk),
            movement_type=StockMovement.MovementType.ADJUSTMENT_IN,
        ).aggregate(total=Sum("quantity_in"))["total"] or Decimal("0")
        needed = (row["quantity"] or Decimal("0")) + prior_repaired - prior_reversed
        if needed < 0:
            raise ValidationError(
                {"stock": f'{row["product__name"]} batch {row["batch__batch_number"]} '
                          "has more cancellation reversals than receipts and repairs."}
            )
        required[key] = {"quantity": needed, "row": row}

    # Preflight every affected batch before writing the first immutable movement.
    # transaction.atomic handles InnoDB; this guard also prevents ordinary stock
    # shortages from causing partial cancellation on legacy MyISAM installations.
    balances = {
        (balance.warehouse_id, balance.product_id, balance.batch_id): balance
        for balance in StockBalance.objects.select_for_update().filter(
            warehouse_id__in={key[0] for key in required},
            product_id__in={key[1] for key in required},
            batch_id__in={key[2] for key in required},
        )
    }
    insufficient = []
    for key, details in required.items():
        needed = details["quantity"]
        available = balances[key].quantity if key in balances else Decimal("0")
        if available < needed:
            row = details["row"]
            insufficient.append(
                f'{row["product__name"]} (batch {row["batch__batch_number"]}): '
                f"required {needed}, available {available}"
            )
    if insufficient:
        raise ValidationError(
            {
                "stock": [
                    "Purchase cannot be cancelled because some received stock has "
                    "already been sold, transferred, returned, or adjusted. Nothing "
                    "was reversed."
                ] + insufficient
            }
        )

    for key, details in required.items():
        needed = details["quantity"]
        if needed <= 0:
            continue
        movement = receipt_qs.filter(
            warehouse_id=key[0], product_id=key[1], batch_id=key[2]
        ).select_related("warehouse", "product", "batch").order_by("id").first()
        issue_stock_batch(
            warehouse=movement.warehouse, product=movement.product, batch=movement.batch,
            quantity=needed,
            movement_type=StockMovement.MovementType.VOID_REVERSAL,
            reference_type="PurchaseVoid", reference_id=purchase.pk,
            remarks=str(reason).strip(), created_by=user,
        )
    purchase.document_status = Purchase.DocumentStatus.VOID
    purchase.voided_at = timezone.now()
    purchase.voided_by = user
    purchase.void_reason = str(reason).strip()
    purchase.save(update_fields=[
        "document_status", "voided_at", "voided_by", "void_reason", "updated_at"
    ])
    void_system_journal(shop=purchase.shop, source_type="PURCHASE", source_id=purchase.pk, user=user, reason=str(reason).strip())
    return purchase
