from django.db import IntegrityError, transaction
from django.utils import timezone

from accounts.services import verify_manager_pin
from inventory.models import SparePart, StockMovement
from inventory.services import apply_stock_delta, check_stock_alerts
from notifications.models import NotificationType
from notifications.services import notify

from .models import Sale, SaleItem, SaleReturn, SaleReturnItem

MAX_INVOICE_ATTEMPTS = 5


class InsufficientStockError(Exception):
    def __init__(self, spare_part, requested, available):
        self.spare_part = spare_part
        self.requested = requested
        self.available = available
        super().__init__(
            f'Insufficient stock for {spare_part}: requested {requested}, available {available}.'
        )


class BelowMinimumPriceError(Exception):
    """Kept for backward compatibility; no longer raised by create_sale.

    A price outside [minimum_price, maximum_price] is now flagged (SaleItem.price_alert)
    and notified rather than blocked, unless the SparePart has enforce_price_range=True,
    in which case PriceRangeError is raised instead.
    """

    def __init__(self, spare_part, price, minimum_price):
        self.spare_part = spare_part
        self.price = price
        self.minimum_price = minimum_price
        super().__init__(
            f'Selling price for {spare_part} ({price}) is below the minimum allowed ({minimum_price}).'
        )


class PriceRangeError(Exception):
    def __init__(self, spare_part, price, minimum_price, maximum_price):
        self.spare_part = spare_part
        self.price = price
        self.minimum_price = minimum_price
        self.maximum_price = maximum_price
        super().__init__(
            f'Selling price for {spare_part} ({price}) is outside the allowed range '
            f'({minimum_price} - {maximum_price}), and this part requires a price within range.'
        )


class ExcessiveReturnError(Exception):
    def __init__(self, sale_item, requested, available):
        self.sale_item = sale_item
        self.requested = requested
        self.available = available
        super().__init__(
            f'Cannot return {requested} of {sale_item.spare_part}: only {available} of this line is returnable.'
        )


def _next_invoice_number():
    year = timezone.now().year
    prefix = f'INV-{year}-'
    last = (
        Sale.objects.select_for_update()
        .filter(invoice_number__startswith=prefix)
        .order_by('-invoice_number')
        .first()
    )
    next_seq = 1 if last is None else int(last.invoice_number.rsplit('-', 1)[-1]) + 1
    return f'{prefix}{next_seq:06d}'


