from decimal import Decimal, ROUND_HALF_UP

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

from api.models.data.currency import BASE_CURRENCY_ID
from api.models.data.inventory_batch import InventoryBatch
from api.models.data.packing import PackingRun, PackingRunComponent
from api.models.data.products import Product
from api.models.data.raw_material_process import RawMaterialProcess, RawMaterialProcessItem
from api.models.data.raw_materials import RawMaterial, RawMaterialPurchaseItem, RawMaterialStock
from api.models.data.sale_batch_allocation import SaleBatchAllocation
from api.models.data.stock import Stock
from api.models.data.storage_tank import ProductionStorageTransfer, StorageTank
from api.utils.packaging import PIECES_PER_CARTON, expected_packing_component_quantities

FOUR = Decimal('0.0001')
SIX = Decimal('0.000001')
PACKING_COMPONENTS = {key for key, _ in PackingRun.COMPONENT_CHOICES}
REQUIRED_PACKING_COMPONENTS = PACKING_COMPONENTS - {PackingRun.COMPONENT_CAP}


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


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


def _tank_liquid_totals(storage_tank, *, lock=False, exclude_packing_run_id=None,
                        exclude_sale_item_id=None):
    from api.services.tank_inventory import get_tank_liquid_totals

    return get_tank_liquid_totals(
        storage_tank,
        lock=lock,
        exclude_packing_run_id=exclude_packing_run_id,
        exclude_sale_item_id=exclude_sale_item_id,
    )


def _product_for_tank(storage_tank):
    production_names = list(ProductionStorageTransfer.objects.filter(
        storage_tank=storage_tank,
        deleted_at__isnull=True,
        production__deleted_at__isnull=True,
        production__status=RawMaterialProcess.STATUS_MOVED_TO_STORAGE,
    ).values_list('production__product_name', flat=True).distinct())
    names = {name.strip()
             for name in production_names if name and name.strip()}
    if len(names) != 1:
        raise ValidationError({
            'storage_tank': (
                'The selected tank must contain production with one product name '
                'to determine the finished inventory item.'
            )
        })
    product_name = names.pop()
    product = Product.objects.filter(
        name__iexact=product_name).order_by('id').first()
    if product is None:
        return Product(name=product_name)
    return product


def _format_bottle_capacity_label(capacity_liters) -> str:
    """Human bottle size: 500 ml / 750 ml / 1 L."""
    cap = _q(capacity_liters).normalize()
    if cap <= 0:
        return '0 L'
    if cap < 1:
        ml = (cap * Decimal('1000')).quantize(Decimal('1'))
        return f'{ml} ml'
    return f'{format(cap, "f")} L'


def _packaged_product(base_product, bottle_capacity, carton_capacity, bottles_per_carton):
    """
    Finished stock SKU named from packaging only (bottle · plastic · carton),
    not the long process product title. Process name is kept in description.
    """
    bottle_label = _format_bottle_capacity_label(bottle_capacity)
    product_name = f'{bottle_label} · plastic · {int(bottles_per_carton)}/ctn'
    description = (base_product.name or '').strip()
    if base_product.description:
        description = f'{description} — {base_product.description}'.strip(' —')
    return Product.objects.update_or_create(
        name=product_name,
        defaults={
            'description': description,
            'bottle_capacity_liters': bottle_capacity,
            'carton_capacity_liters': carton_capacity,
            'bottles_per_carton': bottles_per_carton,
        },
    )[0]


def get_available_packing_tanks(*, exclude_packing_run_id=None):
    results = []
    for tank in StorageTank.objects.order_by('name', 'id'):
        liters, _, liquid_cost_per_liter = _tank_liquid_totals(
            tank,
            exclude_packing_run_id=exclude_packing_run_id,
        )
        if liters <= 0:
            continue
        results.append({
            'id': tank.id,
            'name': tank.name,
            'capacity_liters': str(tank.capacity_liters),
            'current_liters': str(liters),
            'available_liters': str(liters),
            'liquid_cost_per_liter_afn': str(liquid_cost_per_liter),
        })
    return results


