<?php

namespace App\Domain\Training\Services;

use App\Domain\Financial\Services\InvoiceService;
use App\Domain\Participant\Models\Participant;
use App\Domain\Pricing\Services\PricingService;
use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Shared\Services\ProrationService;
use App\Domain\Shared\Services\SettingsService;
use App\Domain\Attendance\Services\AttendanceGenerationService;
use App\Domain\Training\Enums\RenewalPolicy;
use App\Domain\Training\Events\EnrollmentCancelled;
use App\Domain\Training\Events\EnrollmentCompleted;
use App\Domain\Training\Events\EnrollmentCreated;
use App\Domain\Training\Events\EnrollmentTransferred;
use App\Domain\Training\Events\WaitlistSpotAvailable;
use App\Domain\Training\Models\Enrollment;
use App\Domain\Training\Models\TrainingGroup;
use App\Domain\Training\Models\TrainingProgram;
use App\Domain\Training\Models\Waitlist;
use App\Domain\Training\Support\BillingCycle;
use App\Models\User;
use Illuminate\Support\Facades\DB;

class EnrollmentService
{
    public function __construct(
        private readonly TrainingGroupService $groupService,
        private readonly PricingService $pricingService,
        private readonly InvoiceService $invoiceService,
        private readonly SettingsService $settings,
        private readonly ProrationService $prorationService,
    ) {}

    public function enroll(Participant $participant, TrainingGroup $group, User $actor, ?array $options = []): Enrollment
    {
        return DB::transaction(function () use ($participant, $group, $actor, $options) {
            // Guard: participant status must allow enrollment
            $blockedStatuses = ['frozen', 'suspended', 'blacklisted', 'transferred'];
            if (in_array($participant->status->value, $blockedStatuses)) {
                throw new DomainException('لا يمكن تسجيل مشترك ' . $this->getStatusLabel($participant->status->value));
            }

            // Guard: check if already enrolled in THIS group
            $existingInGroup = Enrollment::where('participant_id', $participant->id)
                ->where('training_group_id', $group->id)
                ->whereIn('status', ['pending', 'active'])
                ->exists();
            if ($existingInGroup) {
                throw new DomainException('المشترك مسجل بالفعل في هذه المجموعة');
            }

            // Guard: one group per program rule
            $existingInProgram = Enrollment::where('participant_id', $participant->id)
                ->where('training_program_id', $group->training_program_id)
                ->whereIn('status', ['pending', 'active'])
                ->exists();
            if ($existingInProgram) {
                throw new DomainException('المشترك مسجل بالفعل في مجموعة أخرى من نفس البرنامج');
            }

            // Guard: group capacity
            if ($group->isFull()) {
                if ($group->program && $group->program->allow_waitlist) {
                    $this->addToWaitlist($participant, $group);
                    $position = Waitlist::where('training_group_id', $group->id)
                        ->whereNull('response')
                        ->max('position');
                    throw new DomainException('المجموعة ممتلئة. تمت إضافة المشترك لقائمة الانتظار (المركز: ' . $position . ')');
                }
                throw new DomainException('المجموعة ممتلئة ولا تقبل تسجيلات جديدة');
            }

            // Guard: group must accept enrollments
            $acceptingStatuses = ['forming', 'active'];
            if (!in_array($group->status->value, $acceptingStatuses)) {
                throw new DomainException('المجموعة لا تقبل تسجيلات حالياً');
            }

            // Guard: one branch. `enrollments` inherits its branch from the
            // group, and a member enrolled into another branch's group appears
            // on that branch's roster, attendance sheets and session lists for
            // good — while their own branch bills them. Moving a member between
            // branches is TransferService's job, deliberately and with the
            // enrolments cancelled; it is not something enrolment does by
            // accident because a group id came in from a form.
            if ($participant->branch_id && $group->branch_id
                && (int) $participant->branch_id !== (int) $group->branch_id) {
                throw new DomainException('المشترك والمجموعة في فرعين مختلفين');
            }

            $enrollment = Enrollment::create([
                // The enrolment belongs to the group's branch, not to whichever
                // branch the person doing the enrolling was looking at.
                'branch_id' => $group->branch_id ?? $participant->branch_id,
                'participant_id' => $participant->id,
                'training_group_id' => $group->id,
                'training_program_id' => $group->training_program_id,
                'enrollment_date' => now()->toDateString(),
                'start_date' => $options['start_date'] ?? $group->start_date ?? now()->toDateString(),
                'end_date' => $options['end_date'] ?? $group->end_date,
                'next_billing_date' => $participant->is_free ? null : $this->calculateFirstBillingDate($group->program),
                'status' => 'active',
                'enrolled_by' => $actor->id,
                'invoice_id' => $options['invoice_id'] ?? null,
                'payment_status' => $participant->is_free ? 'waived' : ($options['payment_status'] ?? 'pending'),
                'sessions_total' => $group->program?->total_sessions,
            ]);

            // Update group count
            $this->groupService->incrementCount($group);

            // Activate participant if still in registered status
            if ($participant->status->value === 'registered') {
                $participant->update(['status' => 'active']);
            }

            // Auto-create invoice if program has a price (skip if invoice already provided, explicitly skipped, or free player)
            if (empty($options['invoice_id']) && empty($options['skip_auto_invoice']) && !$participant->is_free && (bool) $this->settings->get('auto_invoice_on_enrollment', false)) {
                $this->createEnrollmentInvoice($enrollment, $participant, $group, $actor);
            }

            EnrollmentCreated::dispatch($enrollment, $actor);

            return $enrollment;
        });
    }

