<?php

namespace App\Services;

use App\Enums\AppointmentStatus;
use App\Enums\PaymentStatus;
use App\Enums\QueueStatus;
use App\Enums\UserRole;
use App\Exceptions\DomainException;
use App\Models\Appointment;
use App\Models\AppointmentStatusHistory;
use App\Models\DoctorLocationAssignment;
use App\Models\NotificationLog;
use App\Models\Patient;
use App\Models\QueueEntry;
use App\Models\QueueStatusHistory;
use App\Models\User;
use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\DB;

class AppointmentService
{
    public function __construct(
        private readonly AccessService $access,
        private readonly ScheduleService $schedule,
        private readonly AuditService $audit,
        private readonly PaymentService $payments,
        private readonly ClinicSettings $settings,
        private readonly PatientMetricsService $patientMetrics,
    ) {}

    public function book(User $actor, array $data): Appointment
    {
        $patient = Patient::query()
            ->where('organization_id', $actor->organization_id)
            ->whereNull('archived_at')
            ->findOrFail($data['patient_id']);

        if ($actor->role === UserRole::PATIENT) {
            if ((int) $patient->user_id !== (int) $actor->id || ! $this->patientSelfBookingEnabled($actor->organization_id)) {
                throw new DomainException('FORBIDDEN', 'Patient self-booking is not enabled for this account.', 403);
            }
        } elseif (! $this->access->canUseDoctorLocation(
            $actor,
            (int) $data['doctor_id'],
            (int) $data['location_id'],
            'can_book_appointment'
        )) {
            throw new DomainException('FORBIDDEN', 'You cannot book for this doctor/location.', 403);
        }

        $startUtc = CarbonImmutable::parse($data['scheduled_start_at'])->utc();

        return DB::transaction(function () use ($actor, $patient, $data, $startUtc) {
            $assignment = DoctorLocationAssignment::query()
                ->where('organization_id', $actor->organization_id)
                ->where('doctor_id', $data['doctor_id'])
                ->where('location_id', $data['location_id'])
                ->where('is_active', true)
                ->whereHas('doctor', fn ($q) => $q->where('is_active', true))
                ->whereHas('location', fn ($q) => $q->where('is_active', true))
                ->lockForUpdate()
                ->first();

            if (! $assignment) {
                throw new DomainException('DOCTOR_NOT_ASSIGNED', 'Doctor is not active at the selected location.', 422);
            }

            $assignment->loadMissing(['doctor.organization', 'location.organization']);

            $allowBackfill = (bool) ($data['allow_backfill'] ?? false);
            if ($allowBackfill && ! $this->access->isAdmin($actor)) {
                throw new DomainException('FORBIDDEN', 'Only an administrator can create a past/backfill appointment.', 403);
            }

            $endUtc = $this->schedule->assertSlotAvailable($assignment, $startUtc, null, $allowBackfill);

            $baseFee = $data['type'] === 'follow_up'
                ? (float) $assignment->followup_fee
                : (float) $assignment->consultation_fee;

            $discount = $this->access->isAdmin($actor) ? (float) ($data['discount_amount'] ?? 0) : 0.0;
            if ($discount < 0 || $discount > $baseFee) {
                throw new DomainException('INVALID_DISCOUNT', 'Discount must be between zero and the base fee.', 422);
            }

            $appointment = Appointment::create([
                'organization_id' => $actor->organization_id,
                'patient_id' => $patient->id,
                'doctor_id' => $assignment->doctor_id,
                'location_id' => $assignment->location_id,
                'doctor_location_assignment_id' => $assignment->id,
                'created_by_user_id' => $actor->id,
                'created_by_assistant_id' => $actor->assistant?->id,
                'scheduled_start_at' => $startUtc,
                'scheduled_end_at' => $endUtc,
                'type' => $data['type'],
                'reason' => $data['reason'] ?? null,
                'base_fee' => $baseFee,
                'discount_amount' => $discount,
                'final_fee' => max(0, $baseFee - $discount),
                'payment_status' => PaymentStatus::PENDING,
                'status' => AppointmentStatus::CONFIRMED,
            ]);

            $appointment->update([
                'appointment_code' => 'APT-'.str_pad((string) (12000 + $appointment->id), 5, '0', STR_PAD_LEFT),
            ]);

            AppointmentStatusHistory::create([
                'appointment_id' => $appointment->id,
                'from_status' => null,
                'to_status' => AppointmentStatus::CONFIRMED->value,
                'reason' => 'Appointment created',
                'changed_by_user_id' => $actor->id,
                'created_at' => now(),
            ]);

            $this->audit->log('appointment.created', $actor, $appointment, null, [
                'patient_id' => $appointment->patient_id,
                'doctor_id' => $appointment->doctor_id,
                'location_id' => $appointment->location_id,
                'scheduled_start_at' => $appointment->scheduled_start_at,
                'scheduled_end_at' => $appointment->scheduled_end_at,
                'status' => $appointment->status->value,
            ], [
                'backfill' => (bool) ($data['allow_backfill'] ?? false),
                'backfill_reason' => $data['backfill_reason'] ?? null,
            ]);

            return $appointment->fresh([
                'patient', 'doctor.user', 'location', 'assignment',
            ]);
        }, 3);
    }

