from decimal import Decimal, ROUND_HALF_UP

from django.core.exceptions import ValidationError as DjangoValidationError
from django.db import transaction
from django.db.models import Sum
from rest_framework.exceptions import ValidationError

from api.models.data.currency import currency_details
from api.models.data.raw_material_process import (
    RawMaterialProcessItem,
)
from api.models.data.raw_materials import RawMaterial, RawMaterialPurchaseItem, RawMaterialStock
from api.services.currency_conversion import get_rate_to_base

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


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


def _quantize(value, places):
    return _decimal(value).quantize(places, rounding=ROUND_HALF_UP)


def _raise_currency_error(error):
    details = getattr(error, 'message_dict', None) or getattr(
        error, 'messages', None)
    raise ValidationError(details or str(error))


def _purchase_source_totals(process_items):
    totals = {}
    for sources in process_items.values_list('purchase_sources', flat=True):
        for source in sources or []:
            purchase_item_id = int(source.get('purchase_item') or 0)
            if not purchase_item_id:
                continue
            total = totals.setdefault(purchase_item_id, {
                'quantity': Decimal('0'),
                'piece_count': 0,
            })
            total['quantity'] += _decimal(source.get('quantity'))
            total['piece_count'] += int(source.get('piece_count') or 0)
    return totals


def _allocated_from_purchase_line(purchase_item):
    process_items = RawMaterialProcessItem.objects.filter(
        process__deleted_at__isnull=True,
        raw_material_id=purchase_item.raw_material_id,
    )
    return _purchase_source_totals(process_items).get(purchase_item.id, {
        'quantity': Decimal('0'),
        'piece_count': 0,
    })


def purchase_has_active_production_usage(purchase):
    """Return whether active production references any line from this purchase."""
    purchase_item_ids = set(purchase.items.values_list('id', flat=True))
    if not purchase_item_ids:
        return False
    process_items = RawMaterialProcessItem.objects.filter(
        process__deleted_at__isnull=True,
    )
    return any(
        int(source.get('purchase_item') or 0) in purchase_item_ids
        for sources in process_items.values_list('purchase_sources', flat=True)
        for source in sources or []
    )


@transaction.atomic
def restore_raw_materials_for_process(process):
    """Return a run's consumed stock before replacing its ingredients."""
    _return_process_material_stock(process, remove_items=True)


@transaction.atomic
def release_raw_materials_for_process(process):
    """Return a run's consumed stock while preserving its audit item rows."""
    _return_process_material_stock(process, remove_items=False)


def _return_process_material_stock(process, *, remove_items):
    items = list(process.items.select_for_update().all())
    for item in items:
        stock = RawMaterialStock.objects.select_for_update().get(
            raw_material_id=item.raw_material_id,
        )
        stock.quantity += item.quantity
        stock.piece_count += item.piece_count
        stock.save(update_fields=['quantity', 'piece_count', 'updated_at'])

    if remove_items:
        process.items.all().delete()


