"""Actual consumption -> commercial allocation -> immutable invoice.

All writes serialize on the existing ServiceCase lock after actor/path locks.
Inventory consumption is immutable; RETURNED dispositions mean unused custody,
not a reversal of CONSUMED. No stock-position/unit locks or inventory writes.
"""
from collections import defaultdict
from contextlib import contextmanager
from decimal import Decimal, ROUND_UP
from django.core.exceptions import ValidationError
from django.utils import timezone
from apps.devices.locking import persisted_pk
from apps.inventory.models import PartsDisposition
from apps.service.handover_services import _authorized_case, _technical_history
from apps.service.models import ServiceCase
from .models import ServiceQuotation, QuotationLine, ServiceInvoice, InvoiceSequence, InvoiceLine, InvoiceAllocation
from .money import calculate, decimal_value, CENT
from .services import text, revision

MANAGE = "commercial.manage_serviceinvoice"
FINALIZE = "commercial.finalize_serviceinvoice"
DRAFT_STATES = ("DIAGNOSED", "REPAIRING", "REPAIRED", "QC_PENDING", "QC_IN_PROGRESS", "QC_PASSED", "READY_FOR_DELIVERY")
FINAL_STATES = ("QC_PASSED", "READY_FOR_DELIVERY")


@contextmanager
def _context(actor, case, permission=MANAGE):
    with _authorized_case(case, actor, permission) as (snapshot, actor):
        # Deliberately do not acquire customer/device/catalog locks after Case.
        case = ServiceCase.objects.select_for_update(of=("self",)).select_related(
            "customer", "device", "service_center").get(pk=snapshot.pk)
        if case.status not in DRAFT_STATES:
            raise ValidationError("Invoice work requires an undelivered, uncancelled technical case.")
        yield actor, case


def _quote(case):
    row = ServiceQuotation.objects.filter(family__service_case=case, is_current=True, status="APPROVED").select_related("family", "decision").first()
    if row is None or row.decision.outcome != "APPROVED":
        raise ValidationError("A current approved quotation is required for billing; unknown responsibility is not free or covered.")
    return row


def _load(invoice):
    return ServiceInvoice.objects.select_related("service_case").get(pk=persisted_pk(invoice, ServiceInvoice))


def _lock(invoice, expected_revision):
    row = ServiceInvoice.objects.select_for_update().get(pk=invoice.pk)
    if not isinstance(expected_revision, str) or revision(row) != expected_revision:
        raise ValidationError("Invoice changed; reload and reconcile current evidence.")
    if row.status != "DRAFT":
        raise ValidationError("Finalized invoices and their provenance are immutable.")
    return row


def consumption_sources(case):
    """Posted immutable consumption; unused returns do not subtract used parts."""
    return PartsDisposition.objects.filter(issue__service_case=case, kind="CONSUMED").select_related(
        "issue__reservation__spare_part", "movement", "repair_action__repair_execution").order_by("recorded_at", "pk")


def _facts(case, quote):
    lines = {line.pk: line for line in quote.lines.filter(is_active=True).select_related("repair_action__repair_execution", "spare_part")}
    sources = {source.pk: source for source in consumption_sources(case)}
    for source in sources.values():
        if source.movement.kind != "CONSUME" or source.movement.quantity != source.quantity or (
                source.movement.spare_part_id != source.issue.reservation.spare_part_id or
                source.movement.company_id != case.company_id):
            raise ValidationError("Consumption provenance is inconsistent.")
    return lines, sources


def _matches(source, line):
    return (line.kind == "PART" and line.spare_part_id == source.issue.reservation.spare_part_id and
            (line.repair_action_id is None or line.repair_action_id == source.repair_action_id))


def _quantity(value):
    if type(value) is not int or not 1 <= value <= 1000000:
        raise ValidationError("Supply a positive whole quantity no greater than one million.")
    return value