def _raw_material_purchase_usage(*, exclude_packing_run_id=None):
    usage = {}
    source_lists = RawMaterialProcessItem.objects.filter(
        process__deleted_at__isnull=True,
    ).values_list('purchase_sources', flat=True)
    packing_components = PackingRunComponent.objects.filter(
        raw_material_stock__isnull=False,
        packing_run__deleted_at__isnull=True,
        deleted_at__isnull=True,
    )
    if exclude_packing_run_id is not None:
        packing_components = packing_components.exclude(
            packing_run_id=exclude_packing_run_id,
        )
    source_lists = list(source_lists) + list(
        packing_components.values_list('inventory_allocations', flat=True)
    )
    for sources in source_lists:
        for source in sources or []:
            purchase_item_id = int(source.get('purchase_item') or 0)
            if not purchase_item_id:
                continue
            allocation = usage.setdefault(purchase_item_id, {
                'quantity': Decimal('0'),
                'piece_count': 0,
            })
            allocation['quantity'] += _d(source.get('quantity'))
            allocation['piece_count'] += int(source.get('piece_count') or 0)
    return usage


def _raw_material_purchase_lines(raw_material, *, lock=False):
    lines = RawMaterialPurchaseItem.objects.filter(
        raw_material=raw_material,
        purchase__status='received',
        purchase__deleted_at__isnull=True,
        quantity__gt=0,
    ).select_related('purchase').order_by('purchase__purchase_date', 'id')
    return lines.select_for_update() if lock else lines


def get_available_packing_components(*, exclude_packing_run_id=None):
    results = []
    usage = _raw_material_purchase_usage(
        exclude_packing_run_id=exclude_packing_run_id,
    )
    stock_ids_in_run = set()
    run_pieces_by_stock = {}
    if exclude_packing_run_id is not None:
        for component in PackingRunComponent.objects.filter(
            packing_run_id=exclude_packing_run_id,
            raw_material_stock__isnull=False,
        ):
            stock_id = component.raw_material_stock_id
            stock_ids_in_run.add(stock_id)
            run_pieces_by_stock[stock_id] = (
                run_pieces_by_stock.get(stock_id, Decimal('0'))
                + _d(component.quantity_pieces)
            )
    stocks = RawMaterialStock.objects.filter(
        Q(raw_material__unit=RawMaterial.UNIT_PIECE, quantity__gt=0)
        | Q(raw_material__unit=RawMaterial.UNIT_KG, piece_count__gt=0)
        | Q(pk__in=stock_ids_in_run),
    ).select_related('raw_material').order_by('raw_material__name')

    for stock in stocks:
        uses_piece_count = stock.raw_material.unit == RawMaterial.UNIT_KG
        purchase_lines = list(_raw_material_purchase_lines(stock.raw_material))
        stock_balance = _d(
            stock.piece_count if uses_piece_count else stock.quantity
        ) + run_pieces_by_stock.get(stock.id, Decimal('0'))
        quantity_remaining = stock_balance
        purchase_lots = []
        available_quantity = Decimal('0')
        total_cost = Decimal('0')
        for line in purchase_lines:
            if uses_piece_count:
                layer_balance = Decimal(max(
                    0,
                    line.piece_count -
                    usage.get(line.id, {}).get('piece_count', 0),
                ))
                unit_cost = (
                    _d(line.total_price_base) / line.piece_count
                    if line.piece_count > 0
                    else Decimal('0')
                )
            else:
                layer_balance = max(
                    Decimal('0'),
                    _d(line.quantity) - usage.get(line.id, {}).get(
                        'quantity', Decimal('0')),
                )
                unit_cost = _d(line.price_per_unit_base)
            allocated = min(quantity_remaining, layer_balance)
            if allocated <= 0:
                continue
            lot_cost = _q(allocated * unit_cost)
            purchase_lots.append({
                'id': line.id,
                'invoice_number': line.purchase.invoice_number,
                'bill_number': line.purchase.bill_number or '',
                'purchase_date': line.purchase.purchase_date.isoformat(),
                'available_quantity': str(_q(allocated)),
                'unit_cost_afn': str(_q(unit_cost, SIX)),
            })
            available_quantity += allocated
            total_cost += lot_cost
            quantity_remaining -= allocated
            if quantity_remaining <= 0:
                break
        if available_quantity <= 0:
            continue
        results.append({
            'raw_material': stock.raw_material_id,
            'raw_material_name': stock.raw_material.name,
            'material_type': stock.raw_material.material_type,
            'unit': RawMaterial.UNIT_PIECE,
            'capacity_liters': str(stock.raw_material.capacity_liters) if stock.raw_material.capacity_liters is not None else None,
            'capacity_display_unit': stock.raw_material.capacity_display_unit,
            'bottles_per_carton': stock.raw_material.bottles_per_carton,
            'available_quantity': str(_q(available_quantity)),
            'unit_cost_afn': str(_q(total_cost / available_quantity, SIX)),
            'purchase_lots': purchase_lots,
        })
    return results


