"""Synthetic invoice reconciliation, liability, history and query contracts."""
from decimal import Decimal
from unittest.mock import patch
from django.contrib.auth.models import Permission
from django.core.exceptions import ValidationError
from django.db import IntegrityError, transaction, connection
from django.test import TestCase
from apps.access import services as access
from apps.inventory.test_usage import setup_usage
from apps.inventory.models import StockMovement, PartsDisposition
from apps.service import test_repair as repair
from apps.service import quality_control_services as qc
from apps.service.quality_control_checklist import CHECKLIST
from .tests import QuotationFixture
from . import invoice_services as s, invoice_queries as q
from .models import ServiceInvoice, InvoiceLine, InvoiceAllocation, InvoiceSequence, QuotationLine, QuotationDecision


class InvoiceFixture(QuotationFixture):
    def quoted(self, *, mixed=False, quantity=2, responsibility="CUSTOMER", price="100.00", **terms):
        self.execution, self.action = repair.prepared(self, performed=False)
        inputs = [self.line(kind="PART", spare_part=self.part, quantity=quantity, responsibility=responsibility,
            coverage_reason="Synthetic coverage", unit_price=price, **terms)]
        if mixed:
            inputs.append(self.line(kind="PART", spare_part=self.part, quantity=quantity, responsibility="WARRANTY", coverage_reason="Synthetic warranty"))
        self.quote = self.decide(self.submit(self.lines(self.create(), inputs)))
        repair.perform(self, self.action)
        return self.quote

    def ready(self):
        self.execution.refresh_from_db()
        repair.complete(self, self.execution)
        access.set_role_permissions(role=self.role, permissions=[*self.role.permissions.all(), Permission.objects.get(content_type__app_label="service", codename="perform_quality_control")])
        self.case.refresh_from_db()
        qc.submit_for_quality_control(service_case=self.case, inspector=self.engineer2)
        self.case.refresh_from_db()
        row = qc.begin_quality_control(service_case=self.case, inspector=self.engineer2)
        for code, _, _ in CHECKLIST:
            row.refresh_from_db()
            qc.set_quality_control_check(quality_control=row, inspector=self.engineer2, check_code=code, result="PASS")
        row.refresh_from_db()
        qc.complete_quality_control_pass(quality_control=row, inspector=self.engineer2)
        self.case.refresh_from_db()

    def billed(self, *, mixed=False, consumed=1, quoted=2, responsibility="CUSTOMER", **terms):
        self.quoted(mixed=mixed, quantity=quoted, responsibility=responsibility, **terms)
        self.issue_row = self.prepared_issue(quantity=quoted)
        self.disposition = self.consume(self.issue_row, self.action, quantity=consumed)
        return self.prepare()

    def prepare(self, **kwargs):
        return s.prepare_service_invoice(**(dict(actor=self.actor, service_case=self.case)|kwargs))

    def reconcile(self, row, allocations=(), **kwargs):
        return s.reconcile_service_invoice(**(dict(actor=self.actor, invoice=row, expected_revision=s.revision(row), allocations=allocations)|kwargs))

    def allocation(self, *, line=None, source=None, quantity=1, reason="Reviewed approved payer against service evidence"):
        return dict(consumption=source or self.disposition, quotation_line=line or self.quote.lines.filter(is_active=True).first(), quantity=quantity, reason=reason)

    def finalize(self, row, **kwargs):
        return s.finalize_service_invoice(**(dict(actor=self.actor, invoice=row, expected_revision=s.revision(row))|kwargs))