def _automatic(lines, sources):
    result, proposed = [], defaultdict(int)
    for source in sources.values():
        candidates = [line for line in lines.values() if _matches(source, line)]
        # Even equivalent duplicate lines require explicit selection: never choose
        # a source by ordering or consume a capacity before another candidate.
        if len(candidates) == 1:
            line = candidates[0]
            result.append(dict(consumption=source, quotation_line=line, quantity=source.quantity, mode="AUTO", reason=""))
            proposed[line.pk] += source.quantity
    return [item for item in result if proposed[item["quotation_line"].pk] <= item["quotation_line"].quantity]


def _money(line, quantity, extra):
    # Apply the approved fixed discount proportionately. Round the discount up
    # to a cent so partial billing never removes an approved discount benefit.
    base_discount = (line.discount * quantity / line.quantity).quantize(CENT, rounding=ROUND_UP)
    extra = decimal_value(extra)
    values = calculate(quantity=quantity, unit_price=line.unit_price, discount=base_discount+extra, tax_rate=line.tax_rate)
    if values["total"] > line.total:
        raise ValidationError("Final charge exceeds approved line terms.")
    return values, extra


def _composition(case, quote, allocation_inputs, confirmations, adjustments):
    lines, sources = _facts(case, quote)
    source_used, line_used, allocations = defaultdict(int), defaultdict(int), []
    for item in allocation_inputs:
        if not isinstance(item, dict) or set(item) - {"consumption", "quotation_line", "quantity", "reason", "mode"}:
            raise ValidationError("Invalid consumption allocation fields.")
        sid = persisted_pk(item.get("consumption"), PartsDisposition)
        lid = persisted_pk(item.get("quotation_line"), QuotationLine)
        source, line = sources.get(sid), lines.get(lid)
        if source is None or line is None or not _matches(source, line):
            raise ValidationError("Allocation must link this case's consumed part to an applicable current approved line.")
        quantity = _quantity(item.get("quantity"))
        reason = text(item.get("reason", ""), 2000, required=item.get("mode") != "AUTO")
        if item.get("mode") == "AUTO" and len([l for l in lines.values() if _matches(source, l)]) != 1:
            raise ValidationError("Ambiguous consumption requires explicit authorized allocation.")
        if any(a[0].pk == sid and a[1].pk == lid for a in allocations):
            raise ValidationError("Combine repeated source/quotation-line allocations.")
        source_used[sid] += quantity
        line_used[lid] += quantity
        if source_used[sid] > source.quantity or line_used[lid] > line.quantity:
            raise ValidationError("Allocation exceeds actual consumption or approved quotation quantity.")
        allocations.append((source, line, quantity, item.get("mode", "MANUAL"), reason))
    notes = {}
    for item in confirmations:
        if not isinstance(item, dict) or set(item) - {"quotation_line", "quantity", "reason"}:
            raise ValidationError("Invalid performed-service confirmation fields.")
        lid = persisted_pk(item.get("quotation_line"), QuotationLine)
        line = lines.get(lid)
        if line is None or line.kind == "PART" or lid in notes:
            raise ValidationError("Confirm each approved labor/service line at most once.")
        quantity = _quantity(item.get("quantity"))
        if quantity > line.quantity:
            raise ValidationError("Confirmed quantity exceeds approved service scope.")
        if line.repair_action_id and (line.repair_action.performed_at is None or
                line.repair_action.repair_execution.service_case_id != case.pk):
            raise ValidationError("Referenced repair action has not been performed for this case.")
        notes[lid] = text(item.get("reason", ""), 2000, required=True)
        line_used[lid] = quantity
    adjustments_by_line = {}
    for item in adjustments:
        if not isinstance(item, dict) or set(item) - {"quotation_line", "additional_discount", "reason"}:
            raise ValidationError("Only explicit additional discounts are accepted; prices and responsibility come from approval.")
        lid = persisted_pk(item.get("quotation_line"), QuotationLine)
        if lid not in line_used or lid in adjustments_by_line:
            raise ValidationError("Discount must refer to one billed approved line.")
        adjustments_by_line[lid] = decimal_value(item.get("additional_discount"))
        notes[lid] = (notes.get(lid, "") + " Discount: " + text(item.get("reason", ""), 1000, required=True)).strip()
    prepared = []
    for lid, quantity in line_used.items():
        line = lines[lid]
        values, extra = _money(line, quantity, adjustments_by_line.get(lid, 0))
        prepared.append((line, quantity, values, extra, notes.get(lid, "")))
    unresolved = {sid: source.quantity-source_used[sid] for sid, source in sources.items() if source.quantity != source_used[sid]}
    return prepared, allocations, unresolved


