"""Commercial commands: dependencies -> case -> family/revision -> children.

No inventory rows are locked or written. Case locks are shared coordination with
the frozen repair/consumption APIs, not a new technical lifecycle.
"""
from contextlib import contextmanager
from decimal import Decimal
from django.core.exceptions import ValidationError
from django.db import transaction
from django.db.models import Sum
from django.utils import timezone
from apps.access.authorization import require_permission
from apps.access.locks import share_rows
from apps.devices.locking import persisted_pk, locked_catalog, require_catalog_active
from apps.devices.models import Device
from apps.parts.models import SparePart, PartCategory
from apps.parts import queries as compatibility
from apps.service.engineer_services import _actors, _eligibility_dependencies
from apps.service.locking import share
from apps.service.models import ServiceCase, ServiceRepairAction, ServiceDiagnosticFinding
from .models import QuotationSequence, QuotationFamily, ServiceQuotation, QuotationLine, QuotationDecision
from .money import calculate, decimal_value

MANAGE = "commercial.manage_servicequotation"
DECIDE = "commercial.record_quotationdecision"


def text(value, maximum, *, required=False):
    if not isinstance(value, str) or len(value.strip()) > maximum or (required and not value.strip()):
        raise ValidationError("Supply valid bounded text. Never record credentials, PINs or tokens.")
    return value.strip()


def revision(row):
    return row.updated_at.isoformat()


def _expected(row, expected):
    if not isinstance(expected, str) or expected != revision(row):
        raise ValidationError("Quotation changed; reload and review it.")


@contextmanager
def _context(*, actor, case, part_ids=(), permission=MANAGE):
    case_id = persisted_pk(case, ServiceCase)
    with transaction.atomic():
        actor = _actors(actor)
        _eligibility_dependencies(case_id, actor)
        snapshot = ServiceCase.objects.select_related("device", "service_center__region", "company").get(pk=case_id)
        center = snapshot.service_center
        if not (snapshot.company.is_active and center.is_active and center.region.is_active
                and center.company_id == center.region.company_id == snapshot.company_id):
            raise ValidationError("An active consistent organizational hierarchy is required.")
        require_permission(user=actor, permission=permission, target=center)
        with locked_catalog(snapshot.device.product_model_id, snapshot.device.product_variant_id) as (model, variant):
            require_catalog_active(model, variant)
            device = share(Device, snapshot.device_id)
            if not device.is_active:
                raise ValidationError("An active device is required.")
            categories = dict(SparePart.objects.filter(pk__in=part_ids).values_list("pk", "category_id"))
            if len(categories) != len(set(part_ids)):
                raise ValidationError("A referenced part does not exist.")
            share_rows(PartCategory, categories.values())
            share_rows(SparePart, part_ids)
            parts = {p.pk: p for p in SparePart.objects.filter(pk__in=part_ids).select_related("category")}
            if categories != {pk:p.category_id for pk,p in parts.items()}:
                raise ValidationError("Part category changed; retry.")
            # Dependencies are already protected above. Locking joined rows here
            # upgrades Device SHARE and can deadlock overlapping quotation writes.
            row = ServiceCase.objects.select_for_update(of=("self",)).select_related("customer", "device", "service_center").get(pk=case_id)
            if row.status not in ("DIAGNOSED", "REPAIRING"):
                raise ValidationError("Quotations require diagnosed or actively planned repair work.")
            yield actor, row, parts, model, variant


def _load(quotation):
    return ServiceQuotation.objects.select_related("family__service_case").get(pk=persisted_pk(quotation, ServiceQuotation))


def _lock(quotation, expected, *, status=None):
    QuotationFamily.objects.select_for_update().get(pk=quotation.family_id)
    row = ServiceQuotation.objects.select_for_update().select_related("family").get(pk=quotation.pk)
    _expected(row, expected)
    if not row.is_current or (status is not None and row.status != status):
        raise ValidationError("This revision is no longer actionable for that operation.")
    return row


def _metadata(row, currency, customer_note, internal_note, valid_until):
    # No exchange engine: a three-letter currency label with an explicit 2-place
    # contract. Deployments must only select currencies appropriate to that scale.
    currency = text(currency, 3, required=True).upper()
    if len(currency) != 3 or not currency.isascii() or not currency.isalpha():
        raise ValidationError("Use a three-letter currency code.")
    if valid_until is not None and (not hasattr(valid_until, "tzinfo") or timezone.is_naive(valid_until) or valid_until <= timezone.now()):
        raise ValidationError("Validity must be an aware future timestamp.")
    row.currency, row.valid_until = currency, valid_until
    row.customer_note, row.internal_note = text(customer_note, 4000), text(internal_note, 4000)