    public function update(User $actor, Appointment $appointment, array $data): Appointment
    {
        $this->access->assertOrganization($actor, $appointment);

        if (! $this->access->isAdmin($actor)
            && ! $this->access->canUseDoctorLocation(
                $actor,
                $appointment->doctor_id,
                $appointment->location_id,
                $actor->role === UserRole::ASSISTANT ? 'can_book_appointment' : null
            )) {
            throw new DomainException('FORBIDDEN', 'You cannot update this appointment.', 403);
        }

        return DB::transaction(function () use ($actor, $appointment, $data) {
            $appointment = Appointment::query()->lockForUpdate()->findOrFail($appointment->id);
            $before = $appointment->only(['type', 'reason', 'base_fee', 'discount_amount', 'final_fee', 'payment_status']);

            if (isset($data['type']) && $data['type'] !== $appointment->type) {
                if (! $this->access->isAdmin($actor)) {
                    throw new DomainException('FORBIDDEN', 'Only an administrator can change appointment type after booking.', 403);
                }

                $assignment = DoctorLocationAssignment::query()
                    ->whereKey($appointment->doctor_location_assignment_id)
                    ->lockForUpdate()
                    ->firstOrFail();

                $baseFee = $data['type'] === 'follow_up'
                    ? (float) $assignment->followup_fee
                    : (float) $assignment->consultation_fee;
                $discount = min((float) $appointment->discount_amount, $baseFee);
                $newFinalFee = max(0, $baseFee - $discount);

                $summary = $this->payments->summary($appointment);
                if ($summary['applied'] > $newFinalFee + 0.0001) {
                    throw new DomainException(
                        'PAYMENT_EXCEEDS_BALANCE',
                        'The new appointment fee would be lower than money already applied. Refund or adjust payment first.',
                        409
                    );
                }

                $appointment->base_fee = $baseFee;
                $appointment->discount_amount = $discount;
                $appointment->final_fee = $newFinalFee;
            }

            if (array_key_exists('type', $data)) {
                $appointment->type = $data['type'];
            }
            if (array_key_exists('reason', $data)) {
                $appointment->reason = $data['reason'];
            }

            $appointment->save();
            $this->payments->syncPaymentStatus($appointment);

            $this->audit->log('appointment.updated', $actor, $appointment, $before, $appointment->only([
                'type', 'reason', 'base_fee', 'discount_amount', 'final_fee', 'payment_status',
            ]));

            return $appointment->fresh(['patient', 'doctor.user', 'location', 'assignment']);
        }, 3);
    }