def _replace(row, actor, prepared, allocations):
    for old in row.lines.filter(is_active=True):
        old.is_active = False
        old._persist()
    created = {}
    for quote_line, quantity, money, extra, note in prepared:
        line = InvoiceLine(invoice=row, quotation_line=quote_line, generation=row.generation,
            kind=quote_line.kind, responsibility=quote_line.responsibility, description=quote_line.description,
            part_code=quote_line.part_code, part_name=quote_line.part_name, tax_label=quote_line.tax_label,
            quantity=quantity, additional_discount=extra, confirmed_by=actor, confirmation_note=note, **money)
        setattr(line, {"CUSTOMER":"customer_pay", "WARRANTY":"warranty_covered", "COMPANY":"company_covered"}[line.responsibility], line.total)
        line._persist()
        created[quote_line.pk] = line
    for source, quote_line, quantity, mode, reason in allocations:
        InvoiceAllocation(line=created[quote_line.pk], consumption=source, quantity=quantity, mode=mode, actor=actor, reason=reason)._persist()
    for field, child in (("subtotal", "subtotal"), ("discount_total", "discount"), ("tax_total", "tax"),
            ("grand_total", "total"), ("customer_pay_total", "customer_pay"),
            ("warranty_covered_total", "warranty_covered"), ("company_covered_total", "company_covered")):
        setattr(row, field, decimal_value(sum((getattr(l, child) for l in created.values()), Decimal(0))))
    row._persist()


def prepare_service_invoice(*, actor, service_case, invoice=None, expected_revision=None, note=""):
    """Create/rebuild an automatically allocated draft, retaining old generations."""
    with _context(actor, service_case) as (actor, case):
        quote = _quote(case)
        if invoice is None:
            if ServiceInvoice.objects.filter(service_case=case).exists():
                raise ValidationError("This case already has an invoice; rebuild its draft with a revision token.")
            sequence, _ = InvoiceSequence.objects.select_for_update().get_or_create(service_center=case.service_center)
            if sequence.next_value >= 10**18:
                raise ValidationError("Invoice number sequence exhausted.")
            row = ServiceInvoice(service_case=case, service_center=case.service_center, number=f"INV-{sequence.next_value:08d}",
                quotation=quote, currency=quote.currency, created_by=actor, note=text(note, 4000))
            sequence.next_value += 1
            sequence.save(update_fields=["next_value"])
            row._persist()
        else:
            snapshot = _load(invoice)
            if snapshot.service_case_id != case.pk:
                raise ValidationError("Invoice belongs to another case.")
            row = _lock(snapshot, expected_revision)
            row.generation += 1
            row.quotation, row.currency, row.note = quote, quote.currency, text(note, 4000)
            row._persist()
        lines, sources = _facts(case, quote)
        prepared, allocations, _ = _composition(case, quote, _automatic(lines, sources), [], [])
        _replace(row, actor, prepared, allocations)
        return row