def _consume_raw_material_component(stock, quantity, purchase_item_id):
    quantity = _q(quantity)
    material = stock.raw_material
    uses_piece_count = material.unit == RawMaterial.UNIT_KG
    if quantity <= 0:
        raise ValidationError(
            {'components': 'Packaging quantities must be greater than zero.'})
    available_stock = stock.piece_count if uses_piece_count else stock.quantity
    if quantity > _d(available_stock):
        raise ValidationError({
            'components': (
                f'Not enough {material.name} in raw-material stock. '
                f'Available: {_q(available_stock)} pieces.'
            )
        })

    usage = _raw_material_purchase_usage()
    remaining = quantity
    total_cost = Decimal('0')
    allocations = []
    purchase_lines = _raw_material_purchase_lines(
        material, lock=True,
    ).filter(pk=purchase_item_id)
    if not purchase_lines.exists():
        raise ValidationError({
            'components': f'Select a received purchase for {material.name}.'
        })
    for line in purchase_lines:
        if remaining <= 0:
            break
        if uses_piece_count:
            available = max(
                0, line.piece_count -
                usage.get(line.id, {}).get('piece_count', 0),
            )
        else:
            available = max(
                Decimal('0'),
                _d(line.quantity) - usage.get(line.id, {}).get(
                    'quantity', Decimal('0')),
            )
        take = min(remaining, available)
        if take <= 0:
            continue
        unit_cost = _q(
            _d(line.total_price_base) / line.piece_count
            if uses_piece_count and line.piece_count > 0
            else line.price_per_unit_base,
            SIX,
        )
        line_cost = _q(take * unit_cost)
        allocations.append({
            'purchase_item': line.id,
            'invoice_number': line.purchase.invoice_number,
            'purchase_date': line.purchase.purchase_date.isoformat(),
            'quantity': '0' if uses_piece_count else str(take),
            'piece_count': int(take) if uses_piece_count else 0,
            'unit_cost_afn': str(unit_cost),
            'total_cost_afn': str(line_cost),
        })
        total_cost += line_cost
        remaining -= take

    if remaining > 0:
        raise ValidationError({
            'components': (
                f'Could not cost all {material.name} from received FIFO purchases.'
            )
        })

    if uses_piece_count:
        stock.piece_count -= int(quantity)
        stock.save(update_fields=['piece_count', 'updated_at'])
    else:
        stock.quantity = _q(_d(stock.quantity) - quantity)
        stock.save(update_fields=['quantity', 'updated_at'])
    total_cost = _q(total_cost)
    return _q(total_cost / quantity, SIX), total_cost, allocations