class InvoiceTests(InvoiceFixture, TestCase):
    @classmethod
    def setUpTestData(cls):
        setup_usage(cls)

    def test_number_and_single_case_draft(self):
        self.quoted()
        row = self.prepare()
        self.assertEqual(row.number, "INV-00000001")
        with self.assertRaises(ValidationError): self.prepare()
        self.assertEqual(InvoiceSequence.objects.get().next_value, 2)

    def test_expected_parts_are_not_actual_billing(self):
        self.quoted()
        self.prepared_issue()
        row = self.prepare()
        self.assertEqual(row.grand_total, 0)
        self.assertFalse(row.lines.exists())

    def test_partial_actual_quantity_and_automatic_provenance(self):
        row = self.billed(consumed=2, quoted=3)
        self.assertEqual(row.grand_total, 200)
        line = row.lines.get(is_active=True)
        self.assertEqual(line.quantity, 2)
        self.assertEqual(line.allocations.get().consumption, self.disposition)
        self.assertEqual(line.allocations.get().mode, "AUTO")

    def test_unused_return_does_not_reverse_consumption(self):
        row = self.billed()
        self.return_unused(self.issue_row)
        self.ready()
        row = self.finalize(row)
        self.assertEqual(row.grand_total, 100)
        self.assertEqual(PartsDisposition.objects.filter(kind="RETURNED").count(), 1)

    def test_mixed_payers_remain_unresolved(self):
        row = self.billed(mixed=True)
        self.assertFalse(row.lines.exists())
        self.ready()
        with self.assertRaises(ValidationError): self.finalize(row)
        row.refresh_from_db()
        self.assertEqual(row.status, "DRAFT")

    def test_explicit_warranty_allocation_zero_customer_due(self):
        row = self.billed(mixed=True)
        source = self.quote.lines.get(responsibility="WARRANTY")
        row = self.reconcile(row, [self.allocation(line=source)])
        self.ready()
        row = self.finalize(row)
        self.assertEqual((row.grand_total, row.customer_pay_total, row.warranty_covered_total), (100, 0, 100))
        evidence = row.lines.get(is_active=True).allocations.get()
        self.assertEqual(evidence.mode, "MANUAL")
        self.assertEqual(evidence.actor, self.actor)
        self.assertTrue(evidence.reason)

    def test_explicit_customer_allocation(self):
        row = self.billed(mixed=True)
        source = self.quote.lines.get(responsibility="CUSTOMER")
        row = self.reconcile(row, [self.allocation(line=source)])
        self.assertEqual(row.customer_pay_total, 100)

    def test_company_value_is_not_zeroed(self):
        row = self.billed(responsibility="COMPANY")
        self.ready()
        row = self.finalize(row)
        self.assertEqual((row.grand_total, row.company_covered_total, row.customer_pay_total), (100, 100, 0))

    def test_manual_reason_required(self):
        row = self.billed(mixed=True)
        with self.assertRaises(ValidationError): self.reconcile(row, [self.allocation(reason="")])

    def test_cannot_claim_automatic_mode(self):
        row = self.billed(mixed=True)
        with self.assertRaises(ValidationError): self.reconcile(row, [self.allocation()|dict(mode="AUTO")])

    def test_source_quantity_cannot_be_billed_twice(self):
        row = self.billed(mixed=True)
        lines = list(self.quote.lines.all())
        with self.assertRaises(ValidationError): self.reconcile(row, [self.allocation(line=lines[0]), self.allocation(line=lines[1])])

    def test_actual_quantity_overrun_rejected(self):
        row = self.billed()
        with self.assertRaises(ValidationError): self.reconcile(row, [self.allocation(quantity=2)])

    def test_approved_quantity_overrun_rejected(self):
        self.quoted(quantity=1, responsibility="WARRANTY")
        issue = self.prepared_issue(quantity=2)
        self.disposition = self.consume(issue, self.action, quantity=2)
        row = self.prepare()
        self.assertFalse(row.lines.exists())
        with self.assertRaises(ValidationError): self.reconcile(row, [self.allocation(quantity=2)])

    def test_multiple_sources_aggregate_with_full_provenance(self):
        row = self.billed()
        second = self.consume(self.issue_row, self.action)
        row = self.prepare(invoice=row, expected_revision=s.revision(row))
        line = row.lines.get(is_active=True)
        self.assertEqual(line.quantity, 2)
        self.assertEqual(set(line.allocations.values_list("consumption_id", flat=True)), {self.disposition.pk, second.pk})

    def test_discount_tax_partial_and_explicit_reduction(self):
        row = self.billed(quoted=3, consumed=2, discount="10.00", tax_rate="10", tax_label="Synthetic tax")
        line = row.lines.get(is_active=True)
        self.assertEqual((line.discount, line.tax, line.total), (Decimal("6.67"), Decimal("19.33"), Decimal("212.66")))
        source = self.quote.lines.get(is_active=True)
        row = self.reconcile(row, [self.allocation(quantity=2)], adjustments=[dict(quotation_line=source, additional_discount="10", reason="Authorized goodwill")])
        self.assertEqual(row.grand_total, Decimal("201.66"))
        self.ready()
        self.assertEqual(self.finalize(row).grand_total, Decimal("201.66"))

    def test_price_payer_total_overrides_rejected(self):
        row = self.billed()
        for field in ("unit_price", "responsibility", "total"):
            with self.subTest(field=field), self.assertRaises(ValidationError):
                self.reconcile(row, [self.allocation()|{field:"999"}])

    def test_quoted_labor_is_not_automatically_billed(self):
        self.execution, self.action = repair.prepared(self, performed=False)
        self.quote = self.decide(self.submit(self.lines(self.create())))
        repair.perform(self, self.action)
        row = self.prepare()
        self.assertFalse(row.lines.exists())
        source = self.quote.lines.get(is_active=True)
        row = self.reconcile(row, confirmations=[dict(quotation_line=source, quantity=1, reason="Confirmed completed repair labor")])
        self.ready()
        self.assertEqual(self.finalize(row).grand_total, 100)

    def test_unperformed_linked_labor_rejected(self):
        self.execution, self.action = repair.prepared(self, performed=False)
        self.quote = self.decide(self.submit(self.lines(self.create(), [self.line(repair_action=self.action)])))
        row = self.prepare()
        with self.assertRaises(ValidationError):
            self.reconcile(row, confirmations=[dict(quotation_line=self.quote.lines.get(), quantity=1, reason="Must not assume performance")])

    def test_finalization_requires_successful_qc(self):
        row = self.billed()
        with self.assertRaises(ValidationError): self.finalize(row)
        self.execution.refresh_from_db()
        repair.complete(self, self.execution)
        with self.assertRaises(ValidationError): self.finalize(row)

    def test_quotation_free_is_not_free_invoice(self):
        with self.assertRaises(ValidationError): self.prepare()

    def test_replacement_quotation_requires_rebuild(self):
        row = self.billed()
        draft = self.revise(self.quote)
        with self.assertRaises(ValidationError): self.reconcile(row, [self.allocation()])
        new = self.decide(self.submit(draft))
        with self.assertRaises(ValidationError): self.reconcile(row, [self.allocation()])
        rebuilt = self.prepare(invoice=row, expected_revision=s.revision(row))
        self.assertEqual(rebuilt.quotation, new)

    def test_regeneration_retires_but_retains_evidence(self):
        row = self.billed()
        old = row.lines.get()
        row = self.prepare(invoice=row, expected_revision=s.revision(row))
        old.refresh_from_db()
        self.assertFalse(old.is_active)
        self.assertTrue(old.allocations.exists())
        self.assertEqual(row.lines.count(), 2)

    def test_stale_reconcile_and_rebuild_fail(self):
        row = self.billed()
        token = s.revision(row)
        row = self.reconcile(row, [self.allocation()])
        with self.assertRaises(ValidationError): self.reconcile(row, [self.allocation()], expected_revision=token)
        with self.assertRaises(ValidationError): self.prepare(invoice=row, expected_revision=token)

    def test_creation_rollback_includes_number(self):
        self.quoted()
        with self.assertRaises(RuntimeError), transaction.atomic():
            self.prepare()
            raise RuntimeError("Synthetic rollback")
        self.assertFalse(ServiceInvoice.objects.exists())
        self.assertEqual(self.prepare().number, "INV-00000001")

    def test_finalization_rollback(self):
        row = self.billed()
        self.ready()
        with self.assertRaises(RuntimeError), transaction.atomic():
            self.finalize(row)
            raise RuntimeError("Synthetic rollback")
        row.refresh_from_db()
        self.assertEqual(row.status, "DRAFT")
        self.assertIsNone(row.finalized_at)

    def test_final_history_immutable_via_services_models_and_queryset(self):
        row = self.billed()
        self.ready()
        row = self.finalize(row)
        for operation in (lambda:self.finalize(row), lambda:self.reconcile(row), lambda:self.prepare(invoice=row, expected_revision=s.revision(row)), row.save, row.delete):
            with self.assertRaises(ValidationError): operation()
        for model, filters, values in ((ServiceInvoice, dict(pk=row.pk), dict(note="rewrite")),
                (InvoiceLine, dict(invoice=row), dict(quantity=2)), (InvoiceAllocation, dict(line__invoice=row), dict(reason="rewrite"))):
            with self.assertRaises(IntegrityError), transaction.atomic(): model.objects.filter(**filters).update(**values)

    def test_finalization_has_no_inventory_or_quotation_side_effects(self):
        row = self.billed()
        self.ready()
        before = (list(StockMovement.objects.values()), list(PartsDisposition.objects.values()), list(QuotationLine.objects.values()), list(QuotationDecision.objects.values()))
        self.finalize(row)
        after = (list(StockMovement.objects.values()), list(PartsDisposition.objects.values()), list(QuotationLine.objects.values()), list(QuotationDecision.objects.values()))
        self.assertEqual(before, after)

    def test_query_budgets_and_allocations(self):
        row = self.billed()
        with self.assertNumQueries(1):
            rows = list(q.service_invoices(actor=self.actor))
            for invoice in rows:
                str(invoice.service_case.customer); str(invoice.service_case.device); str(invoice.service_center); str(invoice.created_by); str(invoice.quotation.family)
        with self.assertNumQueries(1): list(q.invoice_lines(actor=self.actor, invoice=row))
        with self.assertNumQueries(1): list(q.invoice_allocations(actor=self.actor, invoice=row))
        with self.assertNumQueries(1): list(q.invoice_history_for_case(actor=self.actor, service_case=self.case))
        with self.assertNumQueries(1): list(q.service_cases_ready_for_invoicing(actor=self.actor))
        with self.assertNumQueries(1): list(q.service_cases_with_draft_invoices(actor=self.actor))
        with self.assertNumQueries(4):
            detail = q.invoice_detail(actor=self.actor, invoice=row)
            for line in detail.lines.all():
                for allocation in line.allocations.all(): list(allocation.consumption.movement.unit_links.all())
        self.assertEqual(q.service_invoice_summary(actor=self.actor, invoice=row)["customer_pay_total"], 100)

    def test_database_invalid_status_rejected(self):
        row = self.billed()
        with self.assertRaises(IntegrityError), transaction.atomic():
            ServiceInvoice.objects.filter(pk=row.pk).update(status="PAID")

    def test_database_totals_deferred_reconciliation(self):
        row = self.billed()
        with self.assertRaises(IntegrityError), transaction.atomic():
            ServiceInvoice.objects.filter(pk=row.pk).update(subtotal=200, grand_total=200, customer_pay_total=200)
            with connection.cursor() as cursor: cursor.execute("SET CONSTRAINTS ALL IMMEDIATE")

    def test_serialized_consumption_traceability(self):
        from apps.parts import services as parts
        from apps.inventory import services as stock
        parts.update_spare_part(spare_part=self.part, serialization_policy="REQUIRED_SERIAL")
        self.quoted(quantity=1)
        unit = stock.register_serialized_unit(actor=self.actor, company=self.company, spare_part=self.part, identifier="SYNTHETIC-INVOICE-UNIT")
        issue = self.prepared_issue(quantity=1, units=[unit])
        self.consume(issue, self.action, units=[unit])
        row = self.prepare()
        self.ready()
        row = self.finalize(row)
        allocation = row.lines.get(is_active=True).allocations.get()
        self.assertEqual(allocation.consumption.movement.unit_links.get().unit_id, unit.pk)
        unit.refresh_from_db()
        self.assertEqual((unit.identifier, unit.state), ("SYNTHETIC-INVOICE-UNIT", "CONSUMED"))
        with self.assertNumQueries(5):
            detail = q.invoice_detail(actor=self.actor, invoice=row)
            for line in detail.lines.all():
                for allocation in line.allocations.all():
                    for link in allocation.consumption.movement.unit_links.all():
                        self.assertEqual(link.unit.identifier, "SYNTHETIC-INVOICE-UNIT")

    def test_partial_split_across_distinct_approved_payers(self):
        row = self.billed(mixed=True, consumed=2, quoted=2)
        lines = list(self.quote.lines.all())
        row = self.reconcile(row, [self.allocation(line=lines[0]), self.allocation(line=lines[1])])
        self.ready()
        row = self.finalize(row)
        self.assertEqual((row.customer_pay_total, row.warranty_covered_total, row.grand_total), (100, 100, 200))

    def test_service_confirmation_reason_and_scope_caps(self):
        self.execution, self.action = repair.prepared(self, performed=False)
        self.quote = self.decide(self.submit(self.lines(self.create(), [self.line(kind="SERVICE")])) )
        repair.perform(self, self.action)
        row = self.prepare()
        source = self.quote.lines.get()
        for quantity, reason in ((2, "Too many"), (1, ""), (Decimal("0.5"), "Fraction")):
            with self.assertRaises(ValidationError):
                self.reconcile(row, confirmations=[dict(quotation_line=source, quantity=quantity, reason=reason)])

    def test_invoice_operations_do_not_post_inventory(self):
        row = self.billed()
        before = list(StockMovement.objects.values())
        row = self.reconcile(row, [self.allocation()])
        self.prepare(invoice=row, expected_revision=s.revision(row))
        self.assertEqual(before, list(StockMovement.objects.values()))

    def test_new_consumption_invalidates_complete_reconciliation(self):
        row = self.billed()
        self.consume(self.issue_row, self.action)
        self.ready()
        with self.assertRaises(ValidationError): self.finalize(row)
        row.refresh_from_db()
        self.assertEqual(row.status, "DRAFT")

    def test_frozen_terms_survive_master_label_change(self):
        from apps.parts.services import update_spare_part
        row = self.billed()
        original = row.lines.get().part_name
        update_spare_part(spare_part=self.part, name="Renamed synthetic part")
        self.ready()
        row = self.finalize(row)
        self.assertEqual(row.lines.get().part_name, original)