    /**
     * Enroll a participant in a program by finding (or auto-creating) an available group.
     */
    public function enrollInProgram(Participant $participant, TrainingProgram $program, User $actor, array $options = []): Enrollment
    {
        return DB::transaction(function () use ($participant, $program, $actor, $options) {
            // Guard: program must accept enrollments
            if (!$program->registration_open) {
                throw new DomainException('التسجيل في هذا البرنامج مغلق حالياً');
            }

            // Guard: participant not already enrolled in this program
            $existingInProgram = Enrollment::where('participant_id', $participant->id)
                ->where('training_program_id', $program->id)
                ->whereIn('status', ['pending', 'active'])
                ->exists();
            if ($existingInProgram) {
                throw new DomainException('المشترك مسجل بالفعل في هذا البرنامج');
            }

            $allowOverload = $options['allow_overload'] ?? false;

            // The member's own branch decides which groups are eligible. A
            // programme can run in several branches, and picking "the group
            // with the most room" across all of them quietly enrolled people
            // into another branch — and then filed the auto-invoice there too,
            // because it is stamped with the group's branch.
            $group = $this->findOrCreateGroup($program, $actor, $allowOverload, $participant->branch_id);

            return $this->enroll($participant, $group, $actor, $options);
        });
    }

    /**
     * Find an available group in the program, or auto-create one.
     * Uses lockForUpdate to prevent race conditions on group selection.
     */
    private function findOrCreateGroup(
        TrainingProgram $program,
        User $actor,
        bool $allowOverload,
        ?int $branchId = null
    ): TrainingGroup {
        // Find groups accepting enrollments (forming or active) with available capacity
        $availableGroup = TrainingGroup::where('training_program_id', $program->id)
            ->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
            ->whereIn('status', ['forming', 'active'])
            ->lockForUpdate()
            ->get()
            ->filter(fn (TrainingGroup $g) => $g->hasAvailableSpots())
            ->sortByDesc(fn (TrainingGroup $g) => $g->max_capacity - $g->current_count)
            ->first();

        if ($availableGroup) {
            return $availableGroup;
        }

        // No group with capacity found — handle overload or auto-create
        if ($allowOverload) {
            // Overload: pick the least-full group (even though all are full)
            $leastFullGroup = TrainingGroup::where('training_program_id', $program->id)
                ->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
                ->whereIn('status', ['forming', 'active', 'full'])
                ->lockForUpdate()
                ->orderByRaw('(max_capacity - current_count) DESC')
                ->first();

            if (!$leastFullGroup) {
                throw new DomainException('لا توجد مجموعات في هذا البرنامج يمكن التسجيل فيها');
            }

            return $leastFullGroup;
        }

        // Auto-create a new group (only if enabled)
        if (!(bool) $this->settings->get('auto_create_groups_enabled', false)) {
            throw new DomainException('لا توجد مجموعة متاحة — يرجى إنشاء مجموعة يدوياً');
        }

        return $this->autoCreateGroup($program, $actor, $branchId);
    }

    /**
     * Auto-create a new group for the program with sequential naming.
     */
    private function autoCreateGroup(TrainingProgram $program, User $actor, ?int $branchId = null): TrainingGroup
    {
        $existingCount = TrainingGroup::where('training_program_id', $program->id)
            ->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
            ->count();
        $sequenceNumber = $existingCount + 1;

        $programName = $program->name_ar ?: $program->name;
        $groupNameAr = "مجموعة {$programName} {$sequenceNumber}";
        $groupName = $program->name ? "Group {$program->name} {$sequenceNumber}" : $groupNameAr;

        return $this->groupService->create([
            'academy_id' => $program->academy_id,
            'training_program_id' => $program->id,
            // Programmes are shared, so an academy-wide one carries no branch —
            // and `training_groups` is strict, where a null branch is a group
            // no branch can see. Fall back to the branch we are enrolling for.
            'branch_id' => $program->branch_id ?? $branchId,
            'name' => $groupName,
            'name_ar' => $groupNameAr,
            'max_capacity' => $program->max_participants,
            'current_count' => 0,
            'waitlist_count' => 0,
            'start_date' => $program->program_start_date ?? now()->toDateString(),
            'end_date' => $program->program_end_date,
        ], $actor);
    }

