<?php

declare(strict_types=1);

namespace App\Models;

use App\Enums\SignupError;
use App\Enums\UserRole;
use App\Jobs\SendAccountExpiredEmail;
use App\Jobs\SendAccountWillExpireEmail;
use App\Notifications\VerifyEmailBranded;
use App\Rules\ValidEmailDomain;
use App\Services\InvitationService;
use Carbon\CarbonImmutable;
use Illuminate\Auth\MustVerifyEmail as MustVerifyEmailTrait;
use Illuminate\Contracts\Auth\CanResetPassword as CanResetPasswordContract;
use Illuminate\Contracts\Auth\MustVerifyEmail as MustVerifyEmailContract;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Http\Request;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Arr;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Spatie\LaravelPasskeys\Models\Concerns\HasPasskeys;
use Spatie\LaravelPasskeys\Models\Concerns\InteractsWithPasskeys;
use Spatie\LaravelPasskeys\Models\Passkey;
use Spatie\Permission\Models\Role;
use Spatie\Permission\Traits\HasRoles;

/**
 * @property int $id
 * @property string $username
 * @property string|null $firstname
 * @property string|null $lastname
 * @property string $email
 * @property string $password
 * @property int $roles_id
 * @property string|null $host
 * @property int $grabs
 * @property string $api_token
 * @property Carbon|null $created_at
 * @property Carbon|null $updated_at
 * @property string|null $resetguid
 * @property Carbon|null $lastlogin
 * @property Carbon|null $apiaccess
 * @property Carbon|null $lastdownload
 * @property int $invites
 * @property int|null $invitedby
 * @property bool $movieview
 * @property bool $xxxview
 * @property bool $musicview
 * @property bool $consoleview
 * @property bool $bookview
 * @property bool $gameview
 * @property string|null $saburl
 * @property string|null $sabapikey
 * @property bool|null $sabapikeytype
 * @property bool|null $sabpriority
 * @property string|null $nzbgeturl
 * @property string|null $nzbgetusername
 * @property string|null $nzbgetpassword
 * @property string|null $nzbvortex_api_key
 * @property string|null $nzbvortex_server_url
 * @property string $notes
 * @property string|null $cp_url
 * @property string|null $cp_api
 * @property string|null $style
 * @property Carbon|null $rolechangedate
 * @property Carbon|null $pending_role_start_date
 * @property int|null $pending_roles_id
 * @property string|null $remember_token
 * @property string|null $session_token
 * @property int $rate_limit
 * @property Carbon|null $email_verified_at
 * @property bool $verified
 * @property string|null $verification_token
 * @property string|null $timezone
 * @property bool $can_post
 * @property array<int>|string|null $categoryexclusions Computed from join/subquery for category exclusion lists
 * @property string|null $sort_date Computed column for date sorting
 * @property string|null $role_name Computed from join with roles table
 * @property int|null $num Computed count value
 * @property string|null $mth Computed month value
 * @property-read bool $is_disabled Computed via Attribute accessor
 * @property bool|null $is_role_expired Computed via Attribute accessor
 * @property int|null $days_until_expiry Computed via Attribute accessor
 * @property int|null $count Computed count from aggregate queries
 * @property int|null $daily_api_count Computed daily API request count
 * @property int|null $daily_download_count Computed daily download count
 * @property-read Collection<int, ReleaseComment> $comments
 * @property-read Collection<int, UserDownload> $downloads
 * @property-read Collection<int, DnzbFailure> $failedReleases
 * @property-read Collection<int, Invitation> $invitations
 * @property-read Collection<int, UsersRelease> $releases
 * @property-read Collection<int, UserRequest> $requests
 * @property-read Collection<int, UserSerie> $series
 * @property-read Collection<int, RolePromotionStat> $promotionStats
 * @property-read Collection<int, UserRoleHistory> $roleHistory
 * @property-read Collection<int, Passkey> $passkeys
 * @property-read Role|null $role
 * @property-read Role|null $pendingRole
 * @property-read PasswordSecurity|null $passwordSecurity
 *
 * @method static Builder|User whereUsername(string $value)
 * @method static Builder|User whereEmail(string $value)
 * @method static Builder|User whereApiToken(string $value)
 * @method static Builder|User whereResetguid(string $value)
 * @method static Builder|User active()
 * @method static Builder|User verified()
 * @method static Builder|User withRole(int|string $role)
 * @method static Builder|User expiringSoon(int $days = 7)
 * @method static Builder|User expired()
 */
final class User extends Authenticatable implements CanResetPasswordContract, HasPasskeys, MustVerifyEmailContract
{
    use HasFactory; // @phpstan-ignore missingType.generics
    use HasRoles;
    use InteractsWithPasskeys;
    use MustVerifyEmailTrait;
    use Notifiable;
    use SoftDeletes;

    /**
     * @var list<string>
     */
    protected $hidden = [
        'remember_token',
        'session_token',
        'password',
    ];

    /**
     * @var list<string>
     */
    protected $guarded = [];

    /**
     * Roles excluded from promotions.
     *
     * @var list<string>
     */
    private const PROMOTION_EXCLUDED_ROLES = [
        'User',
        'Admin',
        'Moderator',
        'Disabled',
        'Friend',
    ];

    /**
     * Days in a year for subscription calculations.
     */
    private const DAYS_PER_YEAR = 365;

    /**
     * Get the attributes that should be cast.
     *
     * @return array<string, string>
     */
    protected function casts(): array
    {
        return [
            'email_verified_at' => 'datetime',
            'lastlogin' => 'datetime',
            'apiaccess' => 'datetime',
            'lastdownload' => 'datetime',
            'rolechangedate' => 'datetime',
            'pending_role_start_date' => 'datetime',
            'created_at' => 'datetime',
            'updated_at' => 'datetime',
            'password' => 'hashed',
            'movieview' => 'boolean',
            'xxxview' => 'boolean',
            'musicview' => 'boolean',
            'consoleview' => 'boolean',
            'bookview' => 'boolean',
            'gameview' => 'boolean',
            'sabapikeytype' => 'boolean',
            'sabpriority' => 'boolean',
            'verified' => 'boolean',
            'can_post' => 'boolean',
            'grabs' => 'integer',
            'invites' => 'integer',
            'rate_limit' => 'integer',
            'roles_id' => 'integer',
            'pending_roles_id' => 'integer',
            'invitedby' => 'integer',
        ];
    }

    public function getPasskeyName(): string
    {
        return $this->email ?: $this->username;
    }

    public function getPasskeyId(): string
    {
        return (string) $this->id;
    }