    public function reschedule(User $actor, Appointment $appointment, array $data): Appointment
    {
        $appointment->loadMissing(['patient', 'assignment']);

        if (! $this->access->canReschedule($actor, $appointment)) {
            throw new DomainException('FORBIDDEN', 'You cannot reschedule this appointment.', 403);
        }

        if ($appointment->status !== AppointmentStatus::CONFIRMED) {
            throw new DomainException(
                'INVALID_STATUS_TRANSITION',
                'Only confirmed appointments can be rescheduled. Checked-in or active queue visits must be resolved first.',
                409
            );
        }

        if ($actor->role === UserRole::PATIENT) {
            $cutoff = (int) ($appointment->assignment?->cancellation_cutoff_minutes ?? 0);
            if (now()->greaterThan($appointment->scheduled_start_at->copy()->subMinutes($cutoff))) {
                throw new DomainException(
                    'RESCHEDULE_CUTOFF_PASSED',
                    'The reschedule cutoff for this appointment has passed.',
                    409
                );
            }
        }

        $newLocationId = (int) ($data['location_id'] ?? $appointment->location_id);
        $startUtc = CarbonImmutable::parse($data['scheduled_start_at'])->utc();

        return DB::transaction(function () use ($actor, $appointment, $newLocationId, $startUtc) {
            $appointment = Appointment::query()->lockForUpdate()->findOrFail($appointment->id);

            $assignment = DoctorLocationAssignment::query()
                ->where('organization_id', $actor->organization_id)
                ->where('doctor_id', $appointment->doctor_id)
                ->where('location_id', $newLocationId)
                ->where('is_active', true)
                ->whereHas('doctor', fn ($q) => $q->where('is_active', true))
                ->whereHas('location', fn ($q) => $q->where('is_active', true))
                ->lockForUpdate()
                ->first();

            if (! $assignment) {
                throw new DomainException('DOCTOR_NOT_ASSIGNED', 'Doctor is not active at the selected location.', 422);
            }

            if (! $this->access->isAdmin($actor)
                && ! $this->access->canUseDoctorLocation($actor, $appointment->doctor_id, $newLocationId, 'can_reschedule')
                && $actor->role !== UserRole::PATIENT) {
                throw new DomainException('FORBIDDEN', 'You cannot reschedule into this doctor/location.', 403);
            }

            $assignment->loadMissing(['doctor.organization', 'location.organization']);
            $endUtc = $this->schedule->assertSlotAvailable($assignment, $startUtc, $appointment->id);

            $baseFee = $appointment->type === 'follow_up'
                ? (float) $assignment->followup_fee
                : (float) $assignment->consultation_fee;
            $discount = min((float) $appointment->discount_amount, $baseFee);
            $newFinalFee = max(0, $baseFee - $discount);

            $summary = $this->payments->summary($appointment);
            if ($summary['applied'] > $newFinalFee + 0.0001) {
                throw new DomainException(
                    'PAYMENT_EXCEEDS_BALANCE',
                    'The target location fee is lower than money already applied. Refund or adjust payment first.',
                    409
                );
            }

            $before = [
                'location_id' => $appointment->location_id,
                'scheduled_start_at' => $appointment->scheduled_start_at,
                'scheduled_end_at' => $appointment->scheduled_end_at,
                'base_fee' => $appointment->base_fee,
                'final_fee' => $appointment->final_fee,
            ];

            $appointment->update([
                'location_id' => $newLocationId,
                'doctor_location_assignment_id' => $assignment->id,
                'scheduled_start_at' => $startUtc,
                'scheduled_end_at' => $endUtc,
                'base_fee' => $baseFee,
                'discount_amount' => $discount,
                'final_fee' => $newFinalFee,
            ]);

            $this->payments->syncPaymentStatus($appointment);

            NotificationLog::query()
                ->where('appointment_id', $appointment->id)
                ->whereIn('status', ['queued', 'failed', 'processing'])
                ->update(['status' => 'superseded']);

            $this->audit->log('appointment.rescheduled', $actor, $appointment, $before, [
                'location_id' => $appointment->location_id,
                'scheduled_start_at' => $appointment->scheduled_start_at,
                'scheduled_end_at' => $appointment->scheduled_end_at,
                'base_fee' => $appointment->base_fee,
                'final_fee' => $appointment->final_fee,
            ]);

            return $appointment->fresh(['patient', 'doctor.user', 'location', 'assignment']);
        }, 3);
    }

    public function cancel(User $actor, Appointment $appointment, string $reason): Appointment
    {
        $appointment->loadMissing(['patient', 'assignment']);

        if (! $this->access->canCancel($actor, $appointment)) {
            throw new DomainException('FORBIDDEN', 'You cannot cancel this appointment.', 403);
        }

        if ($appointment->status === AppointmentStatus::CANCELLED) {
            return $appointment;
        }

        if ($actor->role === UserRole::PATIENT) {
            $cutoff = (int) ($appointment->assignment?->cancellation_cutoff_minutes ?? 0);
            if (now()->greaterThan($appointment->scheduled_start_at->copy()->subMinutes($cutoff))) {
                throw new DomainException(
                    'CANCELLATION_CUTOFF_PASSED',
                    'The cancellation cutoff for this appointment has passed.',
                    409
                );
            }
        }

        return DB::transaction(function () use ($actor, $appointment, $reason) {
            $appointment = Appointment::query()->lockForUpdate()->findOrFail($appointment->id);
            $from = $appointment->status;

            if (! in_array(AppointmentStatus::CANCELLED, $from->allowedNext(), true)
                && ! $this->access->isAdmin($actor)) {
                throw new DomainException(
                    'INVALID_STATUS_TRANSITION',
                    "Cannot cancel an appointment in {$from->value} status.",
                    409
                );
            }

            $appointment->update([
                'status' => AppointmentStatus::CANCELLED,
                'cancelled_at' => now(),
                'cancelled_by_user_id' => $actor->id,
                'cancellation_reason' => $reason,
            ]);

            AppointmentStatusHistory::create([
                'appointment_id' => $appointment->id,
                'from_status' => $from->value,
                'to_status' => AppointmentStatus::CANCELLED->value,
                'reason' => $reason,
                'changed_by_user_id' => $actor->id,
                'created_at' => now(),
            ]);

            $queueEntries = $appointment->queueEntries()
                ->whereNotIn('status', ['completed', 'no_show', 'cancelled'])
                ->lockForUpdate()
                ->get();

            foreach ($queueEntries as $entry) {
                $queueFrom = $entry->status;
                $entry->update(['status' => 'cancelled']);
                QueueStatusHistory::create([
                    'queue_entry_id' => $entry->id,
                    'event_type' => 'queue.status_changed',
                    'from_status' => $queueFrom->value,
                    'to_status' => 'cancelled',
                    'reason' => $reason,
                    'changed_by_user_id' => $actor->id,
                    'created_at' => now(),
                ]);
            }

            NotificationLog::query()
                ->where('appointment_id', $appointment->id)
                ->whereIn('status', ['queued', 'failed', 'processing'])
                ->update(['status' => 'superseded']);

            $this->audit->log('appointment.cancelled', $actor, $appointment, [
                'status' => $from->value,
            ], [
                'status' => AppointmentStatus::CANCELLED->value,
                'reason' => $reason,
                'cancelled_at' => $appointment->cancelled_at,
            ]);

            return $appointment->fresh(['patient', 'doctor.user', 'location', 'assignment']);
        }, 3);
    }