    public function cancel(Enrollment $enrollment, string $reason, User $actor): Enrollment
    {
        return DB::transaction(function () use ($enrollment, $reason, $actor) {
            if (!in_array($enrollment->status->value, ['pending', 'active'])) {
                throw new DomainException('لا يمكن إلغاء هذا التسجيل');
            }

            $enrollment->update([
                'status' => 'cancelled',
                'withdrawal_date' => now()->toDateString(),
                'withdrawal_reason' => $reason,
            ]);

            // Decrement group count
            $group = $enrollment->group;
            $this->groupService->decrementCount($group);

            EnrollmentCancelled::dispatch($enrollment, $actor);

            return $enrollment->fresh();
        });
    }

    public function complete(Enrollment $enrollment, ?User $actor = null): Enrollment
    {
        return DB::transaction(function () use ($enrollment, $actor) {
            if ($enrollment->status->value !== 'active') {
                throw new DomainException('يمكن إكمال التسجيلات النشطة فقط');
            }

            // Check attendance requirement
            $program = $enrollment->program;
            if ($program && $program->attendance_requirement_percent) {
                $percentage = $enrollment->attendance_percentage ?? 0;
                if ($percentage < $program->attendance_requirement_percent) {
                    throw new DomainException("نسبة الحضور ({$percentage}%) أقل من المطلوب ({$program->attendance_requirement_percent}%)");
                }
            }

            $enrollment->update([
                'status' => 'completed',
                'end_date' => now()->toDateString(),
                'completion_percentage' => 100,
            ]);

            if ($actor) {
                EnrollmentCompleted::dispatch($enrollment, $actor);
            }

            return $enrollment->fresh();
        });
    }

    public function transfer(Enrollment $enrollment, TrainingGroup $toGroup, User $actor): Enrollment
    {
        return DB::transaction(function () use ($enrollment, $toGroup, $actor) {
            if ($enrollment->status->value !== 'active') {
                throw new DomainException('يمكن نقل التسجيلات النشطة فقط');
            }

            if ($toGroup->isFull()) {
                throw new DomainException('المجموعة المنقول إليها ممتلئة');
            }

            // Same branch only. This is a move between groups, not between
            // branches: the target comes from a picker, and a cross-branch move
            // would leave the member's participants.branch_id pointing at one
            // branch while their enrolment, sessions and attendance live in
            // another. Moving branches is TransferService, which also cancels
            // the enrolments it leaves behind.
            $fromBranchId = $enrollment->group?->branch_id ?? $enrollment->branch_id;

            if ($fromBranchId && $toGroup->branch_id
                && (int) $fromBranchId !== (int) $toGroup->branch_id) {
                throw new DomainException('لا يمكن النقل إلى مجموعة في فرع آخر');
            }

            // Cancel old enrollment
            $enrollment->update([
                'status' => 'cancelled',
                'withdrawal_date' => now()->toDateString(),
                'withdrawal_reason' => 'تحويل إلى مجموعة أخرى',
                'transferred_to_id' => $toGroup->id,
            ]);

            $this->groupService->decrementCount($enrollment->group);

            // Create new enrollment in target group (carry over billing date)
            $newEnrollment = Enrollment::create([
                'branch_id' => $toGroup->branch_id ?? $enrollment->branch_id,
                'participant_id' => $enrollment->participant_id,
                'training_group_id' => $toGroup->id,
                'training_program_id' => $toGroup->training_program_id,
                'enrollment_date' => now()->toDateString(),
                'start_date' => now()->toDateString(),
                'end_date' => $toGroup->end_date,
                'next_billing_date' => $enrollment->next_billing_date?->toDateString(),
                'last_billed_at' => $enrollment->last_billed_at?->toDateString(),
                'status' => 'active',
                'enrolled_by' => $actor->id,
                'payment_status' => $enrollment->payment_status->value,
                'sessions_attended' => $enrollment->sessions_attended,
                'sessions_total' => $enrollment->sessions_total,
            ]);

            $this->groupService->incrementCount($toGroup);

            // Remove future attendance from old group
            app(AttendanceGenerationService::class)->removeForCancelledEnrollment($enrollment);

            // Generate future attendance in new group
            app(AttendanceGenerationService::class)->generateForEnrollment($newEnrollment);

            // Process waitlist on source group
            $this->processWaitlist($enrollment->group);

            // Dispatch transfer event (notifications fire after commit)
            EnrollmentTransferred::dispatch($enrollment, $newEnrollment, $actor);

            return $newEnrollment;
        });
    }