    public function getPasskeyDisplayName(): string
    {
        return $this->full_name ?: $this->username;
    }

    protected function getDefaultGuardName(): string
    {
        return 'web';
    }

    // ===== Relationships =====

    /**
     * @return BelongsTo<Role, $this>
     */
    public function role(): BelongsTo
    {
        return $this->belongsTo(Role::class, 'roles_id');
    }

    /**
     * @return BelongsTo<Role, $this>
     */
    public function pendingRole(): BelongsTo
    {
        return $this->belongsTo(Role::class, 'pending_roles_id');
    }

    /**
     * @return HasMany<UserRequest, $this>
     */
    public function requests(): HasMany
    {
        return $this->hasMany(UserRequest::class, 'users_id');
    }

    /**
     * @return HasMany<UserDownload, $this>
     */
    public function downloads(): HasMany
    {
        return $this->hasMany(UserDownload::class, 'users_id');
    }

    /**
     * @return HasMany<UsersRelease, $this>
     */
    public function releases(): HasMany
    {
        return $this->hasMany(UsersRelease::class, 'users_id');
    }

    /**
     * @return HasMany<UserSerie, $this>
     */
    public function series(): HasMany
    {
        return $this->hasMany(UserSerie::class, 'users_id');
    }

    /**
     * @return HasMany<Invitation, $this>
     */
    public function invitations(): HasMany
    {
        return $this->hasMany(Invitation::class, 'invited_by');
    }

    /**
     * @return HasMany<DnzbFailure, $this>
     */
    public function failedReleases(): HasMany
    {
        return $this->hasMany(DnzbFailure::class, 'users_id');
    }

    /**
     * @return HasMany<ReleaseComment, $this>
     */
    public function comments(): HasMany
    {
        return $this->hasMany(ReleaseComment::class, 'users_id');
    }

    /**
     * @return HasMany<RolePromotionStat, $this>
     */
    public function promotionStats(): HasMany
    {
        return $this->hasMany(RolePromotionStat::class);
    }

    /**
     * @return HasMany<UserRoleHistory, $this>
     */
    public function roleHistory(): HasMany
    {
        return $this->hasMany(UserRoleHistory::class);
    }

    /**
     * @return HasOne<PasswordSecurity, $this>
     */
    public function passwordSecurity(): HasOne
    {
        return $this->hasOne(PasswordSecurity::class);
    }

    /**
     * @return HasMany<UserExcludedCategory, $this>
     */
    public function excludedCategories(): HasMany
    {
        return $this->hasMany(UserExcludedCategory::class, 'users_id');
    }

    // ===== Backward Compatibility Aliases =====

    public function request(): HasMany // @phpstan-ignore missingType.generics
    {
        return $this->requests();
    }

    public function download(): HasMany // @phpstan-ignore missingType.generics
    {
        return $this->downloads();
    }

    public function release(): HasMany // @phpstan-ignore missingType.generics
    {
        return $this->releases();
    }

    public function invitation(): HasMany // @phpstan-ignore missingType.generics
    {
        return $this->invitations();
    }

    public function failedRelease(): HasMany // @phpstan-ignore missingType.generics
    {
        return $this->failedReleases();
    }

    public function comment(): HasMany // @phpstan-ignore missingType.generics
    {
        return $this->comments();
    }

    // ===== Query Scopes =====

    /**
     * Scope to get active (non-disabled) users.
     *
     * @param  Builder<User>  $query
     * @return Builder<User>
     */
    public function scopeActive(Builder $query): Builder
    {
        return $query->where('roles_id', '!=', UserRole::DISABLED->value);
    }

    /**
     * Scope to get verified users.
     *
     * @param  Builder<User>  $query
     * @return Builder<User>
     */
    public function scopeVerified(Builder $query): Builder
    {
        return $query->where(function (Builder $verifiedQuery): void {
            $verifiedQuery
                ->where('verified', true)
                ->orWhereNotNull('email_verified_at');
        });
    }

    /**
     * Determine if the user has verified their email address.
     */
    public function hasVerifiedEmail(): bool
    {
        return $this->verified || $this->email_verified_at !== null;
    }

    /**
     * Mark the user's email as verified and keep legacy columns in sync.
     */
    public function markEmailAsVerified(): bool
    {
        return $this->forceFill([
            'email_verified_at' => now(),
            'verified' => true,
            'verification_token' => null,
        ])->save();
    }

    /**
     * Reset email verification state, typically after an email change.
     */
    public function resetEmailVerification(): bool
    {
        return $this->forceFill([
            'email_verified_at' => null,
            'verified' => false,
            'verification_token' => null,
        ])->save();
    }

    /**
     * Send the branded email verification notification.
     *
     * Uses {@see VerifyEmailBranded} so the verification email renders with
     * the same themed Markdown components as the rest of the site's
     * transactional emails (instead of Laravel's default notification look).
     */
    public function sendEmailVerificationNotification(): void
    {
        $this->notify(new VerifyEmailBranded);
    }

    /**
     * Scope to filter users by role.
     *
     * @param  Builder<User>  $query
     * @return Builder<User>
     */
    public function scopeWithRole(Builder $query, int|string $role): Builder
    {
        if (is_numeric($role)) {
            return $query->where('roles_id', (int) $role);
        }

        return $query->whereHas('role', fn (Builder $q) => $q->where('name', $role));
    }

    /**
     * Scope to get users with roles expiring within specified days.
     *
     * @param  Builder<User>  $query
     * @return Builder<User>
     */
    public function scopeExpiringSoon(Builder $query, int $days = 7): Builder
    {
        return $query->whereNotNull('rolechangedate')
            ->whereBetween('rolechangedate', [now(), now()->addDays($days)]);
    }

    /**
     * Scope to get users with expired roles.
     *
     * @param  Builder<User>  $query
     * @return Builder<User>
     */
    public function scopeExpired(Builder $query): Builder
    {
        return $query->whereNotNull('rolechangedate')
            ->where('rolechangedate', '<', now());
    }

    /**
     * Scope to exclude sharing email.
     *
     * @param  Builder<User>  $query
     * @return Builder<User>
     */
    public function scopeExcludeSharing(Builder $query): Builder
    {
        return $query->where('email', '!=', 'sharing@nZEDb.com');
    }

    // ===== Attribute Accessors =====

    /**
     * Get the user's full name.
     */
    protected function fullName(): Attribute // @phpstan-ignore missingType.generics
    {
        return Attribute::make(
            get: fn (): string => trim("{$this->firstname} {$this->lastname}") ?: $this->username,
        );
    }

