<?php

namespace App\Domain\Attendance\Services;

use App\Domain\Attendance\Enums\AttendanceStatus;
use App\Domain\Attendance\Models\AttendanceRecord;
use App\Domain\Participant\Models\Participant;
use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Training\Enums\SessionStatus;
use App\Domain\Training\Models\TrainingSession;
use App\Models\User;
use Illuminate\Support\Facades\DB;

/**
 * Turns a scanned pass into an attendance mark.
 *
 * This does **not** loosen AttendanceMarkingService. That service requires a
 * staff `User $marker` on every method, and that requirement is correct: an
 * attendance record says a member of staff observed someone. Here the marker is
 * the person holding the scanner, which is exactly true — so the existing
 * service is called with a real marker rather than bypassed.
 *
 * The one attendance write path stays one path. `POST /api/v1/absences/report`
 * was a second one, writing status='excused' with no marker, no transition
 * check, no audit and no check that the session even belonged to the
 * participant — a player could excuse himself. That is what a second write path
 * costs, and it is why this one goes through the front door.
 */
class SelfCheckInService
{
    /** How far either side of a session's start a scan still counts as arrival. */
    private const EARLY_MINUTES = 45;

    private const LATE_MINUTES = 30;

    public function __construct(
        private readonly CheckInTokenService $tokens,
        private readonly AttendanceMarkingService $marking,
    ) {}

    /**
     * @return array{record: AttendanceRecord, participant: Participant, session: TrainingSession, replay: bool}
     */
    public function scan(string $token, User $scanner, ?int $branchId = null, ?string $ip = null): array
    {
        ['participant' => $participant, 'counter' => $counter] = $this->tokens->verify($token);

        // The token asserted identity. Everything that follows is authorisation,
        // and every part of it is read fresh — which is what makes a suspension
        // take effect at the next scan rather than at the next token rotation.
        // `status` is cast to ParticipantStatus, so comparing it to the string
        // 'active' is always false and would turn this gate into a wall that
        // turns everyone away.
        $status = $participant->status instanceof \BackedEnum
            ? $participant->status->value
            : (string) $participant->status;

        if ($status !== 'active') {
            throw new DomainException(term('membership', $participant->branch_id) . ' غير نشطة — يرجى مراجعة الإدارة');
        }

        $session = $this->resolveSession($participant);

        if (! $session) {
            throw new DomainException('لا توجد حصة مجدولة لهذا ' . term('member_definite', $participant->branch_id) . ' الآن');
        }

        // The session's own branch first: facility_id is nullable on both
        // sessions and schedules, and reading the branch only through the
        // facility meant a facility-less session skipped this check entirely —
        // a scanner at one branch could mark a member into another branch's
        // session. training_sessions now carries branch_id, so the fallback
        // chain always resolves for a correctly filed session.
        $sessionBranchId = $session->branch_id
            ?? $session->facility?->branch_id
            ?? $session->group?->branch_id;

        if ($branchId && $sessionBranchId && (int) $sessionBranchId !== (int) $branchId) {
            throw new DomainException('هذه الحصة في فرع آخر');
        }

        return DB::transaction(function () use ($participant, $session, $scanner, $counter, $branchId, $ip) {
            // The consumption record and the attendance write are one atomic
            // act. Zero rows inserted means this exact code was already used —
            // a screenshot passed to a friend marks nothing.
            $inserted = DB::table('checkin_consumptions')->insertOrIgnore([
                'academy_id' => $participant->academy_id,
                'participant_id' => $participant->id,
                'training_session_id' => $session->id,
                'scanned_by' => $scanner->id,
                'branch_id' => $branchId,
                'counter' => $counter,
                'skew_steps' => $counter - $this->tokens->currentCounter(),
                'scanner_ip' => $ip,
                'created_at' => now(),
            ]);

            $record = AttendanceRecord::where('training_session_id', $session->id)
                ->where('subject_type', Participant::class)
                ->where('subject_id', $participant->id)
                ->lockForUpdate()
                ->first();

            if (! $record) {
                throw new DomainException('لا يوجد سجل حضور لهذا ' . term('member_definite', $participant->branch_id) . ' في هذه الحصة');
            }

            if ($inserted === 0) {
                return [
                    'record' => $record,
                    'participant' => $participant,
                    'session' => $session,
                    'replay' => true,
                ];
            }

            // Already marked by a coach on the sheet: the scan confirms rather
            // than overwrites. Re-marking would discard the human observation.
            if (in_array($record->status, [
                AttendanceStatus::Present,
                AttendanceStatus::Late,
                AttendanceStatus::Partial,
                AttendanceStatus::LeftEarly,
            ], true)) {
                return [
                    'record' => $record,
                    'participant' => $participant,
                    'session' => $session,
                    'replay' => true,
                ];
            }

            $record = $this->marking->markPresent($record, $scanner, now());

            $record->forceFill([
                'metadata' => array_merge($record->metadata ?? [], [
                    'marked_via' => 'qr_scan',
                    'scanned_by' => $scanner->id,
                ]),
            ])->save();

            return [
                'record' => $record,
                'participant' => $participant,
                'session' => $session,
                'replay' => false,
            ];
        });
    }

    /**
     * The session this member is plausibly arriving for: one of their groups,
     * today, inside the arrival window, nearest start time first.
     */
    private function resolveSession(Participant $participant): ?TrainingSession
    {
        $groupIds = $participant->activeEnrollments()->pluck('training_group_id')->all();

        if ($groupIds === []) {
            return null;
        }

        $from = now()->subMinutes(self::LATE_MINUTES)->format('H:i:s');
        $to = now()->addMinutes(self::EARLY_MINUTES)->format('H:i:s');

        return TrainingSession::whereIn('training_group_id', $groupIds)
            ->where('session_date', now()->toDateString())
            ->where('status', SessionStatus::Scheduled)
            ->whereBetween('start_time', [$from, $to])
            ->with('facility')
            ->orderByRaw('ABS(EXTRACT(EPOCH FROM (start_time - ?::time)))', [now()->format('H:i:s')])
            ->first();
    }
}
