import uuid
from unittest.mock import patch
from django.contrib.auth.models import Permission
from django.core.exceptions import ValidationError
from django.db import IntegrityError, transaction
from django.test import TestCase
from apps.access import services as access
from apps.parts import services as parts
from apps.service import test_repair as repair
from apps.service import services as cases
from . import usage_services as s, services as stock, queries as q
from .models import PartsIssue, PartsDisposition, StockMovement
from .test_requests import RequestFixture, setup_requests


def setup_usage(test):
    setup_requests(test)
    access.set_role_permissions(role=test.role, permissions=[test.permission, *Permission.objects.filter(content_type__app_label="inventory")])


class UsageFixture(RequestFixture):
    def issue(self, reservation, **kwargs):
        return s.issue_reserved_parts(**dict(actor=self.actor, reservation=reservation, expected_revision=reservation.updated_at.isoformat()) | kwargs)

    def prepared_issue(self, quantity=2, units=()):
        self.receive(quantity=quantity, units=units)
        request = self.approve(self.request(lines=[dict(spare_part=self.part, quantity=quantity)]))
        return self.issue(self.reserve(request, quantity=quantity, units=units))

    def consume(self, issue, action, **kwargs):
        return s.consume_issued_parts(**dict(actor=self.engineer, issue=issue, repair_action=action, quantity=1, command_key=uuid.uuid4()) | kwargs)

    def return_unused(self, issue, **kwargs):
        return s.return_unused_parts(**dict(actor=self.actor, issue=issue, destination=self.location, quantity=1, command_key=uuid.uuid4()) | kwargs)


def deliver_case(test, execution=None):
    from apps.service import quality_control_services as qc, handover_services as handover, handover_queries
    from apps.service.quality_control_checklist import CHECKLIST
    if execution is None:
        execution, _ = repair.prepared(test)
    repair.complete(test, execution)
    access.set_role_permissions(role=test.role, permissions=[*test.role.permissions.all(), Permission.objects.get(content_type__app_label="service", codename="perform_quality_control")])
    test.case.refresh_from_db()
    qc.submit_for_quality_control(service_case=test.case, inspector=test.engineer2)
    test.case.refresh_from_db()
    row = qc.begin_quality_control(service_case=test.case, inspector=test.engineer2)
    for code, _, _ in CHECKLIST:
        row.refresh_from_db()
        qc.set_quality_control_check(quality_control=row, inspector=test.engineer2, check_code=code, result="PASS")
    row.refresh_from_db()
    qc.complete_quality_control_pass(quality_control=row, inspector=test.engineer2)
    test.case.refresh_from_db()
    identity = handover_queries.device_identity_fingerprint(test.device)
    handover.mark_service_case_ready_for_delivery(service_case=test.case, actor=test.actor, expected_device=test.device, expected_device_identity=identity)
    test.case.refresh_from_db()
    handover.handover_service_case(service_case=test.case, actor=test.actor, expected_device=test.device,
        expected_device_identity=identity, recipient_type="CUSTOMER", verification_method="CUSTOMER_RECORD", accessories=[])
    test.case.refresh_from_db()