    /**
     * Get the user's timezone or default.
     */
    protected function effectiveTimezone(): Attribute // @phpstan-ignore missingType.generics
    {
        return Attribute::make(
            get: fn (): string => $this->timezone ?? 'UTC',
        );
    }

    /**
     * Check if user has admin privileges.
     */
    protected function isAdmin(): Attribute // @phpstan-ignore missingType.generics
    {
        return Attribute::make(
            get: fn (): bool => $this->roles_id === UserRole::ADMIN->value,
        );
    }

    /**
     * Check if user is disabled.
     */
    protected function isDisabled(): Attribute // @phpstan-ignore missingType.generics
    {
        return Attribute::make(
            get: fn (): bool => $this->roles_id === UserRole::DISABLED->value,
        );
    }

    /**
     * Check if role is expired.
     */
    protected function isRoleExpired(): Attribute // @phpstan-ignore missingType.generics
    {
        return Attribute::make(
            get: fn (): bool => $this->rolechangedate?->isPast() ?? false,
        );
    }

    /**
     * Get days until role expires.
     */
    protected function daysUntilExpiry(): Attribute // @phpstan-ignore missingType.generics
    {
        return Attribute::make(
            get: fn (): ?int => $this->rolechangedate
                ? (int) now()->diffInDays($this->rolechangedate, false)
                : null,
        );
    }

    /**
     * Get country code from IP address lookup.
     * Uses ip-api.com with caching to minimize API calls.
     */
    protected function countryCode(): Attribute // @phpstan-ignore missingType.generics
    {
        return Attribute::make(
            get: fn (): ?string => $this->getCountryFromIp()['countryCode'] ?? null,
        );
    }

    /**
     * Get country name from IP address lookup.
     * Uses ip-api.com with caching to minimize API calls.
     */
    protected function countryName(): Attribute // @phpstan-ignore missingType.generics
    {
        return Attribute::make(
            get: fn (): ?string => $this->getCountryFromIp()['country'] ?? null,
        );
    }

    /**
     * Get country flag emoji from country code.
     * Converts 2-letter country code to Unicode regional indicator symbols.
     */
    protected function countryFlag(): Attribute // @phpstan-ignore missingType.generics
    {
        return Attribute::make(
            get: function (): ?string {
                $countryCode = $this->getCountryFromIp()['countryCode'] ?? null;

                if (empty($countryCode) || strlen($countryCode) !== 2) {
                    return null;
                }

                // Convert country code to regional indicator symbols (flag emoji)
                // Each letter is converted to its regional indicator equivalent
                // A = 🇦 (U+1F1E6), B = 🇧 (U+1F1E7), etc.
                $countryCode = strtoupper($countryCode);
                $flag = '';

                for ($i = 0; $i < 2; $i++) {
                    $char = ord($countryCode[$i]) - ord('A') + 0x1F1E6;
                    $flag .= mb_chr($char, 'UTF-8');
                }

                return $flag;
            },
        );
    }

    /**
     * Lookup country information from IP address using ip-api.com.
     *
     * @return array{country: string|null, countryCode: string|null}
     */
    protected function getCountryFromIp(): array
    {
        $ip = $this->host;

        if (empty($ip)) {
            return ['country' => null, 'countryCode' => null];
        }

        // Skip private/local IPs
        if (! filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
            return ['country' => null, 'countryCode' => null];
        }

        $cacheKey = 'ip_country_lookup_'.md5((string) $ip);

        return Cache::remember($cacheKey, 86400, function () use ($ip) {
            return $this->fetchCountryFromApi($ip);
        });
    }

    /**
     * Fetch country info from ip-api.com.
     *
     * @return array{country: string|null, countryCode: string|null}
     */
    protected function fetchCountryFromApi(string $ip): array
    {
        try {
            $response = Http::timeout(3)
                ->retry(2, 100)
                ->get("http://ip-api.com/json/{$ip}", [
                    'fields' => 'status,country,countryCode',
                ]);

            if (! $response->successful()) {
                Log::debug('Country lookup failed', ['ip' => $ip, 'status' => $response->status()]);

                return ['country' => null, 'countryCode' => null];
            }

            $data = $response->json();

            if (($data['status'] ?? '') !== 'success') {
                return ['country' => null, 'countryCode' => null];
            }

            return [
                'country' => $data['country'] ?? null,
                'countryCode' => $data['countryCode'] ?? null,
            ];
        } catch (\Exception $e) {
            Log::debug('Country lookup exception', ['ip' => $ip, 'error' => $e->getMessage()]);

            return ['country' => null, 'countryCode' => null];
        }
    }

    // ===== Pending Role Methods =====

    /**
     * Check if user has a pending stacked role.
     */
    public function hasPendingRole(): bool
    {
        return $this->pending_roles_id !== null && $this->pending_role_start_date !== null;
    }

    /**
     * Get the pending role.
     */
    public function getPendingRole(): ?Role
    {
        if (! $this->hasPendingRole()) {
            return null;
        }

        return $this->pendingRole;
    }

    /**
     * Cancel a pending stacked role.
     */
    public function cancelPendingRole(): bool
    {
        if (! $this->hasPendingRole()) {
            return false;
        }

        return $this->update([
            'pending_roles_id' => null,
            'pending_role_start_date' => null,
        ]);
    }

    /**
     * Get all pending stacked role changes for this user.
     * Returns an array of stacked role changes that haven't been activated yet (effective_date is in the future).
     *
     * @return \Illuminate\Support\Collection<int, array{
     *     role: Role|null,
     *     role_name: string,
     *     start_date: string,
     *     end_date: string|null,
     *     is_current_pending: bool
     * }>
     */
    public function getAllPendingStackedRoles(): \Illuminate\Support\Collection
    {
        // Get all stacked role changes from history where effective_date is in the future
        $stackedHistory = UserRoleHistory::where('user_id', $this->id)
            ->where('is_stacked', true)
            ->where('effective_date', '>', now())
            ->orderBy('effective_date', 'asc')
            ->get();

        return $stackedHistory->map(function ($history) { // @phpstan-ignore return.type
            $role = Role::find($history->new_role_id);

            return [
                'role' => $role,
                'role_name' => $role?->name ?? 'Unknown Role', // @phpstan-ignore nullsafe.neverNull
                'start_date' => $history->effective_date,
                'end_date' => $history->new_expiry_date,
                'is_current_pending' => $this->pending_roles_id === $history->new_role_id
                    && $this->pending_role_start_date?->equalTo($history->effective_date),
            ];
        });
    }

