<?php

namespace App\Models;

use App\Enums\Role;
use App\Notifications\QueuedResetPassword;
use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use PragmaRX\Google2FA\Exceptions\Google2FAException;
use PragmaRX\Google2FAQRCode\Google2FA;

#[Fillable(['name', 'email', 'password', 'must_change_password', 'locale', 'role'])]
#[Hidden(['password', 'remember_token', 'two_factor_secret', 'two_factor_recovery_codes'])]
class User extends Authenticatable
{
    /** @use HasFactory<UserFactory> */
    use HasFactory, Notifiable;

    protected function casts(): array
    {
        return [
            'email_verified_at' => 'datetime',
            'password' => 'hashed',
            'two_factor_secret' => 'encrypted',
            'two_factor_recovery_codes' => 'encrypted:array',
            'two_factor_confirmed_at' => 'datetime',
            'must_change_password' => 'boolean',
            'onboarding_tour_completed_at' => 'datetime',
            'role' => Role::class,
        ];
    }

    /** @return array<int, string> The 8 freshly generated one-time codes (also persisted). */
    public function replaceRecoveryCodes(): array
    {
        $codes = array_map(
            fn () => Str::random(10).'-'.Str::random(10),
            range(1, 8),
        );

        $this->forceFill(['two_factor_recovery_codes' => $codes])->save();

        return $codes;
    }

    /** @return array<int, string> */
    public function recoveryCodes(): array
    {
        return $this->two_factor_recovery_codes ?? [];
    }

    public function hasRecoveryCodes(): bool
    {
        return count($this->recoveryCodes()) > 0;
    }

    /**
     * Consume one recovery code (one-time). Returns true if it was valid + removed.
     * Atomic: the read/check/remove runs under a row lock so two concurrent requests
     * can never both spend the same code (replay).
     */
    public function useRecoveryCode(string $code): bool
    {
        $code = trim($code);

        return DB::transaction(function () use ($code) {
            $locked = static::whereKey($this->getKey())->lockForUpdate()->first();
            $codes = $locked?->recoveryCodes() ?? [];

            if (! in_array($code, $codes, true)) {
                return false;
            }

            $remaining = array_values(array_filter($codes, fn ($c) => $c !== $code));
            $locked->forceFill(['two_factor_recovery_codes' => $remaining])->save();

            // Keep the in-memory instance consistent for the rest of the request.
            $this->setRawAttributes($locked->getAttributes(), true);

            return true;
        });
    }

    /** TOTP factor only: an authenticator secret was confirmed. */
    public function hasTotp(): bool
    {
        return ! is_null($this->two_factor_confirmed_at) && ! is_null($this->two_factor_secret);
    }

    /**
     * Verify a TOTP code against the stored secret. Returns false (never throws) when the
     * user has no TOTP or the stored secret is malformed — callers then fall back to a
     * backup code.
     */
    public function verifyTotp(string $code): bool
    {
        if (! $this->hasTotp()) {
            return false;
        }

        try {
            // Replay protection: verifyKeyNewer rejects any code from a time-step <= the last
            // accepted one and returns the matched step on success. Persisting it makes each
            // 30s code single-use, so an intercepted/replayed code can't be used twice.
            //
            // Pass 0 (never null) as the old timestamp: google2fa returns the bare bool `true`
            // — not the matched step — when oldTimestamp is null, which would break the
            // comparison on the very first use. With 0 it always returns the integer step.
            $step = (new Google2FA)->verifyKeyNewer(
                $this->two_factor_secret,
                preg_replace('/\s+/', '', $code) ?? '',
                (int) ($this->two_factor_last_used_step ?? 0),
            );

            if ($step === false) {
                return false;
            }

            $this->forceFill(['two_factor_last_used_step' => $step])->save();

            return true;
        } catch (Google2FAException) {
            return false;
        }
    }

    /** 2FA is satisfied by EITHER factor — TOTP or a registered security key. */
    public function hasTwoFactorEnabled(): bool
    {
        return $this->hasTotp() || $this->hasWebauthnCredentials();
    }

    /**
     * Completed the security onboarding. 2FA is now OPTIONAL, so this is just the forced
     * password rotation — the gate EnsureSecurityOnboarded and the broadcast channels use.
     */
    public function securityOnboarded(): bool
    {
        return ! $this->must_change_password;
    }

    /** True when the account is a full administrator. */
    public function isAdmin(): bool
    {
        return $this->role === Role::Admin;
    }

    /** True when the account's role is at least $role in the admin>operator>viewer hierarchy. */
    public function roleAtLeast(Role $role): bool
    {
        return $this->role?->atLeast($role) ?? false;
    }

    /**
     * When neither factor remains (no TOTP, no keys), 2FA is fully off — so drop the backup
     * codes (nothing left to recover into). Call after removing any factor.
     */
    public function resetIfNoFactor(): void
    {
        if (! $this->hasTotp() && ! $this->hasWebauthnCredentials()) {
            $this->forceFill(['two_factor_recovery_codes' => null])->save();
        }
    }

    /**
     * Always queue the reset-password mail (never send synchronously), so the request that
     * triggers it returns in constant time whether or not the address exists — see
     * {@see QueuedResetPassword} and ForgotPassword::sendResetLink().
     */
    public function sendPasswordResetNotification(#[\SensitiveParameter] $token): void
    {
        $this->notify(new QueuedResetPassword($token));
    }

    public function webauthnCredentials(): HasMany
    {
        return $this->hasMany(WebauthnCredential::class);
    }

    public function hasWebauthnCredentials(): bool
    {
        return $this->webauthnCredentials()->exists();
    }
}