def reverse_packing_run(run, *, action='edited'):
    """Restore packaging stock and remove finished bottles created by this run."""
    old_components = list(
        run.components.select_for_update().select_related(
            'raw_material_stock__raw_material',
        ).all()
    )
    for component in old_components:
        stock = component.raw_material_stock
        if stock is None:
            raise ValidationError({
                'packing': (
                    'This older packing run has no raw-material allocation '
                    f'record and cannot be safely {action}.'
                ),
            })
        quantity = _d(component.quantity_pieces)
        if stock.raw_material.unit == RawMaterial.UNIT_KG:
            if quantity != quantity.to_integral_value():
                raise ValidationError({
                    'packing': (
                        'Stored package usage is invalid; correct the run '
                        f'before it is {action}.'
                    ),
                })
            stock.piece_count += int(quantity)
            stock.save(update_fields=['piece_count', 'updated_at'])
        else:
            stock.quantity = _q(_d(stock.quantity) + quantity)
            stock.save(update_fields=['quantity', 'updated_at'])

    if run.finished_stock_id:
        finished_stock = Stock.objects.select_for_update().get(
            pk=run.finished_stock_id,
        )
        output_batch = InventoryBatch.objects.select_for_update().filter(
            product_id=run.product_id,
            condition=Stock.CONDITION_GOOD,
            notes=f'Packed from {run.storage_tank.name} in {run.packing_number}',
        ).first()
        if (
            output_batch is None
            or _d(output_batch.remaining_pieces) != _d(output_batch.pieces_received)
            or SaleBatchAllocation.objects.filter(
                inventory_batch_id=output_batch.id,
            ).exists()
        ):
            raise ValidationError({
                'packing': (
                    f'This run cannot be {action} because some finished bottles '
                    'have already been used or moved.'
                ),
            })
        current_pieces = _d(finished_stock.piece_amount)
        if current_pieces < run.bottle_count:
            raise ValidationError({
                'packing': 'Finished-stock balance is too low to reverse this packing run.',
            })
        remaining_pieces = current_pieces - Decimal(run.bottle_count)
        remaining_cost = (
            current_pieces * _d(finished_stock.buy_price)
            - Decimal(run.bottle_count) * _d(run.cost_per_bottle_afn)
        )
        finished_stock.piece_amount = remaining_pieces
        finished_stock.buy_price = (
            _q(remaining_cost / remaining_pieces, Decimal('0.01'))
            if remaining_pieces > 0
            else Decimal('0')
        )
        finished_stock._skip_stock_journal = True
        finished_stock.save(update_fields=[
            'piece_amount', 'buy_price', 'updated_at',
        ])
        if output_batch is not None:
            output_batch._skip_batch_journal = True
            output_batch.delete()

    run.components.all().delete()
    return run


@transaction.atomic
def update_packing_run(*, packing_run_id, storage_tank_id, quantity_liters,
                       bottle_volume_liters, components, notes=''):
    run = PackingRun.objects.select_for_update().filter(pk=packing_run_id).first()
    if run is None:
        raise ValidationError({'packing': 'Packing run not found.'})
    if run.breakages.filter(deleted_at__isnull=True).exists():
        raise ValidationError({
            'packing': (
                'This packing run has breakage records. Delete those first '
                'before editing the packing run.'
            ),
        })

    reverse_packing_run(run, action='edited')
    return create_packing_run(
        storage_tank_id=storage_tank_id,
        quantity_liters=quantity_liters,
        bottle_volume_liters=bottle_volume_liters,
        components=components,
        notes=notes,
        existing_run=run,
    )


@transaction.atomic
def delete_packing_run(*, packing_run_id, user=None):
    """Reverse inventory impact, then soft-delete the packing run."""
    run = PackingRun.objects.select_for_update().filter(pk=packing_run_id).first()
    if run is None:
        raise ValidationError({'packing': 'Packing run not found.'})
    if run.breakages.filter(deleted_at__isnull=True).exists():
        raise ValidationError({
            'packing': (
                'This packing run has breakage records. Delete those first '
                'before deleting the packing run.'
            ),
        })

    reverse_packing_run(run, action='deleted')
    if hasattr(run, 'soft_delete'):
        run.soft_delete(user=user)
    else:
        run.delete()
    return run