    /**
     * Get comprehensive role expiry information.
     *
     * @return array{
     *     current_role: Role|null,
     *     current_expiry: Carbon|null,
     *     has_expiry: bool,
     *     is_expired: bool,
     *     days_until_expiry: int|null,
     *     pending_role: Role|null,
     *     pending_start: Carbon|null,
     *     has_pending_role: bool
     * }
     */
    public function getRoleExpiryInfo(): array
    {
        return [
            'current_role' => $this->role,
            'current_expiry' => $this->rolechangedate,
            'has_expiry' => $this->rolechangedate !== null,
            'is_expired' => $this->is_role_expired,
            'days_until_expiry' => $this->days_until_expiry,
            'pending_role' => $this->getPendingRole(),
            'pending_start' => $this->pending_role_start_date,
            'has_pending_role' => $this->hasPendingRole(),
        ];
    }

    // ===== Static Query Methods =====

    /**
     * Get count of users matching filters.
     */
    public static function getCount(
        ?string $role = null,
        ?string $username = '',
        ?string $host = '',
        ?string $email = '',
        ?string $createdFrom = '',
        ?string $createdTo = '',
        ?string $verified = '',
    ): int {
        return self::adminListBaseQuery([
            'role' => (string) $role,
            'username' => (string) $username,
            'host' => (string) $host,
            'email' => (string) $email,
            'created_from' => (string) $createdFrom,
            'created_to' => (string) $createdTo,
            'verified' => (string) $verified,
        ])
            ->count();
    }

    /**
     * Find user by username.
     */
    public static function findByUsername(string $username): ?static
    {
        return self::whereUsername($username)->first();
    }

    /**
     * Find user by email.
     */
    public static function findByEmail(string $email): ?static
    {
        return static::whereEmail($email)->first();
    }

    /**
     * Find user by RSS token.
     */
    public static function findByRssToken(string $token): ?static
    {
        return static::whereApiToken($token)->first();
    }

    /**
     * Build a query for a verified user resolved by API/RSS token.
     */
    public static function verifiedApiTokenQuery(string $token): Builder // @phpstan-ignore missingType.generics
    {
        return static::query()
            ->verified()
            ->whereApiToken($token);
    }

    /**
     * Find a verified user by API/RSS token.
     */
    public static function findVerifiedByApiToken(string $token): ?static
    {
        return static::verifiedApiTokenQuery($token)->first();
    }

    /**
     * Find user by password reset GUID.
     */
    public static function findByResetGuid(string $guid): ?static
    {
        if (static::whereApiToken($guid)->exists()) {
            return null;
        }

        return static::whereResetguid($guid)
            ->first();
    }

    // ===== User Management Methods =====

    /**
     * Delete user by ID.
     *
     * @throws \Exception
     */
    public static function deleteUser(int $id): void
    {
        static::findOrFail($id)->delete();
    }

    /**
     * Update user details.
     */
    public static function updateUser(
        int $id,
        string $userName,
        ?string $email,
        int $grabs,
        int $role,
        ?string $notes,
        int $invites,
        int $movieview,
        int $musicview,
        int $gameview,
        int $xxxview,
        int $consoleview,
        int $bookview,
        string $style = 'None',
    ): int {
        $user = static::findOrFail($id);
        $roleModel = Role::find($role);

        $user->update([
            'username' => trim($userName),
            'grabs' => $grabs,
            'roles_id' => $role,
            'notes' => substr($notes ?? '', 0, 255),
            'invites' => $invites,
            'movieview' => $movieview,
            'musicview' => $musicview,
            'gameview' => $gameview,
            'xxxview' => $xxxview,
            'consoleview' => $consoleview,
            'bookview' => $bookview,
            'style' => $style,
            'rate_limit' => $roleModel?->rate_limit ?? 60, // @phpstan-ignore nullsafe.neverNull
            ...($email ? ['email' => trim($email)] : []),
        ]);

        if ($roleModel) {
            $user->syncRoles([$roleModel->name]);
        }

        return SignupError::SUCCESS->value;
    }

    /**
     * Update user role with optional stacking and promotions.
     */
    public static function updateUserRole(
        int $uid,
        int|string $role,
        bool $applyPromotions = true,
        bool $stackRole = true,
        ?int $changedBy = null,
        ?string $originalExpiryBeforeEdits = null,
        bool $preserveCurrentExpiry = false,
        ?int $addYears = null,
    ): bool {
        Log::info('updateUserRole called', [
            'uid' => $uid,
            'role' => $role,
            'applyPromotions' => $applyPromotions,
            'stackRole' => $stackRole,
            'changedBy' => $changedBy,
            'originalExpiryBeforeEdits' => $originalExpiryBeforeEdits,
            'preserveCurrentExpiry' => $preserveCurrentExpiry,
            'addYears' => $addYears,
        ]);

        $roleModel = is_numeric($role)
            ? Role::find((int) $role)
            : Role::where('name', $role)->first();

        if (! $roleModel) {
            Log::error('Role not found', ['role' => $role]);

            return false;
        }

        $user = static::find($uid);
        if (! $user) {
            Log::error('User not found', ['uid' => $uid]);

            return false;
        }

        $currentRoleId = $user->roles_id;
        $oldExpiryDate = $originalExpiryBeforeEdits
            ? Carbon::parse($originalExpiryBeforeEdits)
            : $user->rolechangedate;
        $currentExpiryDate = $user->rolechangedate;

        Log::info('updateUserRole - expiry date values', [
            'originalExpiryBeforeEdits_raw' => $originalExpiryBeforeEdits,
            'user_rolechangedate_raw' => $user->rolechangedate,
            'oldExpiryDate' => $oldExpiryDate?->toDateTimeString(),
            'currentExpiryDate' => $currentExpiryDate?->toDateTimeString(),
            'currentRoleId' => $currentRoleId,
            'newRoleId' => $roleModel->id,
        ]);

        // No role change needed
        if ($currentRoleId === $roleModel->id) {
            return self::handleSameRoleUpdate(
                $user,
                $roleModel,
                $applyPromotions,
                $addYears,
                $currentExpiryDate
            );
        }

        // Determine if we should stack
        $shouldStack = $stackRole && $currentExpiryDate?->isFuture();

        if ($shouldStack) {
            return self::handleStackedRoleChange(
                $user,
                $roleModel,
                $currentRoleId,
                $oldExpiryDate,
                $currentExpiryDate,
                $applyPromotions,
                $addYears,
                $changedBy
            );
        }

        return self::handleImmediateRoleChange(
            $user,
            $roleModel,
            $currentRoleId,
            $oldExpiryDate,
            $currentExpiryDate,
            $applyPromotions,
            $addYears,
            $changedBy,
            $preserveCurrentExpiry
        );
    }

