from datetime import time, timedelta
from unittest.mock import patch

from django.contrib.auth import get_user_model
from django.contrib.auth.models import Permission
from django.core.exceptions import PermissionDenied, ValidationError
from django.db import IntegrityError, transaction
from django.test import TestCase, TransactionTestCase, Client
from django.urls import reverse
from django.utils import timezone

from apps.access.models import Role
from apps.access.services import create_role_assignment
from apps.organization.assignment_services import create_assignment
from apps.organization import test_assignment_concurrency as concurrency
from apps.service.tests import setup
from apps.service.models import ServiceCase
from apps.devices import services as devices
from . import services as s, queries
from .models import Appointment, AppointmentSlot, QueueEntry, FrontdeskEvent


def prepare(test):
    setup(test)
    test.admin = get_user_model().objects.create_user(username="desk-admin", is_superuser=True)
    test.second = get_user_model().objects.create_user(username="second-admin", is_superuser=True)
    test.today = timezone.localdate(timezone=timezone.get_default_timezone())
    test.slot = s.create_slot(actor=test.admin, service_center=test.center, date=test.today,
                             start_time=time(0), end_time=time(23, 59, 59), capacity=2)


class Operations:
    def book(self, **kwargs):
        return s.book_appointment(**(dict(actor=self.admin, slot=self.slot, customer=self.customer) | kwargs))

    def walk(self, **kwargs):
        return s.add_walk_in(**(dict(actor=self.admin, service_center=self.center,
                                    customer=self.customer, device=self.device) | kwargs))

    def serving(self, row=None):
        row = row or self.walk()
        s.transition_queue(actor=self.admin, queue_entry=row, target="CALLED")
        return s.transition_queue(actor=self.admin, queue_entry=row, target="SERVING")