def get_available_raw_material_process_options(exclude_process_id=None):
    """Return in-stock materials and their unconsumed received purchase lots."""
    stocks = list(
        RawMaterialStock.objects.filter(
            raw_material__material_type=RawMaterial.TYPE_CHEMISTRY,
        )
        .select_related('raw_material')
        .order_by('raw_material__name')
    )
    material_ids = [stock.raw_material_id for stock in stocks]
    active_process_items = RawMaterialProcessItem.objects.filter(
        raw_material_id__in=material_ids,
        process__deleted_at__isnull=True,
    )
    if exclude_process_id is not None:
        active_process_items = active_process_items.exclude(
            process_id=exclude_process_id)
    process_items = RawMaterialProcessItem.objects.none()
    if exclude_process_id is not None:
        process_items = RawMaterialProcessItem.objects.filter(
            process_id=exclude_process_id,
            raw_material_id__in=material_ids,
        )
    allocations_by_lot = _purchase_source_totals(active_process_items)
    process_totals = {
        row['raw_material_id']: row
        for row in process_items.values('raw_material_id').annotate(
            quantity=Sum('quantity'),
            piece_count=Sum('piece_count'),
        )
    }

    lots_by_material = {}
    purchase_lines = RawMaterialPurchaseItem.objects.filter(
        raw_material_id__in=material_ids,
        purchase__status='received',
        purchase__deleted_at__isnull=True,
        quantity__gt=0,
    ).select_related('purchase').order_by('purchase__purchase_date', 'id')

    for purchase_item in purchase_lines:
        allocated = allocations_by_lot.get(purchase_item.id, {})
        remaining_quantity = max(
            Decimal('0'),
            _decimal(purchase_item.quantity) -
            _decimal(allocated.get('quantity')),
        )
        remaining_piece_count = max(
            0, purchase_item.piece_count -
            int(allocated.get('piece_count') or 0),
        )
        if remaining_quantity <= 0 and remaining_piece_count <= 0:
            continue

        purchase = purchase_item.purchase
        lots_by_material.setdefault(purchase_item.raw_material_id, []).append({
            'id': purchase_item.id,
            'purchase_id': purchase.id,
            'invoice_number': purchase.invoice_number,
            'bill_number': purchase.bill_number or '',
            'purchase_date': purchase.purchase_date,
            'currency': purchase.currency,
            'currency_details': currency_details(purchase.currency),
            'price_per_unit': purchase_item.price_per_unit,
            'price_per_unit_base': purchase_item.price_per_unit_base,
            'available_quantity': remaining_quantity,
            'available_piece_count': remaining_piece_count,
        })

    options = []
    for stock in stocks:
        current_process_usage = process_totals.get(stock.raw_material_id, {})
        available_quantity = (
            stock.quantity + _decimal(current_process_usage.get('quantity'))
        )
        available_piece_count = (
            stock.piece_count +
            int(current_process_usage.get('piece_count') or 0)
        )
        purchase_lots = lots_by_material.get(stock.raw_material_id, [])
        if available_quantity <= 0 or not purchase_lots:
            continue
        options.append({
            'raw_material': stock.raw_material_id,
            'raw_material_details': {
                'id': stock.raw_material_id,
                'name': stock.raw_material.name,
                'unit': stock.raw_material.unit,
                'material_type': stock.raw_material.material_type,
            },
            'available_quantity': available_quantity,
            'available_piece_count': available_piece_count,
            'purchase_lots': purchase_lots,
        })
    return options