def create_service_quotation(*, actor, service_case, currency, customer_note="", internal_note="", valid_until=None):
    with _context(actor=actor, case=service_case) as (actor, case, _, _, _):
        if QuotationFamily.objects.filter(service_case=case).exists():
            raise ValidationError("Use explicit revision creation for an existing quotation family.")
        counter, _ = QuotationSequence.objects.select_for_update().get_or_create(service_center=case.service_center)
        if counter.next_value > 999999999999999999:
            raise ValidationError("Quotation numbering capacity exhausted.")
        number = f"EST-{counter.next_value:08d}"
        counter.next_value += 1
        counter.save(update_fields=["next_value"])
        family = QuotationFamily(service_case=case, service_center=case.service_center, number=number)
        family._persist()
        row = ServiceQuotation(family=family, created_by=actor)
        _metadata(row, currency, customer_note, internal_note, valid_until)
        row._persist()
        return row


def update_service_quotation(*, actor, quotation, expected_revision, currency, customer_note="", internal_note="", valid_until=None):
    snapshot = _load(quotation)
    with _context(actor=actor, case=snapshot.family.service_case):
        row = _lock(snapshot, expected_revision, status="DRAFT")
        previous_currency = row.currency
        _metadata(row, currency, customer_note, internal_note, valid_until)
        if row.revision > 1 and row.currency != previous_currency:
            raise ValidationError("A quotation family cannot change currency.")
        row._persist()
        return row


def _part_valid(part, model, variant):
    valid = (compatibility.spare_part_is_compatible_with_variant(spare_part=part, product_variant=variant)
        if variant else compatibility.spare_part_is_compatible_with_model(spare_part=part, product_model=model))
    if not valid:
        raise ValidationError("Quotation part must be active and compatible with the exact device model/variant.")


def _references(line, case):
    if line.repair_action_id and not ServiceRepairAction.objects.filter(pk=line.repair_action_id,
            repair_execution__service_case=case, is_active=True).exists():
        raise ValidationError("Repair reference must belong to this case and remain active.")
    if line.finding_id and not ServiceDiagnosticFinding.objects.filter(pk=line.finding_id,
            assessment__service_case=case).exists():
        raise ValidationError("Diagnostic reference must belong to this case.")


def _totals(row):
    lines = list(row.lines.filter(is_active=True))
    for target, source in (("subtotal", "subtotal"), ("discount_total", "discount"), ("tax_total", "tax"), ("grand_total", "total")):
        setattr(row, target, decimal_value(sum((getattr(line,source) for line in lines), Decimal(0))))
    row.customer_pay_total = sum((line.total for line in lines if line.responsibility == "CUSTOMER"), Decimal(0))
    row.covered_total = row.grand_total - row.customer_pay_total


def set_quotation_lines(*, actor, quotation, expected_revision, lines):
    if not isinstance(lines, (list, tuple)) or len(lines) > 100:
        raise ValidationError("Supply at most 100 explicit line inputs.")
    allowed = {"kind", "responsibility", "description", "coverage_reason", "spare_part", "repair_action", "finding",
               "quantity", "unit_price", "discount", "tax_rate", "tax_label"}
    if any(not isinstance(line, dict) or set(line)-allowed for line in lines):
        raise ValidationError("Unknown line input, including calculated totals.")
    part_ids = [persisted_pk(line["spare_part"], SparePart) for line in lines if line.get("spare_part") is not None]
    snapshot = _load(quotation)
    with _context(actor=actor, case=snapshot.family.service_case, part_ids=part_ids) as (_, case, parts, model, variant):
        row = _lock(snapshot, expected_revision, status="DRAFT")
        prepared = []
        for position, data in enumerate(lines, 1):
            values = calculate(quantity=data.get("quantity"), unit_price=data.get("unit_price"),
                               discount=data.get("discount", "0"), tax_rate=data.get("tax_rate", "0"))
            line = QuotationLine(quotation=row, position=position, kind=data.get("kind"), responsibility=data.get("responsibility"),
                description=text(data.get("description", ""), 500, required=True),
                coverage_reason=text(data.get("coverage_reason", ""), 1000), tax_label=text(data.get("tax_label", ""),100),
                quantity=data["quantity"], **values)
            if line.responsibility in ("WARRANTY", "COMPANY") and not line.coverage_reason:
                raise ValidationError("Covered charges require explicit coverage rationale.")
            if line.tax_rate and not line.tax_label:
                raise ValidationError("Applied tax requires a snapshot label.")
            part = data.get("spare_part")
            if part is not None:
                part = parts[part.pk]
                _part_valid(part, model, variant)
                line.spare_part, line.part_code, line.part_name = part, part.part_code, part.name
            for field, model_type in (("repair_action", ServiceRepairAction), ("finding", ServiceDiagnosticFinding)):
                if data.get(field) is not None:
                    setattr(line, field+"_id", persisted_pk(data[field], model_type))
            _references(line, case)
            prepared.append(line)
        establishes = any(line.responsibility == "CUSTOMER" and line.total > 0 for line in prepared)
        family = row.family
        if establishes and not family.approval_obligation:
            # A concurrently performed unclassified action must not become
            # retrospectively classified by a stale draft's first priced edit.
            if ServiceRepairAction.objects.filter(repair_execution__service_case=case, performed_at__gte=family.created_at).exists():
                raise ValidationError("Unclassified work occurred after this draft began; retrospective pricing requires a future workflow.")
            family.approval_obligation = True
            family._persist()
        for old in row.lines.filter(is_active=True):
            old.is_active = False
            old._persist()
        for line in prepared:
            line._persist()
        _totals(row)
        row._persist()
        return row