    private function addToWaitlist(Participant $participant, TrainingGroup $group): void
    {
        $existsOnWaitlist = Waitlist::where('participant_id', $participant->id)
            ->where('training_group_id', $group->id)
            ->whereNull('response')
            ->exists();

        if ($existsOnWaitlist) {
            throw new DomainException('المشترك موجود بالفعل في قائمة الانتظار');
        }

        $position = Waitlist::where('training_group_id', $group->id)
            ->whereNull('response')
            ->max('position') ?? 0;

        Waitlist::create([
            'academy_id' => $group->academy_id,
            'participant_id' => $participant->id,
            'training_group_id' => $group->id,
            'position' => $position + 1,
            'requested_at' => now(),
        ]);

        $group->increment('waitlist_count');
    }

    public function processWaitlist(TrainingGroup $group): void
    {
        if (!$group->hasAvailableSpots()) {
            return;
        }

        $next = Waitlist::where('training_group_id', $group->id)
            ->whereNull('response')
            ->orderBy('position')
            ->first();

        if ($next) {
            $next->update([
                'notified_at' => now(),
                'expires_at' => now()->addHours(24),
            ]);
            WaitlistSpotAvailable::dispatch($group, $next);
        }
    }

    /**
     * Auto-create an invoice for a new enrollment if the program has a base price.
     * Uses PricingService to calculate the final price (applies rules/discounts).
     */
    private function createEnrollmentInvoice(Enrollment $enrollment, Participant $participant, TrainingGroup $group, User $actor): void
    {
        $program = $group->program;
        if (!$program) {
            return;
        }

        // Use PricingService to calculate the price — it will throw if no base price exists
        try {
            $priceResult = $this->pricingService->calculate(
                priceable: $program,
                participant: $participant,
                branchId: $group->branch_id,
            );
        } catch (DomainException) {
            // No base price configured for this program — no invoice needed (free program)
            return;
        }

        // If final amount is 0 (100% discount or free), skip invoice
        if ($priceResult->finalAmount <= 0) {
            $enrollment->update(['payment_status' => 'paid']);
            return;
        }

        $finalAmount = $priceResult->finalAmount;
        $lineDescription = "اشتراك: {$program->name_ar}";

        if ($this->prorationService->isEnabled()) {
            $proration = $this->prorationService->calculate($priceResult->finalAmount);
            $finalAmount = $proration->proratedAmount;
            if ($proration->applied) {
                $lineDescription .= " ({$proration->description})";
            }
        }

        $invoice = $this->invoiceService->create([
            'academy_id' => $enrollment->academy_id ?? $group->academy_id,
            'branch_id' => $group->branch_id,
            'billable_type' => $participant->getMorphClass(),
            'billable_id' => $participant->id,
            'number' => $this->invoiceService->generateNumber($group->academy_id),
            'total_amount' => $finalAmount,
            'subtotal_amount' => $priceResult->baseAmount,
            'discount_amount' => $priceResult->totalDiscount,
            'tax_amount' => 0,
            'due_date' => now()->addDays(7)->toDateString(),
            'contact_name' => $participant->person?->name_ar ?? $participant->person?->name,
        ], [
            [
                'description' => $lineDescription,
                'quantity' => 1,
                'unit_price' => $finalAmount,
                'discount_amount' => 0,
                'tax_amount' => 0,
            ],
        ], $actor);

        // Link invoice to enrollment
        $enrollment->update([
            'invoice_id' => $invoice->id,
            'payment_status' => 'pending',
        ]);
    }

    /**
     * A new enrolment's first renewal falls on the 1st of the next cycle —
     * whatever day of the month they actually joined on.
     *
     * The rule lives in BillingCycle, which every other caller now shares, so
     * there is one answer to "when is the next renewal" rather than five
     * hand-written ones that quietly disagreed.
     */
    private function calculateFirstBillingDate(?TrainingProgram $program): ?string
    {
        if (!$program) {
            return null;
        }

        if ($program->renewal_policy === RenewalPolicy::OneTime) {
            return null;
        }

        return BillingCycle::next(
            null,
            $program->billing_cycle,
            $program->program_duration_weeks
        )->toDateString();
    }

    private function getStatusLabel(string $status): string
    {
        return match ($status) {
            'frozen' => 'متجمد',
            'suspended' => 'موقوف',
            'blacklisted' => 'محظور',
            'transferred' => 'محول',
            default => $status,
        };
    }
}
