"""Scope-authorized job demand and stock reservation commands."""
from contextlib import contextmanager

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.devices.locking import locked_catalog, require_catalog_active
from apps.devices.models import Device
from apps.parts.locking import persisted_pk
from apps.parts import queries as compatibility
from apps.parts.models import SparePart
from apps.service.engineer_services import _actors, _eligibility_dependencies
from apps.service.engineer_queries import eligible_engineers_for_service_case
from apps.service.locking import share
from apps.service.models import ServiceCase, ServiceEngineerAssignment
from .locking import inventory_context, lock_positions
from .models import PartsRequest, PartsRequestLine, PartsRequestEvent, StockReservation, ReservationUnit, SerializedStockUnit
from .queries import USABLE_TYPES, _on_hand
from .services import check_revision, quantity_value, text_value


@contextmanager
def job_context(*, actor, service_case, permission, parts=(), locations=(), operational=True, taxonomy_ids=()):
    """Actor/company/RBAC -> catalog/device -> parts -> locations -> case.

    Closure and all existing service lifecycle commands serialize on the same
    case row. No inventory writer locks a case after a stock position.
    """
    case_id = persisted_pk(service_case, ServiceCase)
    with transaction.atomic():
        actor = _actors(actor)
        _eligibility_dependencies(case_id, actor)
        snapshot = ServiceCase.objects.select_related("company", "service_center", "device").get(pk=case_id)
        if operational and not (snapshot.service_center.is_active and snapshot.service_center.region.is_active
                and snapshot.service_center.company_id == snapshot.company_id == snapshot.service_center.region.company_id):
            raise ValidationError("An active consistent case service-center hierarchy is required.")
        device = snapshot.device
        with locked_catalog(device.product_model_id, device.product_variant_id) as (model, variant):
            fresh_device = share(Device, device.pk)
            if taxonomy_ids:
                from apps.access.locks import share_rows
                from apps.service_catalog.models import RepairAction
                share_rows(RepairAction, taxonomy_ids)
            if operational:
                require_catalog_active(model, variant)
                if not fresh_device.is_active:
                    raise ValidationError("An active device is required.")
            with inventory_context(actor=actor, company=snapshot.company, permission=permission,
                    parts=parts, locations=locations, require_active_parts=operational,
                    authorization_target=snapshot.service_center) as (actor, company, locations, current_parts):
                require_permission(user=actor, permission=permission, target=snapshot.service_center)
                case = ServiceCase.objects.select_for_update().get(pk=case_id)
                if operational and case.status not in ("DIAGNOSED", "REPAIRING"):
                    raise ValidationError("Job parts require a diagnosed or actively repairing case.")
                for part in current_parts.values() if operational else ():
                    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("The spare part is not compatible with this device.")
                yield actor, case, current_parts, locations


def _event(row, actor, status, reason=""):
    PartsRequestEvent(request=row, actor=actor, status=status, reason=reason)._persist()


def create_parts_request(*, actor, service_case, lines, note="", expected_case_revision=None):
    if not isinstance(lines, (list, tuple)) or not lines:
        raise ValidationError("Supply at least one requested part.")
    inputs = []
    for line in lines:
        if not isinstance(line, dict) or set(line) != {"spare_part", "quantity"}:
            raise ValidationError("Each line requires a spare part and quantity.")
        inputs.append((persisted_pk(line["spare_part"], SparePart), quantity_value(line["quantity"])))
    if len({pk for pk, _ in inputs}) != len(inputs):
        raise ValidationError("Request each part only once.")
    with job_context(actor=actor, service_case=service_case, permission="inventory.request_parts",
            parts=[line["spare_part"] for line in lines]) as (actor, case, parts, _):
        check_revision(case, expected_case_revision)
        assignment = ServiceEngineerAssignment.objects.filter(service_case=case, ended_at=None).first()
        if assignment is None or assignment.engineer_id != actor.pk or not eligible_engineers_for_service_case(case).filter(pk=actor.pk).exists():
            raise ValidationError("Only the currently eligible assigned engineer may request job parts.")
        row = PartsRequest(company_id=case.company_id, service_case=case, requester=actor, note=text_value(note))
        row._persist()
        for pk, quantity in inputs:
            PartsRequestLine(request=row, spare_part=parts[pk], quantity=quantity)._persist()
        _event(row, actor, "REQUESTED")
        return row


def _snapshot(request):
    return PartsRequest.objects.select_related("service_case").get(pk=persisted_pk(request, PartsRequest))


def approve_parts_request(*, actor, request, expected_revision):
    snapshot = _snapshot(request)
    parts = list(SparePart.objects.filter(partsrequestline__request=snapshot))
    with job_context(actor=actor, service_case=snapshot.service_case, permission="inventory.approve_parts", parts=parts) as (actor, _, _, _):
        row = PartsRequest.objects.select_for_update().get(pk=snapshot.pk)
        check_revision(row, expected_revision)
        if row.status != "REQUESTED":
            raise ValidationError("Only a requested document may be approved.")
        row.status, row.decided_by, row.decided_at = "APPROVED", actor, timezone.now()
        row._persist()
        _event(row, actor, row.status)
        return row


