<?php

namespace App\Domain\Attendance\Services;

use App\Domain\Participant\Models\Participant;
use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Shared\Models\Academy;

/**
 * The rotating pass a member shows at the gate.
 *
 * **The token asserts identity. It never authorizes.** Enrolment status,
 * participant status, whether a session exists in this window and any balance
 * gate are all fresh database reads at the moment of the scan — that is what
 * makes revocation instant rather than "instant once the current token
 * expires".
 *
 * Design decisions worth stating, because each rules out something plausible:
 *
 *  - The participant uuid is in the clear. The server does one indexed lookup
 *    and one HMAC. An opaque token would force it to iterate every participant
 *    trying HMACs, which is both a denial-of-service lever and a timing oracle.
 *  - The secret is derived, never stored: HKDF over a pepper that lives in the
 *    environment. There is no per-participant secret column to leak, and the
 *    pepper is deliberately NOT APP_KEY — APP_KEY decrypts sessions and
 *    cookies, and a check-in pepper does not deserve that blast radius.
 *  - Revocation is one integer, `participants.checkin_key_version`. Bumping it
 *    changes the derived secret, so every outstanding pass for that member dies
 *    at once, atomically and auditably.
 *  - Relay is not solved here, because relay is not solvable: a member can
 *    always screenshot a code and send it to a friend. What the design does is
 *    make it worthless — the consumption record and the unique key on
 *    (participant, session) mean the second scan marks nothing.
 */
class CheckInTokenService
{
    /** One rotation every 30 seconds, with one step of tolerance either way. */
    public const PERIOD_SECONDS = 30;

    private const VERSION = 'v1';

    private const TAG_BYTES = 16; // 128-bit truncation

    public function pepper(): string
    {
        $pepper = (string) config('attendance.checkin_pepper');

        if (strlen($pepper) < 32) {
            throw new DomainException(
                'مفتاح تسجيل الحضور غير مُهيّأ على الخادم (CHECKIN_PEPPER)'
            );
        }

        return $pepper;
    }

    public function currentCounter(?int $at = null): int
    {
        return intdiv($at ?? time(), self::PERIOD_SECONDS);
    }

    /**
     * The string encoded into the QR image.
     *
     * Format: v1.<participant_uuid>.<version>.<base32 tag>
     */
    public function issue(Participant $participant, ?int $counter = null): string
    {
        $counter ??= $this->currentCounter();
        $version = (int) ($participant->checkin_key_version ?? 1);

        $tag = $this->tag($participant, $version, $counter);

        return implode('.', [self::VERSION, $participant->uuid, $version, $tag]);
    }

    /**
     * Verify a scanned string and return the participant it identifies.
     *
     * Returns the participant and the counter that matched, so the caller can
     * record the consumption against that exact counter — recording "now"
     * instead would leave the accepted neighbouring counter reusable.
     *
     * @return array{participant: Participant, counter: int}
     */
    public function verify(string $token): array
    {
        $parts = explode('.', trim($token));

        if (count($parts) !== 4 || $parts[0] !== self::VERSION) {
            throw new DomainException('رمز غير صالح');
        }

        [, $uuid, $version, $tag] = $parts;

        if (! preg_match('/^[0-9a-fA-F-]{36}$/', $uuid) || ! ctype_digit($version)) {
            throw new DomainException('رمز غير صالح');
        }

        $participant = Participant::where('uuid', $uuid)->first();

        if (! $participant) {
            throw new DomainException('رمز غير صالح');
        }

        // A token minted under an older key version is dead the moment the
        // version is bumped — that is the revocation mechanism.
        $currentVersion = (int) ($participant->checkin_key_version ?? 1);

        if ((int) $version !== $currentVersion) {
            throw new DomainException('انتهت صلاحية بطاقة العضو — اطلب منه تحديث البطاقة');
        }

        $now = $this->currentCounter();

        // {C-1, C, C+1}: one step covers clock skew and the second it takes to
        // hold a phone up to a scanner. Widening this is never the fix — log
        // the observed skew instead.
        foreach ([$now, $now - 1, $now + 1] as $candidate) {
            if (hash_equals($this->tag($participant, $currentVersion, $candidate), $tag)) {
                return ['participant' => $participant, 'counter' => $candidate];
            }
        }

        throw new DomainException('انتهت صلاحية الرمز — اطلب من العضو تحديث الشاشة');
    }

    private function tag(Participant $participant, int $version, int $counter): string
    {
        $academyUuid = $this->academyUuid($participant->academy_id);

        // HKDF: one derived key per (academy, participant, version). The
        // counter is the message, not part of the key, so rotating the code
        // does not re-derive a key thirty times a minute.
        $key = hash_hkdf(
            'sha256',
            $this->pepper(),
            32,
            $participant->uuid . '|' . $version,
            $academyUuid
        );

        $mac = hash_hmac('sha256', $academyUuid . '|' . $participant->uuid . '|' . $version . '|' . $counter, $key, true);

        return $this->base32(substr($mac, 0, self::TAG_BYTES));
    }

    private function academyUuid(int $academyId): string
    {
        static $cache = [];

        return $cache[$academyId] ??= (string) Academy::whereKey($academyId)->value('uuid');
    }

    /** Crockford-ish base32: no padding, no characters a scanner confuses. */
    private function base32(string $bytes): string
    {
        $alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
        $bits = '';

        foreach (str_split($bytes) as $byte) {
            $bits .= str_pad(decbin(ord($byte)), 8, '0', STR_PAD_LEFT);
        }

        $out = '';

        foreach (str_split($bits, 5) as $chunk) {
            $out .= $alphabet[bindec(str_pad($chunk, 5, '0', STR_PAD_RIGHT))];
        }

        return $out;
    }
}