    /**
     * Handle role update when role is not changing.
     */
    private static function handleSameRoleUpdate(
        self $user,
        Role $roleModel,
        bool $applyPromotions,
        ?int $addYears,
        ?Carbon $currentExpiryDate,
    ): bool {
        if (in_array($roleModel->name, self::PROMOTION_EXCLUDED_ROLES, true)) {
            return true;
        }

        $additionalDays = self::resolveRoleDurationDays($roleModel, $addYears);
        $promotionDays = $applyPromotions
            ? RolePromotion::calculateAdditionalDays($roleModel->id)
            : 0;
        $totalDays = $additionalDays + $promotionDays;

        if ($totalDays <= 0) {
            return true;
        }

        $newExpiryDate = $currentExpiryDate?->isFuture()
            ? $currentExpiryDate->copy()->addDays($totalDays)
            : now()->addDays($totalDays);

        $updated = $user->update(['rolechangedate' => $newExpiryDate]);

        if ($updated && $promotionDays > 0) {
            self::trackPromotionApplication($user, $roleModel->id, $currentExpiryDate, $newExpiryDate);
        }

        return $updated;
    }

    /**
     * Handle stacked role change.
     */
    private static function handleStackedRoleChange(
        self $user,
        Role $roleModel,
        int $currentRoleId,
        ?Carbon $oldExpiryDate,
        Carbon $currentExpiryDate,
        bool $applyPromotions,
        ?int $addYears,
        ?int $changedBy,
    ): bool {
        // Determine the stacking start date
        // If user already has a pending stacked role, we need to calculate when THAT role would expire
        // and use that as the starting point for this new stacked role

        $stackingStartDate = $currentExpiryDate;

        // Check if there's already a pending role - if so, calculate when it would expire
        if ($user->hasPendingRole()) {
            $pendingRole = $user->getPendingRole();
            $pendingStartDate = $user->pending_role_start_date;

            if ($pendingRole && $pendingStartDate) {
                // Calculate when the pending role would expire
                $pendingRoleBaseDays = self::resolveRoleDurationDays($pendingRole, null);
                $pendingRolePromotionDays = ! in_array($pendingRole->name, self::PROMOTION_EXCLUDED_ROLES, true)
                    ? RolePromotion::calculateAdditionalDays($pendingRole->id)
                    : 0;
                $pendingRoleTotalDays = $pendingRoleBaseDays + $pendingRolePromotionDays;
                $pendingRoleExpiryDate = Carbon::parse($pendingStartDate)->addDays($pendingRoleTotalDays);

                Log::info('Existing pending role detected - calculating new stacking start date', [
                    'pendingRoleId' => $pendingRole->id,
                    'pendingRoleName' => $pendingRole->name,
                    'pendingStartDate' => $pendingStartDate->toDateTimeString(),
                    'pendingRoleBaseDays' => $pendingRoleBaseDays,
                    'pendingRolePromotionDays' => $pendingRolePromotionDays,
                    'pendingRoleExpiryDate' => $pendingRoleExpiryDate->toDateTimeString(),
                ]);

                // Use the pending role's expiry date as the new stacking start date
                if ($pendingRoleExpiryDate->isFuture()) {
                    $stackingStartDate = $pendingRoleExpiryDate;
                }
            }
        } else {
            // No pending role - use the latest of oldExpiryDate and currentExpiryDate
            Log::info('No pending role - comparing old and current expiry dates', [
                'oldExpiryDate' => $oldExpiryDate?->toDateTimeString(),
                'currentExpiryDate' => $currentExpiryDate->toDateTimeString(),
            ]);

            // Use the greater of the two dates if old expiry exists and is in the future
            if ($oldExpiryDate !== null && $oldExpiryDate->isFuture() && $oldExpiryDate->gt($currentExpiryDate)) {
                $stackingStartDate = $oldExpiryDate;
            }
        }

        Log::info('Final stacking start date selected', [
            'stackingStartDate' => $stackingStartDate->toDateTimeString(),
            'hadPendingRole' => $user->hasPendingRole(),
        ]);

        $baseDays = self::resolveRoleDurationDays($roleModel, $addYears);
        $promotionDays = ! in_array($roleModel->name, self::PROMOTION_EXCLUDED_ROLES, true) && $applyPromotions
            ? RolePromotion::calculateAdditionalDays($roleModel->id)
            : 0;
        $totalDays = $baseDays + $promotionDays;
        $newExpiryDate = $stackingStartDate->copy()->addDays($totalDays);

        $user->update([
            'pending_roles_id' => $roleModel->id,
            'pending_role_start_date' => $stackingStartDate,
        ]);

        try {
            UserRoleHistory::recordRoleChange(
                userId: $user->id,
                oldRoleId: $currentRoleId,
                newRoleId: $roleModel->id,
                oldExpiryDate: $stackingStartDate,
                newExpiryDate: $newExpiryDate,
                effectiveDate: $stackingStartDate,
                isStacked: true,
                changeReason: 'stacked_role_change',
                changedBy: $changedBy
            );
        } catch (\Exception $e) {
            Log::error('Failed to record role history', ['error' => $e->getMessage()]);
        }

        return true;
    }

    /**
     * Handle immediate role change.
     */
    private static function handleImmediateRoleChange(
        self $user,
        Role $roleModel,
        int $currentRoleId,
        ?Carbon $oldExpiryDate,
        ?Carbon $currentExpiryDate,
        bool $applyPromotions,
        ?int $addYears,
        ?int $changedBy,
        bool $preserveCurrentExpiry,
    ): bool {
        $baseDays = self::resolveRoleDurationDays($roleModel, $addYears);
        $promotionDays = ! in_array($roleModel->name, self::PROMOTION_EXCLUDED_ROLES, true) && $applyPromotions
            ? RolePromotion::calculateAdditionalDays($roleModel->id)
            : 0;
        $totalDays = $baseDays + $promotionDays;

        $newExpiryDate = match (true) {
            $preserveCurrentExpiry && $currentExpiryDate !== null => $currentExpiryDate,
            $totalDays > 0 => now()->addDays($totalDays),
            default => null,
        };

        $updated = $user->update([
            'roles_id' => $roleModel->id,
            'rolechangedate' => $newExpiryDate,
        ]);

        $user->syncRoles([$roleModel->name]);

        if ($updated) {
            try {
                UserRoleHistory::recordRoleChange(
                    userId: $user->id,
                    oldRoleId: $currentRoleId,
                    newRoleId: $roleModel->id,
                    oldExpiryDate: $oldExpiryDate,
                    newExpiryDate: $newExpiryDate,
                    effectiveDate: now(),
                    isStacked: false,
                    changeReason: 'immediate_role_change',
                    changedBy: $changedBy
                );
            } catch (\Exception $e) {
                Log::error('Failed to record role history', ['error' => $e->getMessage()]);
            }

            if ($applyPromotions && $promotionDays > 0) {
                self::trackPromotionApplication($user, $roleModel->id, $oldExpiryDate, $newExpiryDate);
            }
        }

        return $updated;
    }