@transaction.atomic
def consume_raw_materials_for_process(process, items_data):
    """Allocate ingredient quantities FIFO from purchase lines and cost them in AFN."""
    if not items_data:
        raise ValidationError({'items': 'Add at least one raw material.'})

    material_ids = [
        int(item['raw_material'])
        for item in items_data
        if item.get('raw_material') not in (None, '')
    ]
    if len(material_ids) != len(set(material_ids)):
        raise ValidationError({
            'items': 'A raw material can only be added once per production run.'
        })

    try:
        overhead_rate = get_rate_to_base(
            process.expense_currency,
            process.process_date,
            raise_if_missing=True,
        )
    except DjangoValidationError as error:
        _raise_currency_error(error)

    process.extra_expense_afn = _quantize(
        _decimal(process.extra_expense) * overhead_rate, FOUR
    )

    material_cost_total = Decimal('0')
    for item_data in items_data:
        raw_material_id = item_data.get('raw_material')
        quantity = _decimal(item_data.get('quantity'))
        piece_count = int(item_data.get('piece_count') or 0)
        purchase_item_id = item_data.get('purchase_item')

        if quantity <= 0:
            raise ValidationError(
                {'items': 'Ingredient quantities must be greater than zero.'})

        raw_material = RawMaterial.objects.get(pk=raw_material_id)
        if raw_material.material_type != RawMaterial.TYPE_CHEMISTRY:
            raise ValidationError({
                'items': f'{raw_material.name} is not categorized as a chemistry material.'
            })
        if raw_material.unit == RawMaterial.UNIT_PIECE and piece_count:
            raise ValidationError({
                'items': f'Additional package counts are only valid for kilogram materials ({raw_material.name}).'
            })

        stock = RawMaterialStock.objects.select_for_update().filter(
            raw_material=raw_material,
        ).first()
        if stock is None or stock.quantity < quantity:
            available = stock.quantity if stock else Decimal('0')
            raise ValidationError({
                'items': (
                    f'Insufficient stock for {raw_material.name}: '
                    f'{available} {raw_material.unit} available, {quantity} required.'
                )
            })
        if piece_count > stock.piece_count:
            raise ValidationError({
                'items': (
                    f'Insufficient package count for {raw_material.name}: '
                    f'{stock.piece_count} available, {piece_count} required.'
                )
            })

        process_item = RawMaterialProcessItem.objects.create(
            process=process,
            raw_material=raw_material,
            quantity=quantity,
            piece_count=piece_count,
        )
        quantity_remaining = quantity
        pieces_remaining = piece_count
        line_cost = Decimal('0')
        purchase_sources = []

        purchase_lines = RawMaterialPurchaseItem.objects.select_for_update().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'
        )
        if purchase_item_id not in (None, ''):
            purchase_lines = purchase_lines.filter(pk=purchase_item_id)
            if not purchase_lines.exists():
                raise ValidationError({
                    'items': f'The selected purchase price is not available for {raw_material.name}.'
                })

        for purchase_item in purchase_lines:
            already_allocated = _allocated_from_purchase_line(purchase_item)
            available_quantity = max(
                Decimal('0'),
                _decimal(purchase_item.quantity) -
                already_allocated['quantity'],
            )
            available_pieces = max(
                0, purchase_item.piece_count -
                already_allocated['piece_count'],
            )
            allocated_quantity = min(quantity_remaining, available_quantity)
            allocated_pieces = min(pieces_remaining, available_pieces)
            if allocated_quantity <= 0 and allocated_pieces <= 0:
                continue

            unit_cost_base = _quantize(purchase_item.price_per_unit_base, SIX)
            allocation_cost = _quantize(
                allocated_quantity * unit_cost_base, FOUR)
            purchase = purchase_item.purchase
            purchase_sources.append({
                'purchase_item': purchase_item.id,
                'purchase_invoice_number': purchase.invoice_number,
                'supplier_bill_number': purchase.bill_number or '',
                'purchase_date': purchase.purchase_date.isoformat(),
                'quantity': str(allocated_quantity),
                'piece_count': allocated_pieces,
                'unit_cost_afn': str(unit_cost_base),
                'total_cost_afn': str(allocation_cost),
            })
            quantity_remaining -= allocated_quantity
            pieces_remaining -= allocated_pieces
            line_cost += allocation_cost

            if quantity_remaining <= 0 and pieces_remaining <= 0:
                break

        if quantity_remaining > 0 or pieces_remaining > 0:
            source_message = (
                'The selected purchase does not have enough remaining quantity or packages.'
                if purchase_item_id not in (None, '')
                else 'The available stock cannot be fully costed from received FIFO purchases.'
            )
            raise ValidationError({
                'items': (
                    f'Insufficient purchase quantity for {raw_material.name}. '
                    f'{source_message}'
                )
            })

        process_item.material_cost_afn = _quantize(line_cost, FOUR)
        process_item.purchase_sources = purchase_sources
        process_item.save(update_fields=[
            'material_cost_afn', 'purchase_sources', 'updated_at',
        ])
        material_cost_total += line_cost
        stock.quantity -= quantity
        stock.piece_count -= piece_count
        stock.save(update_fields=['quantity', 'piece_count', 'updated_at'])

    process.material_cost_afn = _quantize(material_cost_total, FOUR)
    process.total_cost_afn = _quantize(
        process.material_cost_afn + process.extra_expense_afn, FOUR
    )
    process.cost_per_liter_afn = _quantize(
        process.total_cost_afn / _decimal(process.output_quantity_liters), SIX
    )
    process.save(update_fields=[
        'material_cost_afn',
        'extra_expense_afn',
        'total_cost_afn',
        'cost_per_liter_afn',
        'updated_at',
    ])
    return process
