"""Packing breakage: liquid → tank, bottle → vendor, packaging scrap."""
from __future__ import annotations

from decimal import Decimal, ROUND_HALF_UP

from django.db import transaction
from django.db.models import F, Sum
from rest_framework.exceptions import ValidationError

from api.models.data.inventory_batch import InventoryBatch
from api.models.data.packing import PackingBreakage, PackingRun, PackingRunComponent
from api.models.data.raw_materials import RawMaterialPurchaseItem
from api.models.data.stock import Stock
from api.services.batch_queries import filter_batches_for_stock, on_hand_batch_q

FOUR = Decimal('0.0001')
SIX = Decimal('0.000001')
ZERO = Decimal('0')

# Components destroyed as waste when a bottle breaks (not returned to vendor).
SCRAP_COMPONENT_TYPES = {
    PackingRun.COMPONENT_CAP,
    PackingRun.COMPONENT_LABEL,
    PackingRun.COMPONENT_PLASTIC,
    PackingRun.COMPONENT_CARTON,
}


def _d(value) -> Decimal:
    return Decimal(str(value or 0))


def _q(value, places=FOUR) -> Decimal:
    return _d(value).quantize(places, rounding=ROUND_HALF_UP)


def broken_bottles_for_run(packing_run_id, *, exclude_breakage_id=None) -> int:
    qs = PackingBreakage.objects.filter(
        packing_run_id=packing_run_id,
        deleted_at__isnull=True,
    )
    if exclude_breakage_id is not None:
        qs = qs.exclude(pk=exclude_breakage_id)
    total = qs.aggregate(t=Sum('broken_bottle_count'))['t'] or 0
    return int(total)


def remaining_breakable_bottles(run: PackingRun, *, exclude_breakage_id=None) -> int:
    """Bottles still on hand that belong to this packing run."""
    already = broken_bottles_for_run(run.id, exclude_breakage_id=exclude_breakage_id)
    packed = int(run.bottle_count or 0)
    remaining_from_run = max(0, packed - already)

    stock = run.finished_stock
    if stock is None:
        return 0
    on_hand = _q(
        InventoryBatch.objects.filter(
            filter_batches_for_stock(stock.product, stock.condition),
            on_hand_batch_q(),
        ).aggregate(t=Sum('remaining_pieces'))['t'] or 0
    )
    return int(min(remaining_from_run, on_hand.to_integral_value()))


def _component_map(run: PackingRun) -> dict[str, PackingRunComponent]:
    return {c.component_type: c for c in run.components.all()}


def _unit_cost_for_component(component: PackingRunComponent, bottle_count: int) -> Decimal:
    """Cost attributable to one finished bottle for this packaging component."""
    if bottle_count <= 0:
        return ZERO
    return _q(_d(component.total_cost_afn) / Decimal(bottle_count), SIX)


def _resolve_bottle_vendor(component: PackingRunComponent | None):
    if component is None:
        return None, None
    purchase_item_id = None
    allocations = component.inventory_allocations or []
    if allocations:
        purchase_item_id = allocations[0].get('purchase_item')
    if not purchase_item_id:
        return None, None
    item = (
        RawMaterialPurchaseItem.objects.select_related('purchase__vendor')
        .filter(pk=purchase_item_id)
        .first()
    )
    if not item:
        return None, None
    return item.purchase.vendor, item


def _reduce_finished_stock(run: PackingRun, pieces: Decimal) -> None:
    stock = run.finished_stock
    if stock is None:
        raise ValidationError({'packing_run': 'This packing run has no finished stock.'})

    stock = Stock.objects.select_for_update().get(pk=stock.pk)
    batches = list(
        InventoryBatch.objects.select_for_update()
        .filter(
            filter_batches_for_stock(stock.product, stock.condition),
            on_hand_batch_q(),
        )
        .order_by('expire_date', 'created_at', 'id')
    )
    # Prefer the lot created by this packing run.
    preferred = [
        b for b in batches
        if run.packing_number and run.packing_number in (b.notes or '')
    ]
    ordered = preferred + [b for b in batches if b not in preferred]

    remaining = pieces
    for batch in ordered:
        if remaining <= 0:
            break
        available = _q(batch.remaining_pieces)
        if available <= 0:
            continue
        take = min(available, remaining)
        batch.remaining_pieces = _q(available - take)
        if batch.remaining_pieces <= 0:
            batch.status = InventoryBatch.STATUS_EMPTY
            batch.remaining_pieces = ZERO
        batch._skip_batch_journal = True
        batch.save(update_fields=['remaining_pieces', 'status', 'updated_at'])
        remaining = _q(remaining - take)

    if remaining > 0:
        raise ValidationError({
            'broken_bottle_count': (
                f'Not enough finished stock on hand. Short by {remaining} bottles.'
            )
        })

    stock.piece_amount = F('piece_amount') - pieces
    stock._skip_stock_journal = True
    stock.save(update_fields=['piece_amount', 'updated_at'])
    stock.refresh_from_db(fields=['piece_amount'])
    if _d(stock.piece_amount) < 0:
        stock.piece_amount = ZERO
        stock._skip_stock_journal = True
        stock.save(update_fields=['piece_amount', 'updated_at'])