def reconcile_service_invoice(*, actor, invoice, expected_revision, allocations, confirmations=(), adjustments=(), note=""):
    """Replace the draft's complete allocation/confirmation set, preserving history."""
    if any(not isinstance(items, (list, tuple)) or len(items) > 1000 for items in (allocations, confirmations, adjustments)):
        raise ValidationError("Supply bounded reconciliation lists.")
    # AUTO is reserved for the deterministic preparer, never browser input.
    if any(not isinstance(a, dict) or "mode" in a for a in allocations):
        raise ValidationError("Manual allocation cannot claim automatic provenance.")
    snapshot = _load(invoice)
    with _context(actor, snapshot.service_case) as (actor, case):
        row = _lock(snapshot, expected_revision)
        quote = _quote(case)
        if quote.pk != row.quotation_id:
            raise ValidationError("Quotation changed; rebuild and review the draft first.")
        prepared, resolved, _ = _composition(case, quote, allocations, confirmations, adjustments)
        row.generation += 1
        row.note = text(note, 4000)
        _replace(row, actor, prepared, resolved)
        return row


def finalize_service_invoice(*, actor, invoice, expected_revision):
    snapshot = _load(invoice)
    with _context(actor, snapshot.service_case, FINALIZE) as (actor, case):
        if case.status not in FINAL_STATES:
            raise ValidationError("Final billing requires successful QC before customer handover.")
        _technical_history(case)
        row = _lock(snapshot, expected_revision)
        quote = _quote(case)
        if quote.pk != row.quotation_id or row.currency != quote.currency:
            raise ValidationError("Commercial approval changed; rebuild the invoice.")
        active = list(row.lines.filter(is_active=True).select_related("quotation_line").prefetch_related("allocations__consumption"))
        if not active:
            raise ValidationError("Confirm at least one actual billable service or consumed part.")
        allocations, confirmations, adjustments = [], [], []
        for line in active:
            if line.kind == "PART":
                allocations.extend(dict(consumption=a.consumption, quotation_line=line.quotation_line, quantity=a.quantity,
                    mode=a.mode, reason=a.reason) for a in line.allocations.all())
            else:
                confirmations.append(dict(quotation_line=line.quotation_line, quantity=line.quantity, reason=line.confirmation_note))
            if line.additional_discount:
                adjustments.append(dict(quotation_line=line.quotation_line, additional_discount=line.additional_discount, reason=line.confirmation_note))
        prepared, _, unresolved = _composition(case, quote, allocations, confirmations, adjustments)
        if unresolved:
            raise ValidationError("Every actual consumed quantity requires approved commercial allocation before finalization.")
        expected = {line.pk:(quantity, money, extra) for line, quantity, money, extra, _ in prepared}
        for line in active:
            source = line.quotation_line
            if source.pk not in expected:
                raise ValidationError("Invoice line lacks reconstructable provenance.")
            quantity, money, extra = expected[source.pk]
            if line.quantity != quantity or line.additional_discount != extra or any(getattr(line, k) != v for k,v in money.items()) or any(
                    getattr(line,k) != getattr(source,k) for k in ("kind", "responsibility", "description", "part_code", "part_name", "tax_label")):
                raise ValidationError("Invoice snapshot no longer reconciles to its approved source.")
        for field, child in (("subtotal", "subtotal"), ("discount_total", "discount"), ("tax_total", "tax"), ("grand_total", "total"),
                ("customer_pay_total", "customer_pay"), ("warranty_covered_total", "warranty_covered"), ("company_covered_total", "company_covered")):
            if getattr(row, field) != sum((getattr(l, child) for l in active), Decimal(0)):
                raise ValidationError("Invoice aggregate does not reconcile.")
        if row.customer_pay_total > quote.customer_pay_total:
            raise ValidationError("Invoice customer liability exceeds approved liability.")
        row.context_snapshot = dict(quotation=quote.context_snapshot, quotation_number=quote.family.number,
            quotation_revision=quote.revision, service_case=str(case.pk), job_number=case.job_number,
            customer_name=case.customer.display_name, customer_number=case.customer.customer_number,
            device=str(case.device), service_center=str(case.service_center))
        row.status, row.finalized_by, row.finalized_at = "FINALIZED", actor, timezone.now()
        row._persist()
        return row