class FrontdeskTests(Operations, TestCase):
    @classmethod
    def setUpTestData(cls):
        prepare(cls)

    def test_appointment_lifecycle_and_history(self):
        appointment = self.book()
        row = s.check_in(actor=self.admin, appointment=appointment)
        self.serving(row)
        s.transition_queue(actor=self.admin, queue_entry=row, target="COMPLETED")
        appointment.refresh_from_db()
        self.assertEqual(appointment.status, "COMPLETED")
        self.assertIsNotNone(appointment.checked_in_at)
        self.assertIsNotNone(appointment.ended_at)
        self.assertEqual(list(appointment.events.values_list("action", flat=True)), ["SCHEDULED", "CHECKED_IN", "COMPLETED"])

    def test_capacity_blocks_third_booking(self):
        self.book()
        self.book()
        with self.assertRaises(ValidationError):
            self.book()
        self.assertEqual(Appointment.objects.count(), 2)

    def test_cancellation_retains_history_and_releases_capacity(self):
        a, _ = self.book(), self.book()
        s.end_appointment(actor=self.admin, appointment=a, reason="Customer cancelled")
        self.book()
        self.assertEqual(Appointment.objects.count(), 3)
        self.assertEqual(a.events.last().actor, self.admin)

    def test_capacity_cannot_shrink_below_booked(self):
        self.book()
        self.book()
        with self.assertRaises(ValidationError):
            s.configure_slot(actor=self.admin, slot=self.slot, capacity=1, is_active=True)

    def test_inactive_slot_blocks_booking_but_preserves_checkin(self):
        a = self.book()
        s.configure_slot(actor=self.admin, slot=self.slot, capacity=2, is_active=False)
        with self.assertRaises(ValidationError):
            self.book()
        self.assertEqual(s.check_in(actor=self.admin, appointment=a).appointment_id, a.pk)

    def test_overlap_rejected_even_for_inactive_slot(self):
        s.configure_slot(actor=self.admin, slot=self.slot, capacity=2, is_active=False)
        with self.assertRaises(ValidationError):
            s.create_slot(actor=self.admin, service_center=self.center, date=self.today,
                          start_time=time(9), end_time=time(10), capacity=3)

    def test_invalid_capacity_and_times(self):
        for capacity, start, end in [(0, time(9), time(10)), (2, time(10), time(9))]:
            with self.subTest(capacity=capacity), self.assertRaises(ValidationError):
                s.create_slot(actor=self.admin, service_center=self.center2, date=self.today,
                              start_time=start, end_time=end, capacity=capacity)

    def test_explicit_no_show_only_after_end(self):
        a = self.book()
        with self.assertRaises(ValidationError):
            s.end_appointment(actor=self.admin, appointment=a, reason="Missing", no_show=True)
        with patch.object(s.timezone, "now", return_value=timezone.now() + timedelta(days=1)):
            row = s.end_appointment(actor=self.admin, appointment=a, reason="Not attended", no_show=True)
        self.assertEqual(row.status, "NO_SHOW")
        self.assertFalse(QueueEntry.objects.exists())

    def test_cancelled_appointment_cannot_check_in(self):
        a = self.book()
        s.end_appointment(actor=self.admin, appointment=a, reason="Cancelled")
        with self.assertRaises(ValidationError):
            s.check_in(actor=self.admin, appointment=a)

    def test_future_appointment_cannot_check_in_early(self):
        slot = s.create_slot(actor=self.admin, service_center=self.center, date=self.today + timedelta(days=1),
                             start_time=time(9), end_time=time(10), capacity=1)
        with self.assertRaises(ValidationError):
            s.check_in(actor=self.admin, appointment=self.book(slot=slot))

    def test_duplicate_checkin_returns_same_record_and_actor(self):
        a = self.book()
        first = s.check_in(actor=self.admin, appointment=a)
        second = s.check_in(actor=self.second, appointment=a)
        self.assertEqual(first.pk, second.pk)
        self.assertEqual(second.created_by, self.admin)
        self.assertEqual(first.arrived_at, second.arrived_at)
        self.assertEqual(QueueEntry.objects.count(), 1)

    def test_checkin_rolls_back_if_customer_inactive(self):
        from apps.customers.services import deactivate_customer
        a = self.book()
        deactivate_customer(customer=self.customer)
        with self.assertRaises(ValidationError):
            s.check_in(actor=self.admin, appointment=a)
        a.refresh_from_db()
        self.assertEqual(a.status, "SCHEDULED")
        self.assertFalse(QueueEntry.objects.exists())

    def test_walk_in_without_device_or_appointment(self):
        row = self.walk(device=None)
        self.assertIsNone(row.appointment_id)
        self.assertIsNone(row.device_id)
        self.assertEqual(row.status, "WAITING")

    def test_tokens_are_center_date_scoped(self):
        rows = [self.walk(), self.walk(), self.walk(service_center=self.center2)]
        self.assertEqual([r.token for r in rows], [1, 2, 1])
        with patch.object(s.timezone, "now", return_value=timezone.now() + timedelta(days=1)):
            self.assertEqual(self.walk().token, 1)

    def test_call_next_uses_waiting_token_order(self):
        first, second = self.walk(), self.walk()
        self.assertEqual(s.call_next(actor=self.admin, service_center=self.center).pk, first.pk)
        self.assertEqual(s.call_next(actor=self.admin, service_center=self.center).pk, second.pk)
        self.assertIsNone(s.call_next(actor=self.admin, service_center=self.center))

    def test_invalid_queue_transitions_and_terminal_protection(self):
        row = self.walk()
        for target in ["SERVING", "COMPLETED", "INVALID"]:
            with self.subTest(target=target), self.assertRaises(ValidationError):
                s.transition_queue(actor=self.admin, queue_entry=row, target=target)
        self.serving(row)
        s.transition_queue(actor=self.admin, queue_entry=row, target="COMPLETED")
        with self.assertRaises(ValidationError):
            s.transition_queue(actor=self.admin, queue_entry=row, target="CALLED")

    def test_queue_cancellation_closes_appointment_with_reason(self):
        a = self.book()
        row = s.check_in(actor=self.admin, appointment=a)
        s.transition_queue(actor=self.admin, queue_entry=row, target="CANCELLED", reason="Left before intake")
        a.refresh_from_db()
        self.assertEqual(a.status, "CANCELLED")
        self.assertEqual(a.events.last().reason, "Left before intake")

    def test_intake_is_idempotent_and_completion_does_not_complete_case(self):
        row = self.serving()
        first = s.start_intake(actor=self.admin, queue_entry=row)
        second = s.start_intake(actor=self.second, queue_entry=row)
        s.transition_queue(actor=self.admin, queue_entry=row, target="COMPLETED")
        third = s.start_intake(actor=self.admin, queue_entry=row)
        self.assertEqual([first.pk, first.pk], [second.pk, third.pk])
        first.refresh_from_db()
        self.assertEqual(first.status, "RECEIVED")
        self.assertEqual(ServiceCase.objects.count(), 1)
        self.assertEqual(first.warranty_snapshot.recorded_coverage, False)

    def test_intake_requires_serving_and_known_device(self):
        row = self.walk(device=None)
        with self.assertRaises(ValidationError):
            s.start_intake(actor=self.admin, queue_entry=row, device=self.device)
        self.serving(row)
        with self.assertRaises(ValidationError):
            s.start_intake(actor=self.admin, queue_entry=row)
        self.assertEqual(s.start_intake(actor=self.admin, queue_entry=row, device=self.device).device, self.device)

    def test_intake_preserves_frozen_device_validation(self):
        row = self.serving()
        devices.deactivate_device(device=self.device)
        with self.assertRaises(ValidationError):
            s.start_intake(actor=self.admin, queue_entry=row)
        self.assertFalse(ServiceCase.objects.exists())

    def test_cross_company_customer_and_device_rejected(self):
        with self.assertRaises(ValidationError):
            self.book(customer=self.outsider)
        devices.assign_device_owner(device=self.device, customer=self.outsider)
        with self.assertRaises(ValidationError):
            self.walk()

    def test_inactive_hierarchy_denies_even_superuser(self):
        from apps.organization.services import deactivate_company
        deactivate_company(company=self.company)
        with self.assertRaises(ValidationError):
            self.walk()

    def test_staff_and_direct_permissions_do_not_grant_business_access(self):
        self.user.is_staff = True
        self.user.save()
        self.user.user_permissions.add(Permission.objects.get(content_type__app_label="frontdesk", codename="manage_queue"))
        with self.assertRaises(PermissionDenied):
            self.walk(actor=self.user)

    def grant(self, codes, center=None):
        center = center or self.center
        assignment = create_assignment(user=self.user, company=center.company, region=center.region, service_center=center)
        role = Role.objects.create(code="DESK", name="Desk")
        role.permissions.set(Permission.objects.filter(codename__in=codes))
        create_role_assignment(user=self.user, role=role, organization_assignment=assignment)

    def test_center_scope_allows_own_and_denies_other_centers(self):
        self.grant(["manage_queue"])
        self.walk(actor=self.user)
        for center in (self.center2, self.other_center):
            with self.subTest(center=center.pk), self.assertRaises(PermissionDenied):
                self.walk(actor=self.user, service_center=center)

    def test_intake_requires_existing_intake_permission(self):
        row = self.serving()
        self.grant(["manage_queue"])
        with self.assertRaises(PermissionDenied):
            s.start_intake(actor=self.user, queue_entry=row)

    def test_no_direct_writes_deletes_or_event_rewrites(self):
        row = self.walk()
        for op in (row.save, row.delete, lambda: QueueEntry.objects.all().delete(),
                   lambda: QueueEntry.objects.filter(pk=row.pk).update(status="COMPLETED"), row.events.first()._persist):
            with self.assertRaises(ValidationError):
                op()

    def test_database_rejects_invalid_state_and_duplicate_tokens(self):
        row = self.walk()
        from django.db import connection
        for sql, values in [
            ('UPDATE frontdesk_queueentry SET status = %s WHERE id = %s', ["INVALID", row.pk]),
            ('UPDATE frontdesk_queueentry SET token = %s WHERE id = %s', [row.token, self.walk().pk]),
        ]:
            with self.assertRaises(IntegrityError), transaction.atomic(), connection.cursor() as cursor:
                cursor.execute(sql, values)

    def test_search_is_scoped_and_uses_authoritative_identifier(self):
        devices.assign_device_owner(device=self.device, customer=self.customer)
        rows, _, _ = queries.search(self.admin, self.center, self.customer.customer_number)
        self.assertEqual(list(rows), [self.customer])
        rows, _, _ = queries.search(self.admin, self.center, self.outsider.customer_number)
        self.assertNotIn(self.outsider, list(rows))
        self.assertEqual(list(queries.devices(self.user, self.center)), [])
        self.assertEqual(list(queries.devices(self.admin, self.center)), [self.device])

    def test_dashboard_and_forms_render(self):
        self.client.force_login(self.admin)
        self.book()
        self.walk()
        url = reverse("frontdesk:desk", args=[self.center.pk])
        self.assertContains(self.client.get(url), "Today's front desk")
        self.assertEqual(self.client.get(url, {"date": "not-a-date"}).status_code, 400)
        self.assertEqual(self.client.get(url, {"date": (self.today + timedelta(days=1)).isoformat()}).status_code, 200)
        for action in ("appointment", "walk-in", "slot", "customer", "call-next"):
            self.assertEqual(self.client.get(reverse("frontdesk:operation", args=[self.center.pk, action])).status_code, 200)

    def test_ui_checkin_post_and_idor(self):
        self.client.force_login(self.admin)
        a = self.book()
        bad = reverse("frontdesk:record-operation", args=[self.other_center.pk, "check-in", a.pk])
        self.assertEqual(self.client.post(bad).status_code, 404)
        url = reverse("frontdesk:record-operation", args=[self.center.pk, "check-in", a.pk])
        self.client.get(url)
        self.assertFalse(QueueEntry.objects.exists())
        self.assertEqual(self.client.post(url).status_code, 302)
        self.assertEqual(self.client.post(url).status_code, 302)
        self.assertEqual(QueueEntry.objects.count(), 1)

    def test_anonymous_denied_and_csrf_required(self):
        url = reverse("frontdesk:operation", args=[self.center.pk, "call-next"])
        self.assertEqual(self.client.get(url).status_code, 302)
        client = Client(enforce_csrf_checks=True)
        client.force_login(self.admin)
        self.assertEqual(client.post(url).status_code, 403)

    def test_ui_scope_cannot_be_bypassed_by_posted_foreign_customer(self):
        self.client.force_login(self.admin)
        url = reverse("frontdesk:operation", args=[self.center.pk, "walk-in"])
        self.assertEqual(self.client.post(url, {"customer": self.outsider.pk}).status_code, 400)
        self.assertFalse(QueueEntry.objects.exists())

    def test_exact_device_identifier_and_job_search(self):
        from apps.devices.tests import synthetic_imei
        device = devices.register_device(product_model=self.model, imei1=synthetic_imei(98), serial="DESK-SYNTHETIC")
        devices.assign_device_owner(device=device, customer=self.customer)
        for term in (synthetic_imei(98), "desk-synthetic"):
            _, found, _ = queries.search(self.admin, self.center, term)
            self.assertEqual(list(found), [device])
            _, hidden, _ = queries.search(self.admin, self.other_center, term)
            self.assertEqual(list(hidden), [])
        case = s.start_intake(actor=self.admin, queue_entry=self.serving())
        _, _, found = queries.search(self.admin, self.center, case.job_number)
        self.assertEqual(list(found), [case])
        _, _, hidden = queries.search(self.admin, self.center2, case.job_number)
        self.assertEqual(list(hidden), [])

    def test_checkin_failure_rolls_back_both_sides(self):
        a = self.book()
        with patch.object(s, "_event", side_effect=ValidationError("Synthetic failure")):
            with self.assertRaises(ValidationError):
                s.check_in(actor=self.admin, appointment=a)
        a.refresh_from_db()
        self.assertEqual(a.status, "SCHEDULED")
        self.assertFalse(QueueEntry.objects.exists())

    def test_queue_with_intake_cannot_be_cancelled_or_rebound(self):
        row = self.serving()
        s.start_intake(actor=self.admin, queue_entry=row)
        with self.assertRaises(ValidationError):
            s.transition_queue(actor=self.admin, queue_entry=row, target="CANCELLED", reason="Invalid")
        with self.assertRaises(ValidationError):
            s.start_intake(actor=self.admin, queue_entry=row, device=self.device2)

    def test_revoked_role_denies_service_action(self):
        from apps.access.services import deactivate_role_assignment
        from apps.access.models import UserRoleAssignment
        self.grant(["manage_queue"])
        deactivate_role_assignment(assignment=UserRoleAssignment.objects.get(user=self.user))
        with self.assertRaises(PermissionDenied):
            self.walk(actor=self.user)

    def test_ui_create_appointment_then_intake(self):
        self.client.force_login(self.admin)
        devices.assign_device_owner(device=self.device, customer=self.customer)
        url = reverse("frontdesk:operation", args=[self.center.pk, "appointment"])
        self.assertEqual(self.client.post(url, {"customer": self.customer.pk, "device": self.device.pk,
                                              "slot": self.slot.pk, "channel": "PHONE"}).status_code, 302)
        row = s.check_in(actor=self.admin, appointment=Appointment.objects.get())
        self.serving(row)
        url = reverse("frontdesk:record-operation", args=[self.center.pk, "intake", row.pk])
        self.assertEqual(self.client.post(url).status_code, 302)
        self.assertEqual(ServiceCase.objects.count(), 1)

    def test_ui_hides_other_center_even_with_view_permission(self):
        self.grant(["view_queue", "view_appointment"])
        self.client.force_login(self.user)
        self.assertEqual(self.client.get(reverse("frontdesk:desk", args=[self.center.pk])).status_code, 200)
        self.assertEqual(self.client.get(reverse("frontdesk:desk", args=[self.center2.pk])).status_code, 404)

    def test_company_contact_search_uses_normalized_mobile(self):
        from apps.customers.services import create_customer_contact
        create_customer_contact(customer=self.customer, contact_type="MOBILE", value="+8801700000098")
        found, _, _ = queries.search(self.admin, self.center, "+880 1700-000098")
        self.assertEqual(list(found), [self.customer])