def _restore_finished_stock(run: PackingRun, pieces: Decimal, unit_cost: Decimal) -> None:
    """Put bottles back onto the packing lot when a breakage is deleted."""
    stock = run.finished_stock
    if stock is None or pieces <= 0:
        return
    stock = Stock.objects.select_for_update().get(pk=stock.pk)
    batch = (
        InventoryBatch.objects.select_for_update()
        .filter(
            filter_batches_for_stock(stock.product, stock.condition),
            notes__icontains=run.packing_number,
        )
        .order_by('id')
        .first()
    )
    if batch:
        batch.remaining_pieces = _q(_d(batch.remaining_pieces) + pieces)
        if batch.status == InventoryBatch.STATUS_EMPTY and batch.remaining_pieces > 0:
            batch.status = InventoryBatch.STATUS_ACTIVE
        batch._skip_batch_journal = True
        batch.save(update_fields=['remaining_pieces', 'status', 'updated_at'])
    else:
        from api.utils.packaging import bottles_per_carton

        bpc = bottles_per_carton(stock.product) or Decimal('8')
        batch = InventoryBatch(
            product=stock.product,
            condition=stock.condition,
            cartons_received=pieces / bpc,
            pieces_received=pieces,
            remaining_pieces=pieces,
            unit_cost=_q(unit_cost, SIX),
            currency=stock.currency,
            status=InventoryBatch.STATUS_ACTIVE,
            notes=f'Restored from packing breakage · {run.packing_number}',
        )
        batch._skip_batch_journal = True
        batch.save()

    stock.piece_amount = F('piece_amount') + pieces
    stock._skip_stock_journal = True
    stock.save(update_fields=['piece_amount', 'updated_at'])


@transaction.atomic
def create_packing_breakage(
    *,
    packing_run: PackingRun,
    broken_bottle_count,
    notes: str = '',
) -> PackingBreakage:
    run = (
        PackingRun.objects.select_for_update()
        .select_related('storage_tank', 'finished_stock', 'product')
        .prefetch_related('components')
        .get(pk=packing_run.pk)
    )
    count = int(broken_bottle_count or 0)
    if count <= 0:
        raise ValidationError({
            'broken_bottle_count': 'Enter how many bottles broke (at least 1).',
        })

    available = remaining_breakable_bottles(run)
    if count > available:
        raise ValidationError({
            'broken_bottle_count': (
                f'Only {available} bottle(s) from this packing run can still be recorded '
                f'as broken (packed {run.bottle_count}, already broken '
                f'{broken_bottles_for_run(run.id)}).'
            ),
        })

    components = _component_map(run)
    bottle_component = components.get(PackingRun.COMPONENT_BOTTLE)
    if bottle_component is None:
        raise ValidationError({
            'packing_run': 'This packing run has no bottle component to return to a vendor.',
        })

    vendor, purchase_item = _resolve_bottle_vendor(bottle_component)
    if vendor is None or purchase_item is None:
        raise ValidationError({
            'packing_run': (
                'Cannot find the bottle purchase / vendor for this packing run. '
                'Breakage requires the original bottle purchase lot.'
            ),
        })

    bottle_volume = _q(run.bottle_volume_liters)
    liquid_liters = _q(Decimal(count) * bottle_volume)
    liquid_cost_per_bottle = (
        _q(_d(run.liquid_cost_afn) / Decimal(run.bottle_count), SIX)
        if run.bottle_count
        else ZERO
    )
    liquid_cost = _q(liquid_cost_per_bottle * Decimal(count))
    bottle_return_value = _q(
        _unit_cost_for_component(bottle_component, run.bottle_count) * Decimal(count),
    )

    scrap_details = {}
    scrap_cost = ZERO
    for ctype in SCRAP_COMPONENT_TYPES:
        component = components.get(ctype)
        if component is None:
            continue
        unit = _unit_cost_for_component(component, run.bottle_count)
        line_cost = _q(unit * Decimal(count))
        if line_cost <= 0:
            continue
        scrap_details[ctype] = {
            'quantity': count if ctype != PackingRun.COMPONENT_CARTON else str(
                _q(Decimal(count) / max(Decimal(run.bottle_count), Decimal('1'))
                   * _d(component.quantity_pieces))
            ),
            'unit_cost_afn': str(unit),
            'total_cost_afn': str(line_cost),
            'raw_material': (
                component.raw_material_stock.raw_material_id
                if component.raw_material_stock_id
                else None
            ),
            'material_name': (
                component.raw_material_stock.raw_material.name
                if component.raw_material_stock_id
                else None
            ),
        }
        scrap_cost += line_cost
    scrap_cost = _q(scrap_cost)

    _reduce_finished_stock(run, Decimal(count))

    breakage = PackingBreakage.objects.create(
        packing_run=run,
        storage_tank=run.storage_tank,
        bottle_vendor=vendor,
        bottle_purchase_item=purchase_item,
        broken_bottle_count=count,
        liquid_liters_returned=liquid_liters,
        liquid_cost_afn=liquid_cost,
        bottle_return_value_afn=bottle_return_value,
        scrap_cost_afn=scrap_cost,
        finished_stock_reduced=Decimal(count),
        scrap_details=scrap_details,
        notes=(notes or '').strip(),
    )
    return breakage


@transaction.atomic
def delete_packing_breakage(breakage: PackingBreakage) -> None:
    breakage = (
        PackingBreakage.objects.select_for_update()
        .select_related('packing_run', 'packing_run__finished_stock')
        .get(pk=breakage.pk)
    )
    run = breakage.packing_run
    pieces = _d(breakage.finished_stock_reduced or breakage.broken_bottle_count)
    unit_cost = _d(run.cost_per_bottle_afn)
    _restore_finished_stock(run, pieces, unit_cost)
    breakage.delete()