def create_sale(*, sold_by, customer_name, customer_phone, payment_method, items, manager_approval=None):
    """Create a Sale with its items, locking and decrementing stock.

    `items` is an iterable of {'spare_part': <id or instance>, 'quantity': int,
    'unit_price': <Decimal, optional>}. A missing/omitted unit_price falls back to
    the SparePart's current selling_price.

    A price outside [minimum_price, maximum_price] never blocks the sale by itself —
    it's recorded on SaleItem.price_alert and a PRICE_OUT_OF_RANGE notification is
    sent to whichever roles have that enabled. The optional `manager_approval` (an
    {'username', 'pin'} dict) is verified once per sale and, if supplied, sets
    approved_by on every flagged line — but it's never required to complete the sale.
    The only thing that still blocks on price is a SparePart with
    enforce_price_range=True, which raises PriceRangeError instead of flagging.

    Raises InsufficientStockError if any line item exceeds available stock,
    PriceRangeError per the above, or ManagerApprovalError if a supplied approval
    is invalid.
    """
    for _ in range(MAX_INVOICE_ATTEMPTS):
        try:
            with transaction.atomic():
                invoice_number = _next_invoice_number()
                sale = Sale.objects.create(
                    invoice_number=invoice_number,
                    customer_name=customer_name,
                    customer_phone=customer_phone,
                    payment_method=payment_method,
                    sold_by=sold_by,
                )
                sale_items = []
                movements = []
                stock_checks = []
                flagged_items = []
                approving_manager = None
                approval_checked = False
                for item in items:
                    spare_part_id = getattr(item['spare_part'], 'pk', item['spare_part'])
                    part = SparePart.objects.select_for_update().get(pk=spare_part_id)
                    quantity = item['quantity']
                    if part.quantity < quantity:
                        raise InsufficientStockError(part, quantity, part.quantity)
                    unit_price = item.get('unit_price')
                    if unit_price is None:
                        unit_price = part.selling_price

                    price_alert = ''
                    if part.minimum_price is not None and unit_price < part.minimum_price:
                        price_alert = SaleItem.PriceAlert.BELOW_MINIMUM
                    elif part.maximum_price is not None and unit_price > part.maximum_price:
                        price_alert = SaleItem.PriceAlert.ABOVE_MAXIMUM

                    approved_by = None
                    if price_alert:
                        if part.enforce_price_range:
                            raise PriceRangeError(part, unit_price, part.minimum_price, part.maximum_price)
                        if manager_approval is not None:
                            if not approval_checked:
                                approval_checked = True
                                approving_manager = verify_manager_pin(
                                    username=manager_approval['username'], pin=manager_approval['pin'],
                                )
                            approved_by = approving_manager
                        flagged_items.append((part, price_alert, unit_price))

                    sale_items.append(SaleItem(
                        sale=sale,
                        spare_part=part,
                        quantity=quantity,
                        unit_cost=part.buying_price,
                        unit_price=unit_price,
                        price_alert=price_alert,
                        approved_by=approved_by,
                    ))
                    part, previous_quantity = apply_stock_delta(spare_part_id=part.pk, delta=-quantity)
                    stock_checks.append((part, previous_quantity))
                    movements.append(StockMovement(
                        spare_part=part,
                        movement_type=StockMovement.MovementType.SALE,
                        quantity=-quantity,
                        reference=invoice_number,
                        created_by=sold_by,
                        before_quantity=previous_quantity,
                        after_quantity=part.quantity,
                    ))
                SaleItem.objects.bulk_create(sale_items)
                StockMovement.objects.bulk_create(movements)

            # Best-effort side effects, deliberately outside the transaction and
            # never allowed to turn an already-committed sale into an error response.
            try:
                for part, previous_quantity in stock_checks:
                    check_stock_alerts(part, previous_quantity)
                for part, price_alert, unit_price in flagged_items:
                    notify(
                        NotificationType.PRICE_OUT_OF_RANGE,
                        title=f'{part.name} sold outside normal price range',
                        message=(
                            f'{part.part_number} sold at {unit_price} '
                            f'({price_alert.replace("_", " ").lower()}) on {invoice_number}.'
                        ),
                        link_path='/app/sales',
                    )
            except Exception:
                pass

            return sale
        except IntegrityError:
            continue
    raise RuntimeError('Could not generate a unique invoice number.')


def create_return(*, sale, processed_by, reason, items):
    """Return some quantity of one or more of a Sale's items, restocking and logging it.

    `items` is an iterable of {'sale_item': <id or instance>, 'quantity': int}.
    Each sale_item must belong to `sale`. Raises ExcessiveReturnError if a
    requested quantity, combined with quantities already returned against that
    line, would exceed what was originally sold.
    """
    with transaction.atomic():
        sale_return = SaleReturn.objects.create(sale=sale, processed_by=processed_by, reason=reason)
        return_items = []
        movements = []
        for item in items:
            sale_item_id = getattr(item['sale_item'], 'pk', item['sale_item'])
            sale_item = SaleItem.objects.select_for_update().select_related('spare_part').get(
                pk=sale_item_id, sale=sale,
            )
            quantity = item['quantity']
            available = sale_item.quantity - sale_item.returned_quantity
            if quantity > available:
                raise ExcessiveReturnError(sale_item, quantity, available)

            part, previous_quantity = apply_stock_delta(spare_part_id=sale_item.spare_part_id, delta=quantity)

            return_items.append(SaleReturnItem(
                sale_return=sale_return,
                sale_item=sale_item,
                quantity=quantity,
                unit_price=sale_item.unit_price,
                unit_cost=sale_item.unit_cost,
            ))
            movements.append(StockMovement(
                spare_part=part,
                movement_type=StockMovement.MovementType.RETURN,
                quantity=quantity,
                reference=sale.invoice_number,
                created_by=processed_by,
                before_quantity=previous_quantity,
                after_quantity=part.quantity,
            ))
        SaleReturnItem.objects.bulk_create(return_items)
        StockMovement.objects.bulk_create(movements)
    return sale_return