    /**
     * Resolve the number of role-duration days to apply.
     */
    private static function resolveRoleDurationDays(Role $roleModel, ?int $addYears, bool $useRoleDefaultWhenMissing = true): int
    {
        if ($addYears !== null) {
            return max(0, $addYears) * self::DAYS_PER_YEAR;
        }

        if (! $useRoleDefaultWhenMissing) {
            return 0;
        }

        $rawAddYears = Role::query()->whereKey($roleModel->id)->value('addyears');
        $roleAddYears = is_numeric($rawAddYears)
            ? (int) $rawAddYears
            : (is_numeric($roleModel->addyears ?? null) ? (int) $roleModel->addyears : 0);

        return max(0, $roleAddYears) * self::DAYS_PER_YEAR;
    }

    /**
     * Track promotion application statistics.
     */
    private static function trackPromotionApplication(
        self $user,
        int $roleId,
        ?Carbon $oldExpiryDate,
        ?Carbon $newExpiryDate,
    ): void {
        $promotions = RolePromotion::getActivePromotions($roleId);
        foreach ($promotions as $promotion) {
            $promotion->trackApplication($user->id, $roleId, $oldExpiryDate, $newExpiryDate);
        }
    }

    /**
     * Process expired roles and send notifications.
     */
    public static function updateExpiredRoles(): void
    {
        $now = CarbonImmutable::now();

        // Send expiration warnings
        self::sendExpirationWarnings($now);

        // Process expired roles
        self::processExpiredRoles($now);
    }

    /**
     * Send expiration warning emails.
     */
    private static function sendExpirationWarnings(CarbonImmutable $now): void
    {
        $periods = [
            'day' => $now->addDay(),
            'week' => $now->addWeek(),
            'month' => $now->addMonth(),
        ];

        foreach ($periods as $period) {
            $users = static::whereDate('rolechangedate', '=', $period)->get();
            $days = $now->diffInDays($period, true);

            foreach ($users as $user) {
                SendAccountWillExpireEmail::dispatch($user, $days)->onQueue('emails'); // @phpstan-ignore argument.type
            }
        }
    }

    /**
     * Process users with expired roles.
     */
    private static function processExpiredRoles(CarbonImmutable $now): void
    {
        static::expired()->each(function (self $user) use ($now) {
            $oldRoleId = $user->roles_id;
            $oldExpiryDate = $user->rolechangedate;

            // Check for pending stacked role
            if ($user->hasPendingRole() && $user->pending_role_start_date?->lte($now)) {
                self::activatePendingRole($user, $oldRoleId, $oldExpiryDate, $now);

                return;
            }

            // Downgrade to default user role
            self::downgradeToDefaultRole($user, $oldRoleId, $oldExpiryDate, $now);
        });
    }

    /**
     * Activate a pending stacked role.
     */
    private static function activatePendingRole(
        self $user,
        int $oldRoleId,
        ?Carbon $oldExpiryDate,
        CarbonImmutable $now,
    ): void {
        $roleModel = Role::find($user->pending_roles_id);
        if (! $roleModel) {
            return;
        }

        $baseDays = $roleModel->addyears * self::DAYS_PER_YEAR;
        $promotionDays = RolePromotion::calculateAdditionalDays($roleModel->id);
        $totalDays = $baseDays + $promotionDays;
        $newExpiryDate = $totalDays > 0 ? $now->addDays($totalDays) : null;

        $user->update([
            'roles_id' => $roleModel->id,
            'rolechangedate' => $newExpiryDate,
            'pending_roles_id' => null,
            'pending_role_start_date' => null,
        ]);
        $user->syncRoles([$roleModel->name]);

        UserRoleHistory::recordRoleChange(
            userId: $user->id,
            oldRoleId: $oldRoleId,
            newRoleId: $roleModel->id,
            oldExpiryDate: $oldExpiryDate,
            newExpiryDate: $newExpiryDate,
            effectiveDate: Carbon::instance($now),
            isStacked: true,
            changeReason: 'stacked_role_activated',
            changedBy: null
        );
    }

    /**
     * Downgrade user to default role.
     */
    private static function downgradeToDefaultRole(
        self $user,
        int $oldRoleId,
        ?Carbon $oldExpiryDate,
        CarbonImmutable $now,
    ): void {
        $user->update([
            'roles_id' => UserRole::USER->value,
            'rolechangedate' => null,
            'pending_roles_id' => null,
            'pending_role_start_date' => null,
        ]);
        $user->syncRoles(['User']);

        UserRoleHistory::recordRoleChange(
            userId: $user->id,
            oldRoleId: $oldRoleId,
            newRoleId: UserRole::USER->value,
            oldExpiryDate: $oldExpiryDate,
            newExpiryDate: null,
            effectiveDate: Carbon::instance($now),
            isStacked: false,
            changeReason: 'role_expired',
            changedBy: null
        );

        SendAccountExpiredEmail::dispatch($user)->onQueue('emails');
    }

    /**
     * Get paginated user list with filters.
     *
     * @return Collection<int, static>
     */
    public static function getRange(
        int|false $start,
        int $offset,
        string $orderBy,
        ?string $userName = '',
        ?string $email = '',
        ?string $host = '',
        ?string $role = '',
        ?string $createdFrom = '',
        ?string $createdTo = '',
        ?string $verified = '',
    ): Collection {
        $order = self::getBrowseOrder($orderBy);

        return self::adminListBaseQuery([
            'username' => (string) $userName,
            'email' => (string) $email,
            'host' => (string) $host,
            'role' => (string) $role,
            'created_from' => (string) $createdFrom,
            'created_to' => (string) $createdTo,
            'verified' => (string) $verified,
        ])
            ->withCount([
                'requests as daily_api_count' => fn (Builder $q) => $q->where('timestamp', '>', now()->subDay()),
                'downloads as daily_download_count' => fn (Builder $q) => $q->where('timestamp', '>', now()->subDay()),
            ])
            ->with('roles:id,name')
            ->orderBy($order[0], $order[1])
            ->when($start !== false, fn (Builder $q) => $q->offset($start)->limit($offset))
            ->get();
    }

