<?php

namespace Tests\Feature;

use App\Domain\Attendance\Enums\AttendanceStatus;
use App\Domain\Attendance\Models\AttendanceRecord;
use App\Domain\Attendance\Services\CheckInTokenService;
use App\Domain\Attendance\Services\SelfCheckInService;
use App\Domain\Participant\Models\Participant;
use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Shared\Models\Academy;
use App\Domain\Training\Enums\SessionStatus;
use App\Domain\Training\Models\TrainingSession;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;

/**
 * The check-in pass, against a restored copy of a real tenant database.
 *
 * Postgres-only: the replay control IS a unique index plus INSERT … ON CONFLICT
 * DO NOTHING inside the attendance transaction, and a Cache::has/Cache::put
 * pair — which is what SQLite would let this degrade into — is a
 * time-of-check-to-time-of-use race that loses exactly when two scanners are
 * working one gate.
 *
 *   DB_CONNECTION=pgsql DB_DATABASE=oc_sport_test ./vendor/bin/phpunit --filter CheckInScanTest
 */
class CheckInScanTest extends TestCase
{
    private Participant $participant;

    private TrainingSession $session;

    private User $staff;

    protected function setUp(): void
    {
        parent::setUp();

        if (config('database.default') !== 'pgsql') {
            $this->markTestSkipped('Needs a restored Postgres tenant; see the class comment.');
        }

        config(['attendance.checkin_pepper' => str_repeat('k', 64)]);

        $academy = Academy::firstOrFail();
        $this->app->instance('current_academy', $academy);
        $this->staff = User::withoutGlobalScopes()->where('academy_id', $academy->id)->firstOrFail();

        DB::beginTransaction();

        $this->participant = $this->aParticipantWithAGroup();
        $this->session = $this->aSessionStartingNow();
    }

    protected function tearDown(): void
    {
        if (config('database.default') === 'pgsql') {
            DB::rollBack();
        }

        parent::tearDown();
    }

    private function aParticipantWithAGroup(): Participant
    {
        $participant = Participant::whereHas('activeEnrollments')->firstOrFail();
        $participant->update(['status' => 'active']);

        return $participant->refresh();
    }

    /** A session for one of the participant's groups, starting right now. */
    private function aSessionStartingNow(): TrainingSession
    {
        $groupId = $this->participant->activeEnrollments()->value('training_group_id');

        $session = TrainingSession::create([
            'academy_id' => $this->participant->academy_id,
            'training_group_id' => $groupId,
            'session_date' => now()->toDateString(),
            'start_time' => now()->format('H:i:s'),
            'end_time' => now()->addHour()->format('H:i:s'),
            'status' => SessionStatus::Scheduled,
        ]);

        AttendanceRecord::create([
            'academy_id' => $this->participant->academy_id,
            'training_session_id' => $session->id,
            'subject_type' => Participant::class,
            'subject_id' => $this->participant->id,
            'status' => AttendanceStatus::Expected,
        ]);

        return $session;
    }

    // ---- what the token is -------------------------------------------------

    public function test_a_valid_pass_marks_the_member_present(): void
    {
        $token = app(CheckInTokenService::class)->issue($this->participant);

        $result = app(SelfCheckInService::class)->scan($token, $this->staff);

        $this->assertFalse($result['replay']);
        $this->assertSame(AttendanceStatus::Present, $result['record']->status);
        // The marker is the person holding the scanner, which is exactly true.
        $this->assertSame($this->staff->id, (int) $result['record']->marked_by);
    }

    public function test_the_same_code_scanned_twice_marks_nothing_the_second_time(): void
    {
        // Relay is unsolvable — a member can always screenshot the code. What
        // makes it worthless is that the second scan changes nothing.
        $token = app(CheckInTokenService::class)->issue($this->participant);
        $scanner = app(SelfCheckInService::class);

        $scanner->scan($token, $this->staff);
        $second = $scanner->scan($token, $this->staff);

        $this->assertTrue($second['replay']);

        $this->assertSame(1, DB::table('checkin_consumptions')
            ->where('participant_id', $this->participant->id)
            ->count());
    }

    public function test_a_forged_tag_is_refused(): void
    {
        $token = app(CheckInTokenService::class)->issue($this->participant);
        $parts = explode('.', $token);
        $parts[3] = strrev($parts[3]);

        $this->expectException(DomainException::class);

        app(SelfCheckInService::class)->scan(implode('.', $parts), $this->staff);
    }

    public function test_bumping_the_key_version_kills_every_outstanding_pass(): void
    {
        // Revocation is one integer column, so it is atomic and takes effect at
        // the next scan rather than at the next token rotation.
        $token = app(CheckInTokenService::class)->issue($this->participant);

        $this->participant->increment('checkin_key_version');

        $this->expectException(DomainException::class);

        app(SelfCheckInService::class)->scan($token, $this->staff);
    }

    public function test_a_code_from_a_distant_time_window_is_refused(): void
    {
        $tokens = app(CheckInTokenService::class);
        $stale = $tokens->issue($this->participant, $tokens->currentCounter() - 10);

        $this->expectException(DomainException::class);

        app(SelfCheckInService::class)->scan($stale, $this->staff);
    }

    public function test_one_step_of_clock_skew_is_tolerated(): void
    {
        $tokens = app(CheckInTokenService::class);
        $token = $tokens->issue($this->participant, $tokens->currentCounter() - 1);

        $result = app(SelfCheckInService::class)->scan($token, $this->staff);

        $this->assertFalse($result['replay']);
    }

    public function test_a_suspended_member_is_refused_even_with_a_valid_code(): void
    {
        // The pass asserts identity and never authorizes: status is a fresh
        // read at the moment of the scan.
        $token = app(CheckInTokenService::class)->issue($this->participant);
        $this->participant->update(['status' => 'suspended']);

        $this->expectException(DomainException::class);

        app(SelfCheckInService::class)->scan($token, $this->staff);
    }

    public function test_a_pass_with_no_session_now_is_refused(): void
    {
        $this->session->update(['session_date' => now()->addDays(3)->toDateString()]);

        $token = app(CheckInTokenService::class)->issue($this->participant);

        $this->expectException(DomainException::class);

        app(SelfCheckInService::class)->scan($token, $this->staff);
    }

    public function test_a_missing_pepper_refuses_to_issue_rather_than_using_a_weak_key(): void
    {
        config(['attendance.checkin_pepper' => '']);

        $this->expectException(DomainException::class);

        app(CheckInTokenService::class)->issue($this->participant);
    }

    public function test_the_token_never_carries_the_secret(): void
    {
        $token = app(CheckInTokenService::class)->issue($this->participant);

        $this->assertStringNotContainsString(str_repeat('k', 16), $token);
        $this->assertStringStartsWith('v1.' . $this->participant->uuid . '.', $token);
    }
}