@transaction.atomic
def create_packing_run(*, storage_tank_id, quantity_liters, bottle_volume_liters,
                       components, notes='', existing_run=None):
    quantity_liters = _q(quantity_liters)
    if quantity_liters <= 0:
        raise ValidationError(
            {'quantity_liters': 'Liters to pack must be greater than zero.'})

    tank = StorageTank.objects.select_for_update().filter(pk=storage_tank_id).first()
    if tank is None:
        raise ValidationError({'storage_tank': 'Select a valid storage tank.'})

    tank_liters, _, liquid_cost_per_liter = _tank_liquid_totals(
        tank,
        lock=True,
        exclude_packing_run_id=existing_run.id if existing_run else None,
    )
    if quantity_liters > tank_liters:
        raise ValidationError({
            'quantity_liters': f'{tank.name} contains only {_q(tank_liters)} liters.'
        })
    liquid_cost_afn = _q(quantity_liters * liquid_cost_per_liter)
    base_product = _product_for_tank(tank)

    by_type = {}
    for component in components or []:
        component_type = component.get('component_type')
        raw_material_id = component.get('raw_material')
        purchase_item_id = component.get('purchase_item')
        quantity_pieces = _d(component.get('quantity_pieces'))
        if component_type == PackingRun.COMPONENT_CAP:
            if not raw_material_id and not purchase_item_id and quantity_pieces <= 0:
                continue
        if component_type not in PACKING_COMPONENTS or component_type in by_type:
            raise ValidationError(
                {'components': 'Select each packaging component once, and omit blank Cap entries.'})
        if not raw_material_id or not purchase_item_id or quantity_pieces <= 0:
            raise ValidationError(
                {'components': 'Select a raw material and purchase, and enter a positive used quantity for each included packaging item.'})
        if quantity_pieces != quantity_pieces.to_integral_value():
            raise ValidationError({
                'components': 'Packaging quantities must be whole pieces.'
            })
        by_type[component_type] = {
            'raw_material': raw_material_id,
            'purchase_item': purchase_item_id,
            'quantity_pieces': quantity_pieces,
        }
    if not REQUIRED_PACKING_COMPONENTS.issubset(by_type):
        raise ValidationError(
            {'components': 'Bottle, label, plastic, and carton are required. Cap is not stored when left blank.'})

    requested_bottle_volume = _q(bottle_volume_liters)
    bottle_material = RawMaterial.objects.get(pk=by_type[PackingRun.COMPONENT_BOTTLE]['raw_material'])
    carton_material = RawMaterial.objects.get(pk=by_type[PackingRun.COMPONENT_CARTON]['raw_material'])
    bottle_volume_liters = _q(bottle_material.capacity_liters)
    bottles_per_carton = int(carton_material.bottles_per_carton or 0)
    if bottle_volume_liters <= 0:
        raise ValidationError({
            'components': 'The selected bottle must have a positive capacity in liters.'
        })
    if bottles_per_carton <= 0:
        bottles_per_carton = int(PIECES_PER_CARTON)
    if requested_bottle_volume > 0 and requested_bottle_volume != bottle_volume_liters:
        raise ValidationError({'bottle_volume_liters': 'Bottle size must match the selected bottle material capacity.'})
    plastic_material = RawMaterial.objects.get(
        pk=by_type[PackingRun.COMPONENT_PLASTIC]['raw_material'])

    try:
        expected_quantities = expected_packing_component_quantities(
            quantity_liters=quantity_liters,
            bottle_capacity_liters=bottle_volume_liters,
            plastic_capacity_liters=plastic_material.capacity_liters,
            carton_bottle_capacity_liters=carton_material.capacity_liters,
            bottles_per_carton_count=bottles_per_carton,
            include_cap=PackingRun.COMPONENT_CAP in by_type,
        )
    except ValueError as exc:
        raise ValidationError({'components': str(exc)}) from exc

    bottle_count = int(expected_quantities['bottle'])
    # Total liquid liters represented by one full carton (for product snapshot).
    carton_capacity_liters = _q(bottle_volume_liters * Decimal(bottles_per_carton))
    # Finished goods count follows liquid ÷ bottle capacity.
    # Plastic/carton used quantities are accepted as entered (no capacity-match checks).

    product = _packaged_product(
        base_product,
        bottle_volume_liters,
        carton_capacity_liters,
        bottles_per_carton,
    )
    selected_material_ids = [selection['raw_material']
                             for selection in by_type.values()]
    if len(set(selected_material_ids)) != len(by_type):
        raise ValidationError(
            {'components': 'Select a different raw material for each packaging component.'})

    stocks = {
        stock.raw_material_id: stock
        for stock in RawMaterialStock.objects.select_for_update().filter(
            raw_material_id__in=[item['raw_material']
                                 for item in by_type.values()],
        ).select_related('raw_material').order_by('id')
    }
    if len(stocks) != len(by_type):
        raise ValidationError(
            {'components': 'A selected raw material is not available in piece-based stock.'})

    component_results = []
    packaging_cost_afn = Decimal('0')
    for component_type, selection in by_type.items():
        raw_material_id = selection['raw_material']
        stock = stocks[raw_material_id]
        expected_type = {
            PackingRun.COMPONENT_BOTTLE: RawMaterial.TYPE_BOTTLE,
            PackingRun.COMPONENT_CAP: RawMaterial.TYPE_CAP,
            PackingRun.COMPONENT_LABEL: RawMaterial.TYPE_LABEL,
            PackingRun.COMPONENT_PLASTIC: RawMaterial.TYPE_PLASTIC,
            PackingRun.COMPONENT_CARTON: RawMaterial.TYPE_CARTON,
        }[component_type]
        if stock.raw_material.material_type != expected_type:
            raise ValidationError({
                'components': f'Select a {component_type} raw material for this row.'
            })
        quantity_pieces = selection['quantity_pieces']
        unit_cost_afn, total_cost_afn, allocations = _consume_raw_material_component(
            stock, quantity_pieces, selection['purchase_item'],
        )
        component_results.append({
            'component_type': component_type,
            'raw_material_stock': stock,
            'quantity_pieces': quantity_pieces,
            'unit_cost_afn': unit_cost_afn,
            'total_cost_afn': total_cost_afn,
            'inventory_allocations': allocations,
        })
        packaging_cost_afn += total_cost_afn

    packaging_cost_afn = _q(packaging_cost_afn)
    total_cost_afn = _q(liquid_cost_afn + packaging_cost_afn)
    cost_per_bottle_afn = _q(total_cost_afn / bottle_count, SIX)
    finished_stock, _ = Stock.objects.select_for_update().get_or_create(
        product=product,
        condition=Stock.CONDITION_GOOD,
        defaults={
            'piece_amount': Decimal('0'),
            'buy_price': cost_per_bottle_afn,
            'currency': BASE_CURRENCY_ID,
        },
    )
    previous_pieces = _d(finished_stock.piece_amount)
    previous_price = _d(finished_stock.buy_price)
    combined_pieces = previous_pieces + Decimal(bottle_count)
    weighted_cost_per_bottle = (
        (previous_pieces * previous_price +
         Decimal(bottle_count) * cost_per_bottle_afn)
        / combined_pieces
        if combined_pieces > 0
        else cost_per_bottle_afn
    )
    finished_stock.buy_price = _q(weighted_cost_per_bottle, Decimal('0.01'))
    finished_stock.currency = BASE_CURRENCY_ID
    finished_stock._skip_stock_journal = True
    finished_stock.save(update_fields=['buy_price', 'currency', 'updated_at'])

    run = existing_run or PackingRun()
    run.storage_tank = tank
    run.product = product
    run.finished_stock = finished_stock
    run.quantity_liters = quantity_liters
    run.bottle_volume_liters = bottle_volume_liters
    run.bottle_count = bottle_count
    run.liquid_cost_afn = liquid_cost_afn
    run.packaging_cost_afn = packaging_cost_afn
    run.total_cost_afn = total_cost_afn
    run.cost_per_bottle_afn = cost_per_bottle_afn
    run.notes = notes or ''
    run.save()

    for component in component_results:
        PackingRunComponent.objects.create(
            packing_run=run,
            component_type=component['component_type'],
            raw_material_stock=component['raw_material_stock'],
            quantity_pieces=component['quantity_pieces'],
            unit_cost_afn=component['unit_cost_afn'],
            total_cost_afn=component['total_cost_afn'],
            inventory_allocations=component['inventory_allocations'],
        )

    # Cost already sits in Inventory from RM purchases; do not post Opening Equity.
    batch = InventoryBatch(
        product=product,
        condition=Stock.CONDITION_GOOD,
        cartons_received=Decimal(bottle_count) / bottles_per_carton,
        pieces_received=Decimal(bottle_count),
        remaining_pieces=Decimal(bottle_count),
        unit_cost=cost_per_bottle_afn,
        currency=BASE_CURRENCY_ID,
        status=InventoryBatch.STATUS_ACTIVE,
        notes=f'Packed from {tank.name} in {run.packing_number}',
    )
    batch._skip_batch_journal = True
    batch.save()
    Stock.objects.filter(pk=finished_stock.pk).update(
        piece_amount=F('piece_amount') + bottle_count,
    )
    return run