class FrontdeskConcurrencyTests(Operations, TransactionTestCase):
    run_concurrent = concurrency.AssignmentConcurrencyTests.run_concurrent

    def setUp(self):
        prepare(self)

    def test_last_capacity_two_actors(self):
        s.configure_slot(actor=self.admin, slot=self.slot, capacity=1, is_active=True)
        self.run_concurrent(lambda: self.book(), lambda: self.book(actor=self.second), expected="validation")
        self.assertEqual(Appointment.objects.count(), 1)

    def test_simultaneous_checkin_is_single_entry(self):
        a = self.book()
        self.run_concurrent(lambda: s.check_in(actor=self.admin, appointment=a),
                            lambda: s.check_in(actor=self.second, appointment=a), expected="success")
        self.assertEqual(QueueEntry.objects.count(), 1)
        self.assertEqual(a.events.filter(action="CHECKED_IN").count(), 1)

    def test_simultaneous_walkin_token_allocation(self):
        self.run_concurrent(lambda: self.walk(), lambda: self.walk(actor=self.second), expected="success")
        self.assertEqual(list(QueueEntry.objects.values_list("token", flat=True)), [1, 2])

    def test_simultaneous_intake_creation_is_single_case(self):
        row = self.serving()
        self.run_concurrent(lambda: s.start_intake(actor=self.admin, queue_entry=row),
                            lambda: s.start_intake(actor=self.second, queue_entry=row), expected="success")
        self.assertEqual(ServiceCase.objects.count(), 1)
        self.assertEqual(row.events.filter(action="INTAKE_CREATED").count(), 1)

    def test_capacity_reduction_then_booking(self):
        self.book()
        self.run_concurrent(lambda: s.configure_slot(actor=self.admin, slot=self.slot, capacity=1, is_active=True),
                            lambda: self.book(actor=self.second), expected="validation")

    def test_cancel_then_checkin_cannot_resurrect(self):
        a = self.book()
        self.run_concurrent(lambda: s.end_appointment(actor=self.admin, appointment=a, reason="Cancelled"),
                            lambda: s.check_in(actor=self.second, appointment=a), expected="validation")

    def test_simultaneous_call_next_selects_different_visits(self):
        self.walk()
        self.walk()
        self.run_concurrent(lambda: s.call_next(actor=self.admin, service_center=self.center),
                            lambda: s.call_next(actor=self.second, service_center=self.center), expected="success")
        self.assertEqual(QueueEntry.objects.filter(status="CALLED").count(), 2)

    def test_booking_then_capacity_reduction_cannot_overbook(self):
        self.book()
        self.run_concurrent(lambda: self.book(),
            lambda: s.configure_slot(actor=self.second, slot=self.slot, capacity=1, is_active=True), expected="validation")

    def test_checkin_then_appointment_cancellation_rejects(self):
        a = self.book()
        self.run_concurrent(lambda: s.check_in(actor=self.admin, appointment=a),
            lambda: s.end_appointment(actor=self.second, appointment=a, reason="Cancelled"), expected="validation")