def _terminate(*, actor, request, reason, expected_revision, reject):
    snapshot = _snapshot(request)
    with job_context(actor=actor, service_case=snapshot.service_case, permission="inventory.approve_parts" if reject else "inventory.request_parts", operational=False) as (actor, _, _, _):
        row = PartsRequest.objects.select_for_update().get(pk=snapshot.pk)
        check_revision(row, expected_revision)
        if row.status not in (("REQUESTED",) if reject else ("REQUESTED", "APPROVED")):
            raise ValidationError("This request cannot be terminated.")
        if not reject and actor.pk != row.requester_id:
            require_permission(user=actor, permission="inventory.approve_parts", target=row.service_case.service_center)
        from django.db.models import F
        from .models import PartsIssue
        unresolved = PartsIssue.objects.filter(reservation__line__request=row).annotate(
            resolved=Sum("dispositions__quantity", default=0)).filter(resolved__lt=F("reservation__quantity"))
        if StockReservation.objects.filter(line__request=row, status="ACTIVE").exists() or unresolved.exists():
            raise ValidationError("Release reservations and resolve issued stock before cancellation.")
        row.status = "REJECTED" if reject else "CANCELLED"
        row.decided_by, row.decided_at = actor, timezone.now()
        row.reason = text_value(reason, required=True, maximum=500)
        row._persist()
        _event(row, actor, row.status, row.reason)
        return row


def cancel_parts_request(*, actor, request, reason, expected_revision):
    return _terminate(actor=actor, request=request, reason=reason, expected_revision=expected_revision, reject=False)


def reject_parts_request(*, actor, request, reason, expected_revision):
    return _terminate(actor=actor, request=request, reason=reason, expected_revision=expected_revision, reject=True)


def reserved_quantity(location, part):
    return StockReservation.objects.filter(location=location, spare_part=part, status="ACTIVE").aggregate(value=Sum("quantity"))["value"] or 0


def require_unreserved(*, location, part, quantity, units):
    """Called with stock position locked, before any physical outward posting."""
    ids = [unit.pk for unit in units]
    reservations = StockReservation.objects.filter(location=location, spare_part=part, status="ACTIVE")
    reserved = reservations.aggregate(value=Sum("quantity"))["value"] or 0
    unit_links = ReservationUnit.objects.filter(reservation__in=reservations, is_active=True)
    if unit_links.filter(unit_id__in=ids).exists():
        raise ValidationError("A selected serialized unit is reserved.")
    serialized = SerializedStockUnit.objects.filter(current_location=location, spare_part=part).count()
    if (_on_hand(location, part) - reserved < quantity
            or _on_hand(location, part) - serialized - (reserved - unit_links.count()) < quantity - len(ids)):
        raise ValidationError("Insufficient unreserved stock in the selected bucket.")


def reserve_parts(*, actor, line, location, quantity, units=(), expected_request_revision):
    line = PartsRequestLine.objects.select_related("request__service_case", "spare_part").get(pk=persisted_pk(line, PartsRequestLine))
    quantity = quantity_value(quantity)
    with job_context(actor=actor, service_case=line.request.service_case, permission="inventory.reserve_parts", parts=[line.spare_part], locations=[location]) as (actor, case, parts, locations):
        request = PartsRequest.objects.select_for_update().get(pk=line.request_id)
        check_revision(request, expected_request_revision)
        if request.status != "APPROVED":
            raise ValidationError("Only approved requests can reserve stock.")
        location, part = locations[location.pk], parts[line.spare_part_id]
        if not location.is_active or location.location_type not in USABLE_TYPES:
            raise ValidationError("Reservations require an active usable location.")
        committed = line.reservations.filter(status__in=["ACTIVE", "ISSUED"]).aggregate(value=Sum("quantity"))["value"] or 0
        if committed + quantity > line.quantity:
            raise ValidationError("Reservation exceeds requested quantity.")
        lock_positions([location], [part])
        from .control_services import require_position_open
        require_position_open(location, part)
        unit_ids = [persisted_pk(unit, SerializedStockUnit) for unit in units]
        if len(unit_ids) != len(set(unit_ids)) or len(unit_ids) > quantity:
            raise ValidationError("Supply distinct units within the requested quantity.")
        if (part.serialization_policy == "REQUIRED_SERIAL" and len(unit_ids) != quantity) or (part.serialization_policy == "NOT_SERIALIZED" and unit_ids):
            raise ValidationError("Unit selection does not match serialization policy.")
        units = list(SerializedStockUnit.objects.filter(pk__in=unit_ids).select_for_update().order_by("pk"))
        if len(units) != len(unit_ids) or any(unit.current_location_id != location.pk or unit.company_id != case.company_id or unit.spare_part_id != part.pk or unit.state != "IN_STOCK" for unit in units):
            raise ValidationError("Selected units must be in the source stock position.")
        require_unreserved(location=location, part=part, quantity=quantity, units=units)
        row = StockReservation(company_id=case.company_id, service_case=case, line=line, spare_part=part, location=location, quantity=quantity, reserved_by=actor)
        row._persist()
        for unit in units:
            ReservationUnit(reservation=row, unit=unit)._persist()
        request._persist()
        return row


def release_reservation(*, actor, reservation, reason, expected_revision):
    snapshot = StockReservation.objects.select_related("service_case", "location", "spare_part", "line").get(pk=persisted_pk(reservation, StockReservation))
    with job_context(actor=actor, service_case=snapshot.service_case, permission="inventory.reserve_parts", locations=[snapshot.location], operational=False) as (actor, _, _, _):
        request = PartsRequest.objects.select_for_update().get(pk=snapshot.line.request_id)
        lock_positions([snapshot.location], [snapshot.spare_part])
        row = StockReservation.objects.select_for_update().get(pk=snapshot.pk)
        check_revision(row, expected_revision)
        if row.status != "ACTIVE":
            raise ValidationError("Only active reservations can be released.")
        row.status, row.ended_by, row.ended_at = "RELEASED", actor, timezone.now()
        row.reason = text_value(reason, required=True, maximum=500)
        row._persist()
        for link in row.units.select_for_update().order_by("pk"):
            link.is_active = False
            link._persist()
        request._persist()
        return row