    public function transition(
        User $actor,
        Appointment $appointment,
        AppointmentStatus $to,
        ?string $reason = null,
        bool $adminCorrection = false
    ): Appointment {
        return DB::transaction(function () use ($actor, $appointment, $to, $reason, $adminCorrection) {
            $appointment = Appointment::query()->lockForUpdate()->findOrFail($appointment->id);
            $from = $appointment->status;

            if ($from === $to) {
                return $appointment;
            }

            $allowed = in_array($to, $from->allowedNext(), true);

            if (! $allowed && ! ($adminCorrection && $this->access->isAdmin($actor))) {
                throw new DomainException(
                    'INVALID_STATUS_TRANSITION',
                    "Cannot move appointment from {$from->value} to {$to->value}.",
                    409
                );
            }

            $queueTarget = match ($to) {
                AppointmentStatus::CHECKED_IN => QueueStatus::CHECKED_IN,
                AppointmentStatus::WAITING => QueueStatus::WAITING,
                AppointmentStatus::WITH_DOCTOR => QueueStatus::WITH_DOCTOR,
                AppointmentStatus::COMPLETED => QueueStatus::COMPLETED,
                AppointmentStatus::NO_SHOW => QueueStatus::NO_SHOW,
                AppointmentStatus::CANCELLED => QueueStatus::CANCELLED,
                default => null,
            };

            $queueEntry = null;
            if ($queueTarget) {
                $queueEntry = QueueEntry::query()
                    ->where('appointment_id', $appointment->id)
                    ->orderByDesc('queue_date')
                    ->lockForUpdate()
                    ->first();

                if (! $queueEntry && ! $adminCorrection) {
                    throw new DomainException(
                        'QUEUE_ENTRY_REQUIRED',
                        'This workflow status must be changed through an active queue entry.',
                        409
                    );
                }
            }

            $appointment->status = $to;
            $appointment->save();

            AppointmentStatusHistory::create([
                'appointment_id' => $appointment->id,
                'from_status' => $from->value,
                'to_status' => $to->value,
                'reason' => $reason,
                'changed_by_user_id' => $actor->id,
                'created_at' => now(),
            ]);

            if ($queueEntry && $queueTarget && $queueEntry->status !== $queueTarget) {
                $queueFrom = $queueEntry->status;
                $queueUpdates = ['status' => $queueTarget];

                if ($queueTarget === QueueStatus::WITH_DOCTOR) {
                    $queueUpdates['sent_to_doctor_at'] = now();
                } elseif ($queueTarget === QueueStatus::COMPLETED) {
                    $queueUpdates['completed_at'] = now();
                } elseif ($queueTarget === QueueStatus::NO_SHOW) {
                    $queueUpdates['no_show_at'] = now();
                }

                $queueEntry->update($queueUpdates);

                QueueStatusHistory::create([
                    'queue_entry_id' => $queueEntry->id,
                    'event_type' => 'queue.status_changed',
                    'from_status' => $queueFrom->value,
                    'to_status' => $queueTarget->value,
                    'reason' => $reason,
                    'changed_by_user_id' => $actor->id,
                    'created_at' => now(),
                ]);
            }

            if ($to === AppointmentStatus::COMPLETED || $from === AppointmentStatus::COMPLETED) {
                $this->patientMetrics->refresh($appointment->patient_id);
            }

            $this->audit->log('appointment.status_changed', $actor, $appointment, [
                'status' => $from->value,
            ], [
                'status' => $to->value,
            ], ['reason' => $reason]);

            return $appointment;
        }, 3);
    }

    private function patientSelfBookingEnabled(int $organizationId): bool
    {
        return $this->settings->bool($organizationId, 'patient_self_booking_enabled', false);
    }
}