    /**
     * @param  array{username?: string, email?: string, host?: string, role?: string, verified?: string, created_from?: string, created_to?: string}  $filters
     * @return LengthAwarePaginator<int, User>
     */
    public static function adminListPaginator(array $filters, string $orderBy, int $perPage): LengthAwarePaginator
    {
        $order = self::getBrowseOrder($orderBy);

        return self::adminListBaseQuery($filters)
            ->withCount([
                'requests as daily_api_count' => fn (Builder $q) => $q->where('timestamp', '>', now()->subDay()),
                'downloads as daily_download_count' => fn (Builder $q) => $q->where('timestamp', '>', now()->subDay()),
            ])
            ->with('roles:id,name')
            ->orderBy($order[0], $order[1])
            ->paginate($perPage);
    }

    /**
     * @param  array{username?: string, email?: string, host?: string, role?: string, verified?: string, created_from?: string, created_to?: string}  $filters
     * @return Builder<User>
     */
    private static function adminListBaseQuery(array $filters): Builder
    {
        $username = $filters['username'] ?? '';
        $email = $filters['email'] ?? '';
        $host = $filters['host'] ?? '';
        $role = $filters['role'] ?? '';
        $verified = $filters['verified'] ?? '';
        $createdFrom = $filters['created_from'] ?? '';
        $createdTo = $filters['created_to'] ?? '';

        return self::query()
            ->withTrashed()
            ->select('users.*')
            ->selectSub(
                'SELECT name FROM roles WHERE roles.id = users.roles_id',
                'rolename'
            )
            ->excludeSharing()
            ->when($username, fn (Builder $q) => $q->where('username', 'like', "%{$username}%"))
            ->when($email, fn (Builder $q) => $q->where('email', 'like', "%{$email}%"))
            ->when($host, fn (Builder $q) => $q->where('host', 'like', "%{$host}%"))
            ->when($role, fn (Builder $q) => $q->where('roles_id', $role))
            ->when($createdFrom, fn (Builder $q) => $q->where('created_at', '>=', "{$createdFrom} 00:00:00"))
            ->when($createdTo, fn (Builder $q) => $q->where('created_at', '<=', "{$createdTo} 23:59:59"))
            ->when($verified === '1', fn (Builder $q) => $q->where(function (Builder $verifiedQuery): void {
                $verifiedQuery->where('verified', true)
                    ->orWhereNotNull('email_verified_at');
            }))
            ->when($verified === '0', fn (Builder $q) => $q->where('verified', false)->whereNull('email_verified_at'));
    }

    /**
     * Get sort configuration for user browsing.
     *
     * @return array{0: string, 1: string}
     */
    public static function getBrowseOrder(string $orderBy = ''): array
    {
        $order = $orderBy ?: 'username_desc';
        $parts = explode('_', $order);

        $field = match ($parts[0]) {
            'email' => 'email',
            'host' => 'host',
            'createdat' => 'created_at',
            'lastlogin' => 'lastlogin',
            'apiaccess' => 'apiaccess',
            'apirequests' => 'daily_api_count',
            'grabs' => 'grabs',
            'role' => 'rolename',
            'rolechangedate' => 'rolechangedate',
            'verification' => 'verified',
            default => 'username',
        };

        $direction = isset($parts[1]) && preg_match('/^asc|desc$/i', $parts[1])
            ? $parts[1]
            : 'desc';

        return [$field, $direction];
    }

    // ===== Password Methods =====

    /**
     * Verify password and rehash if needed.
     */
    public static function checkPassword(string $password, string $hash, int $userId = -1): bool
    {
        if (! Hash::check($password, $hash)) {
            return false;
        }

        if ($userId > 0 && Hash::needsRehash($hash)) {
            static::find($userId)?->update(['password' => Hash::make($password)]);
        }

        return true;
    }

    /**
     * Hash a password.
     */
    public static function hashPassword(string $password): string
    {
        return Hash::make($password);
    }

    /**
     * Generate a secure random password.
     *
     * @throws \Exception
     */
    public static function generatePassword(int $length = 15): string
    {
        return Str::password($length);
    }

    // ===== Update Methods =====

    /**
     * Regenerate RSS key.
     */
    public static function updateRssKey(int $uid): int
    {
        static::find($uid)?->update([
            'api_token' => md5(Str::random(60)),
        ]);

        return SignupError::SUCCESS->value;
    }

    /**
     * Update password reset GUID.
     */
    public static function updatePassResetGuid(int $id, ?string $guid): int
    {
        static::find($id)?->update(['resetguid' => $guid]);

        return SignupError::SUCCESS->value;
    }

    /**
     * Update user password.
     */
    public static function updatePassword(int $id, string $password): int
    {
        static::find($id)?->update(['password' => Hash::make($password)]);

        return SignupError::SUCCESS->value;
    }

    /**
     * Update user role change date.
     */
    public static function updateUserRoleChangeDate(int $id, string $roleChangeDate): int
    {
        static::find($id)?->update(['rolechangedate' => $roleChangeDate]);

        return SignupError::SUCCESS->value;
    }

    /**
     * Increment user's grab count.
     */
    public static function incrementGrabs(int $id, int $num = 1): void
    {
        static::query()
            ->whereKey($id)
            ->increment('grabs', $num, ['lastdownload' => now()]);
    }

    // ===== Validation Methods =====

    /**
     * Validate a URL format.
     */
    public static function isValidUrl(string $url): bool
    {
        return (bool) preg_match(
            '/^(https?|ftp):\/\/([A-Z0-9][A-Z0-9_-]*(?:\.[A-Z0-9][A-Z0-9_-]*)+):?(\d+)?\/?/i',
            $url
        );
    }

    // ===== Registration Methods =====