class UsageTests(UsageFixture, TestCase):
    @classmethod
    def setUpTestData(cls):
        setup_usage(cls)

    def test_issue_moves_stock_to_custody_not_consumption(self):
        issue = self.prepared_issue()
        self.assertEqual(issue.recipient, self.engineer)
        self.assertEqual(issue.reservation.status, "ISSUED")
        self.assertEqual(q.stock_on_hand(actor=self.actor, location=self.location, spare_part=self.part), 0)
        self.assertEqual(q.stock_on_hand(actor=self.actor, location=issue.custody_location, spare_part=self.part), 2)
        self.assertEqual(q.available_stock(actor=self.actor, location=issue.custody_location, spare_part=self.part), 0)
        self.assertEqual(issue.reservation.line.request.status, "FULFILLED")

    def test_duplicate_issue_rejected(self):
        issue = self.prepared_issue()
        with self.assertRaises(ValidationError):
            self.issue(issue.reservation)
        self.assertEqual(PartsIssue.objects.count(), 1)

    def test_consumption_posts_outward_ledger_with_repair_evidence(self):
        issue = self.prepared_issue()
        execution, action = repair.prepared(self)
        row = self.consume(issue, action)
        self.assertEqual(row.kind, "CONSUMED")
        self.assertEqual(row.repair_action.repair_execution, execution)
        self.assertEqual(row.movement.kind, "CONSUME")
        self.assertIsNone(row.movement.destination)
        self.assertEqual(row.movement.entries.get().quantity_delta, -1)
        self.assertEqual(q.stock_on_hand(actor=self.actor, location=issue.custody_location, spare_part=self.part), 1)

    def test_unperformed_action_cannot_consume(self):
        issue = self.prepared_issue()
        _, action = repair.prepared(self, performed=False)
        with self.assertRaises(ValidationError):
            self.consume(issue, action)

    def test_completed_repair_cannot_be_enriched_retroactively(self):
        issue = self.prepared_issue()
        execution, action = repair.prepared(self)
        repair.complete(self, execution)
        with self.assertRaises(ValidationError):
            self.consume(issue, action)
        self.return_unused(issue, quantity=2)

    def test_consumption_is_assigned_engineer_only(self):
        issue = self.prepared_issue()
        _, action = repair.prepared(self)
        with self.assertRaises(ValidationError):
            self.consume(issue, action, actor=self.actor)

    def test_consumption_rechecks_compatibility(self):
        issue = self.prepared_issue()
        _, action = repair.prepared(self)
        parts.set_spare_part_compatibility(spare_part=self.part, product_models=[], product_variants=[])
        with self.assertRaises(ValidationError):
            self.consume(issue, action)
        self.return_unused(issue, quantity=2)

    def test_unused_return_preserves_quantity(self):
        issue = self.prepared_issue()
        row = self.return_unused(issue, quantity=2)
        self.assertEqual(row.kind, "RETURNED")
        self.assertEqual(q.stock_on_hand(actor=self.actor, location=self.location, spare_part=self.part), 2)
        s.require_inventory_resolved(self.case)
        with self.assertRaises(ValidationError):
            self.return_unused(issue)

    def test_consumed_quantity_cannot_return(self):
        issue = self.prepared_issue(quantity=1)
        _, action = repair.prepared(self)
        self.consume(issue, action)
        with self.assertRaises(ValidationError):
            self.return_unused(issue)

    def test_duplicate_command_key_cannot_consume_twice(self):
        issue = self.prepared_issue()
        _, action = repair.prepared(self)
        command = uuid.uuid4()
        self.consume(issue, action, command_key=command)
        with self.assertRaises((ValidationError, IntegrityError)), transaction.atomic():
            self.consume(issue, action, command_key=command)
        self.assertEqual(issue.dispositions.count(), 1)

    def test_return_after_cancellation_and_part_deactivation(self):
        issue = self.prepared_issue()
        cases.cancel_service_case(service_case=self.case, cancelled_by=self.user, reason="Cancelled")
        parts.deactivate_spare_part(spare_part=self.part)
        self.return_unused(issue, quantity=2)
        s.require_inventory_resolved(self.case)

    def test_custody_cannot_be_moved_through_generic_api(self):
        issue = self.prepared_issue()
        with self.assertRaises(ValidationError):
            self.move(source=issue.custody_location)
        with self.assertRaises(ValidationError):
            self.receive(destination=issue.custody_location)
        with self.assertRaises(ValidationError):
            stock.create_location(actor=self.actor, company=self.company, code="FAKE", name="Fake custody", location_type="CUSTODY")

    def test_issue_and_disposition_history_immutable(self):
        issue = self.prepared_issue()
        disposition = self.return_unused(issue)
        for model, pk in ((PartsIssue, issue.pk), (PartsDisposition, disposition.pk)):
            with self.assertRaises(IntegrityError), transaction.atomic():
                model.objects.filter(pk=pk).update(note="rewrite")

    def test_serial_identity_in_custody_and_after_consumption(self):
        parts.update_spare_part(spare_part=self.part, serialization_policy="REQUIRED_SERIAL")
        unit = stock.register_serialized_unit(actor=self.actor, company=self.company, spare_part=self.part, identifier="JOB-USED")
        issue = self.prepared_issue(quantity=1, units=[unit])
        unit.refresh_from_db()
        self.assertEqual(unit.state, "IN_CUSTODY")
        _, action = repair.prepared(self)
        self.consume(issue, action, units=[unit])
        unit.refresh_from_db()
        self.assertEqual(unit.state, "CONSUMED")
        self.assertIsNone(unit.current_location)
        self.assertEqual(unit.identifier, "JOB-USED")
        with self.assertRaises(ValidationError):
            self.return_unused(issue, units=[unit])
        with self.assertRaises(ValidationError):
            parts.update_spare_part(spare_part=self.part, serialization_policy="NOT_SERIALIZED")

    def test_serial_unused_return_retains_identity(self):
        parts.update_spare_part(spare_part=self.part, serialization_policy="REQUIRED_SERIAL")
        unit = stock.register_serialized_unit(actor=self.actor, company=self.company, spare_part=self.part, identifier="JOB-UNUSED")
        issue = self.prepared_issue(quantity=1, units=[unit])
        self.return_unused(issue, units=[unit])
        unit.refresh_from_db()
        self.assertEqual((unit.state, unit.current_location_id), ("IN_STOCK", self.location.pk))

    def test_failed_issue_rolls_back_reservation_and_custody(self):
        self.receive()
        reservation = self.reserve(self.approve(self.request()))
        with patch.object(PartsIssue, "_persist", side_effect=ValidationError("abort")), self.assertRaises(ValidationError):
            self.issue(reservation)
        reservation.refresh_from_db()
        self.assertEqual(reservation.status, "ACTIVE")
        self.assertEqual(StockMovement.objects.count(), 1)

    def test_failed_disposition_rolls_back_ledger(self):
        issue = self.prepared_issue()
        with patch.object(PartsDisposition, "_persist", side_effect=ValidationError("abort")), self.assertRaises(ValidationError):
            self.return_unused(issue)
        self.assertEqual(q.stock_on_hand(actor=self.actor, location=issue.custody_location, spare_part=self.part), 2)

    def test_closure_guard_blocks_reservations_and_unresolved_issues(self):
        self.receive()
        reservation = self.reserve(self.approve(self.request()))
        with self.assertRaises(ValidationError):
            s.require_inventory_resolved(self.case)
        issue = self.issue(reservation)
        with self.assertRaises(ValidationError):
            s.require_inventory_resolved(self.case)
        self.return_unused(issue)
        s.require_inventory_resolved(self.case)

    def test_actual_closure_blocks_custody_then_allows_resolved_inventory(self):
        from apps.service.handover_services import close_service_case
        issue = self.prepared_issue(quantity=1)
        deliver_case(self)
        with self.assertRaises(ValidationError):
            close_service_case(service_case=self.case, actor=self.actor)
        self.return_unused(issue)
        close_service_case(service_case=self.case, actor=self.actor)
        self.case.refresh_from_db()
        self.assertEqual(self.case.status, "CLOSED")
        with self.assertRaises(ValidationError):
            self.return_unused(issue)

    def test_defective_recovery_is_separate_from_usable_stock(self):
        from .models import DefectiveRecovery
        from .usage_queries import defective_recoveries
        issue = self.prepared_issue()
        _, action = repair.prepared(self)
        replacement = self.consume(issue, action)
        quarantine = stock.create_location(actor=self.actor, company=self.company, code="QUARANTINE", name="Quarantine", location_type="QUARANTINE", service_center=self.center)
        row = s.recover_defective_component(actor=self.engineer, repair_action=action, location=quarantine,
            component_description="Removed defective display", command_key=uuid.uuid4(), replacement=replacement)
        self.assertEqual(row.device, self.device)
        self.assertEqual(row.observed_identifier, "")
        self.assertEqual(q.stock_on_hand(actor=self.actor, location=quarantine, spare_part=self.part), 0)
        self.assertEqual(defective_recoveries(actor=self.actor).get(), row)
        with self.assertRaises(ValidationError):
            stock.deactivate_location(actor=self.actor, location=quarantine)
        with self.assertRaises(IntegrityError), transaction.atomic():
            DefectiveRecovery.objects.filter(pk=row.pk).update(quantity=9)

    def test_recovery_cannot_enter_usable_stock(self):
        _, action = repair.prepared(self)
        with self.assertRaises(ValidationError):
            s.recover_defective_component(actor=self.engineer, repair_action=action, location=self.location,
                component_description="Removed display", command_key=uuid.uuid4())

    def test_partial_disposition_revision_is_stale_after_first_return(self):
        issue = self.prepared_issue()
        revision = s.usage_revision(issue)
        self.return_unused(issue, expected_revision=revision)
        with self.assertRaises(ValidationError):
            self.return_unused(issue, expected_revision=revision)

    def test_nonserialized_policy_stays_locked_after_total_stock_reaches_zero(self):
        issue = self.prepared_issue(quantity=1)
        _, action = repair.prepared(self)
        self.consume(issue, action)
        self.assertEqual(q.stock_on_hand(actor=self.actor, location=self.location, spare_part=self.part), 0)
        self.assertEqual(q.stock_on_hand(actor=self.actor, location=issue.custody_location, spare_part=self.part), 0)
        with self.assertRaises(ValidationError):
            parts.update_spare_part(spare_part=self.part, serialization_policy="OPTIONAL_SERIAL")

    def test_usage_queries_are_scoped_and_bounded(self):
        from .usage_queries import parts_issues, parts_usage
        issue = self.prepared_issue()
        self.return_unused(issue)
        self.assertFalse(parts_issues(actor=self.user).exists())
        self.assertFalse(parts_usage(actor=self.user).exists())
        with self.assertNumQueries(1):
            rows = list(parts_issues(actor=self.actor, unresolved_only=True))
            self.assertEqual(rows[0].resolved_quantity, 1)
            str(rows[0].reservation.spare_part)
            str(rows[0].recipient)
        with self.assertNumQueries(1):
            rows = list(parts_usage(actor=self.actor))
            str(rows[0].issue.service_case)
            str(rows[0].movement.destination)