def _scope(case):
    return list(ServiceRepairAction.objects.filter(repair_execution__service_case=case,
        repair_execution__status="OPEN", is_active=True).order_by("pk").values("id", "repair_action_id", "note", "repair_action__code", "repair_action__name"))


def submit_service_quotation(*, actor, quotation, expected_revision):
    snapshot = _load(quotation)
    line_facts = list(snapshot.lines.filter(is_active=True).values_list("pk", "updated_at", "spare_part_id"))
    with _context(actor=actor, case=snapshot.family.service_case, part_ids=[p for _,_,p in line_facts if p]) as (actor, case, parts, model, variant):
        row = _lock(snapshot, expected_revision, status="DRAFT")
        if line_facts != list(row.lines.filter(is_active=True).values_list("pk", "updated_at", "spare_part_id")):
            raise ValidationError("Line composition changed; reload.")
        if not line_facts:
            raise ValidationError("Submit at least one charge line.")
        if row.valid_until and row.valid_until <= timezone.now():
            raise ValidationError("Quotation expired.")
        for line in row.lines.filter(is_active=True):
            _references(line, case)
            if line.spare_part_id:
                part = parts[line.spare_part_id]
                _part_valid(part, model, variant)
                line.part_code, line.part_name = part.part_code, part.name
                line._persist()
        warranty = case.warranty_snapshot
        row.context_snapshot = {"case": str(case.pk), "job_number":case.job_number, "customer":str(case.customer_id),
            "customer_name": case.customer.display_name, "customer_number":case.customer.customer_number,
            "device":str(case.device_id), "device_label":str(case.device),
            "service_center":str(case.service_center), "model":str(model), "variant":str(variant) if variant else None,
            "warranty": {f: str(getattr(warranty,f)) if getattr(warranty,f) is not None else None for f in
                ("recorded_coverage", "coverage_source", "coverage_start_date", "coverage_end_date", "coverage_reference", "captured_at")}}
        row.scope_snapshot = [{k:str(v) for k,v in item.items()} for item in _scope(case)]
        _totals(row)
        row.status, row.submitted_by, row.submitted_at = "SUBMITTED", actor, timezone.now()
        row._persist()
        return row


def record_quotation_decision(*, actor, quotation, expected_revision, outcome, channel, recipient_name, reference, note=""):
    snapshot = _load(quotation)
    with _context(actor=actor, case=snapshot.family.service_case, permission=DECIDE) as (actor, _, _, _, _):
        row = _lock(snapshot, expected_revision, status="SUBMITTED")
        if row.valid_until and row.valid_until <= timezone.now():
            raise ValidationError("Expired quotations require a new revision.")
        if outcome not in ("APPROVED", "REJECTED"):
            raise ValidationError("Choose approval or rejection.")
        QuotationDecision(quotation=row, outcome=outcome, recorded_by=actor, channel=channel,
            recipient_name=text(recipient_name,200,required=True), reference=text(reference,500,required=True), note=text(note,2000))._persist()
        row.status = outcome
        row._persist()
        return row


def revise_service_quotation(*, actor, quotation, expected_revision):
    snapshot = _load(quotation)
    with _context(actor=actor, case=snapshot.family.service_case) as (actor, _, _, _, _):
        row = _lock(snapshot, expected_revision)
        if row.status not in ("SUBMITTED", "APPROVED", "REJECTED"):
            raise ValidationError("Revise a presented quotation; edit a draft directly.")
        row.is_current = False
        if row.status == "SUBMITTED":
            row.status = "SUPERSEDED"
        row._persist()
        new = ServiceQuotation(family=row.family, revision=row.revision+1, currency=row.currency, created_by=actor,
            customer_note=row.customer_note, internal_note=row.internal_note)
        new._persist()
        for old in row.lines.filter(is_active=True):
            values = {f.name:getattr(old, f.attname) for f in old._meta.concrete_fields
                if f.name not in ("id", "created_at", "updated_at", "quotation") and not f.is_relation}
            for field in ("spare_part", "repair_action", "finding"):
                values[field+"_id"] = getattr(old, field+"_id")
            QuotationLine(quotation=new, **values)._persist()
        _totals(new)
        new._persist()
        return new