    /**
     * Register a new user.
     *
     * @throws \Exception
     */
    public static function signUp(
        string $userName,
        string $password,
        string $email,
        string $host,
        ?string $notes,
        int $invites = Invitation::DEFAULT_INVITES,
        string $inviteCode = '',
        bool $forceInviteMode = false,
        int $role = UserRole::USER->value,
        bool $validate = true,
    ): int|string {
        $userData = [
            'username' => trim($userName),
            'password' => trim($password),
            'email' => trim($email),
        ];

        if ($validate) {
            $validator = Validator::make($userData, [
                'username' => ['required', 'string', 'min:5', 'max:255', 'unique:users'],
                'email' => ['required', 'string', 'email', 'max:255', 'unique:users', new ValidEmailDomain],
                'password' => [
                    'required',
                    'string',
                    'min:8',
                    'confirmed',
                    'regex:/^(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9])(?=.*?[#?!@$%^&*-]).{8,}$/',
                ],
            ]);

            if ($validator->fails()) {
                return implode('', Arr::collapse($validator->errors()->toArray()));
            }
        }

        $invitedBy = 0;
        if (! $forceInviteMode && (int) Settings::settingValue('registerstatus') === Settings::REGISTER_STATUS_INVITE) {
            if ($inviteCode === '') {
                return SignupError::BAD_INVITE_CODE->value;
            }

            $invitedBy = self::checkAndUseInvite($inviteCode);
            if ($invitedBy < 0) {
                return SignupError::BAD_INVITE_CODE->value;
            }
        }

        return self::add(
            $userData['username'],
            $userData['password'],
            $userData['email'],
            $role,
            $notes,
            $host,
            $invites,
            $invitedBy
        );
    }

    /**
     * Validate and consume an invite code.
     */
    public static function checkAndUseInvite(string $inviteCode): int
    {
        $invite = Invitation::findValidByToken($inviteCode);
        if (! $invite) {
            return -1;
        }

        static::where('id', $invite->invited_by)->decrement('invites');
        $invite->markAsUsed(0);

        return $invite->invited_by;
    }

    /**
     * Create a new user.
     */
    public static function add(
        string $userName,
        string $password,
        string $email,
        int $role,
        ?string $notes = '',
        string $host = '',
        int $invites = Invitation::DEFAULT_INVITES,
        int $invitedBy = 0,
    ): int {
        $hashedPassword = Hash::make($password);

        $storeIps = config('nntmux:settings.store_user_ip') === true ? $host : '';

        $user = static::create([
            'username' => $userName,
            'password' => $hashedPassword,
            'email' => $email,
            'host' => $storeIps,
            'roles_id' => $role,
            'invites' => $invites,
            'invitedby' => $invitedBy === 0 ? null : $invitedBy,
            'notes' => $notes,
        ]);

        return $user->id;
    }

    // ===== Category Exclusion Methods =====

    /**
     * Get excluded category IDs for a user.
     *
     * This includes both permission-based exclusions (entire root categories)
     * and user-selected subcategory exclusions from user_excluded_categories table.
     *
     * @return list<int>
     *
     * @throws \Exception
     */
    public static function getCachedCategoryExclusionById(int $userId): array
    {
        return Cache::remember(
            static::categoryExclusionCacheKey($userId),
            300,
            static fn (): array => static::getCategoryExclusionById($userId)
        );
    }

    public static function categoryExclusionCacheKey(int $userId): string
    {
        return 'user_category_exclusions_'.$userId;
    }

    /**
     * @return list<int>
     */
    public static function getCategoryExclusionById(int $userId): array
    {
        $user = static::findOrFail($userId);

        $userAllowed = $user->getDirectPermissions()->pluck('name')->toArray();
        $roleAllowed = $user->getAllPermissions()->pluck('name')->toArray();
        $allowed = array_intersect($roleAllowed, $userAllowed);

        $categoryPermissions = [
            'view console' => 1000,
            'view movies' => 2000,
            'view audio' => 3000,
            'view pc' => 4000,
            'view tv' => 5000,
            'view adult' => 6000,
            'view books' => 7000,
            'view other' => 1,
        ];

        $excludedRoots = [];
        foreach ($categoryPermissions as $permission => $rootId) {
            if (! in_array($permission, $allowed, true)) {
                $excludedRoots[] = $rootId;
            }
        }

        // Get all subcategories that belong to excluded root categories
        $permissionExclusions = Category::whereIn('root_categories_id', $excludedRoots)
            ->pluck('id')
            ->toArray();

        // Get user's custom excluded subcategories from pivot table
        $userExclusions = $user->excludedCategories()
            ->pluck('categories_id')
            ->toArray();

        $filteredUserExclusions = $userExclusions === []
            ? []
            : Category::query()
                ->whereIn('id', $userExclusions)
                ->when($excludedRoots !== [], fn (Builder $query): Builder => $query->whereNotIn('root_categories_id', $excludedRoots))
                ->pluck('id')
                ->toArray();

        // Merge permission-based exclusions with user-selected subcategory exclusions
        return array_values(array_unique(array_map(
            static fn (mixed $categoryId): int => (int) $categoryId,
            array_merge($permissionExclusions, $filteredUserExclusions)
        )));
    }

    /**
     * Sync user's excluded subcategories.
     *
     * @param  array<int>  $categoryIds  Array of category IDs to exclude
     */
    public function syncExcludedCategories(array $categoryIds): void
    {
        // Delete existing exclusions
        $this->excludedCategories()->delete();

        // Insert new exclusions
        foreach ($categoryIds as $categoryId) {
            UserExcludedCategory::create([
                'users_id' => $this->id,
                'categories_id' => (int) $categoryId,
            ]);
        }

        Cache::forget(static::categoryExclusionCacheKey($this->id));
    }

    /**
     * Get excluded categories for API request.
     *
     * @return list<int>
     *
     * @throws \Exception
     */
    public static function getCategoryExclusionForApi(Request $request): array
    {
        $apiToken = $request->input('api_token') ?? $request->input('apikey');
        $user = is_string($apiToken) && $apiToken !== ''
            ? static::findVerifiedByApiToken($apiToken)
            : null;

        return $user ? static::getCategoryExclusionById($user->id) : [];
    }

    // ===== Invitation Methods =====

    /**
     * Send an invitation email.
     *
     * @throws \Exception
     */
    public static function sendInvite(string $serverUrl, int $uid, string $emailTo): string
    {
        $user = static::findOrFail($uid);

        $invitation = Invitation::createInvitation($emailTo, $user->id);
        $url = "{$serverUrl}/register?token={$invitation->token}";

        app(InvitationService::class)->sendInvitationEmail($invitation);

        return $url;
    }

    // ===== Cleanup Methods =====

    /**
     * Delete unverified users older than 3 days.
     */
    public static function deleteUnVerified(): void
    {
        static::query()
            ->where('verified', false)
            ->whereNull('email_verified_at')
            ->where('created_at', '<', now()->subDays(3))
            ->delete();
    }

    /**
     * Check if user can post.
     */
    public static function canPost(int $userId): bool
    {
        return (bool) static::where('id', $userId)->value('can_post');
    }

    /**
     * Get the user's timezone.
     */
    public function getTimezone(): string
    {
        return $this->timezone ?? 'UTC';
    }
}
