<?php

declare(strict_types=1);

namespace App\Models;

use App\Enums\QueueType;
use App\Enums\SignupError;
use App\Enums\UserRole;
use App\Jobs\SendAccountExpiredEmail;
use App\Jobs\SendAccountWillExpireEmail;
use App\Rules\ValidEmailDomain;
use App\Services\InvitationService;
use Carbon\CarbonImmutable;
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\Hash;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Jrean\UserVerification\Traits\UserVerification;
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 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 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-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 Role|null $role
 * @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 whereVerified(int $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
{
    use HasFactory;
    use HasRoles;
    use Notifiable;
    use SoftDeletes;
    use UserVerification;

    /**
     * @var list<string>
     */
    protected $hidden = [
        'remember_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',
            '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',
        ];
    }

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

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

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

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

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

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

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

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

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

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

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

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

    public function passwordSecurity(): HasOne
    {
        return $this->hasOne(PasswordSecurity::class);
    }

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

    public function request(): HasMany
    {
        return $this->requests();
    }

    public function download(): HasMany
    {
        return $this->downloads();
    }

    public function release(): HasMany
    {
        return $this->releases();
    }

    public function invitation(): HasMany
    {
        return $this->invitations();
    }

    public function failedRelease(): HasMany
    {
        return $this->failedReleases();
    }

    public function comment(): HasMany
    {
        return $this->comments();
    }

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

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

    /**
     * Scope to get verified users.
     */
    public function scopeVerified(Builder $query): Builder
    {
        return $query->where('verified', true);
    }

    /**
     * Scope to filter users by role.
     */
    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.
     */
    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.
     */
    public function scopeExpired(Builder $query): Builder
    {
        return $query->whereNotNull('rolechangedate')
            ->where('rolechangedate', '<', now());
    }

    /**
     * Scope to exclude sharing email.
     */
    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
    {
        return Attribute::make(
            get: fn (): string => trim("{$this->firstname} {$this->lastname}") ?: $this->username,
        );
    }

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

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

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

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

    /**
     * Get days until role expires.
     */
    protected function daysUntilExpiry(): Attribute
    {
        return Attribute::make(
            get: fn (): ?int => $this->rolechangedate
                ? (int) now()->diffInDays($this->rolechangedate, false)
                : 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 Role::find($this->pending_roles_id);
    }

    /**
     * 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: Carbon,
     *     end_date: Carbon,
     *     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) {
            $role = Role::find($history->new_role_id);

            return [
                'role' => $role,
                'role_name' => $role?->name ?? 'Unknown Role',
                '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 = '',
    ): int {
        return self::query()
            ->withTrashed()
            ->excludeSharing()
            ->when($role, fn (Builder $q) => $q->where('roles_id', $role))
            ->when($username, fn (Builder $q) => $q->where('username', 'like', "%{$username}%"))
            ->when($host, fn (Builder $q) => $q->where('host', 'like', "%{$host}%"))
            ->when($email, fn (Builder $q) => $q->where('email', 'like', "%{$email}%"))
            ->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"))
            ->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();
    }

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

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

    /**
     * @deprecated Use findByUsername() instead
     */
    public static function getByUsername(string $userName): ?static
    {
        return static::findByUsername($userName);
    }

    /**
     * @deprecated Use findByEmail() instead
     */
    public static function getByEmail(string $email): ?static
    {
        return static::findByEmail($email);
    }

    /**
     * @deprecated Use findByRssToken() instead
     */
    public static function getByRssToken(string $rssToken): ?static
    {
        return static::findByRssToken($rssToken);
    }

    /**
     * @deprecated Use findByResetGuid() instead
     */
    public static function getByPassResetGuid(string $guid): ?static
    {
        return static::findByResetGuid($guid);
    }

    // ===== 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,
            ...($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 = ($addYears ?? 0) * self::DAYS_PER_YEAR;
        $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 = $pendingRole->addyears * self::DAYS_PER_YEAR;
                $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 = ($addYears ?? $roleModel->addyears) * self::DAYS_PER_YEAR;
        $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 = ($addYears ?? $roleModel->addyears) * self::DAYS_PER_YEAR;
        $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;
    }

    /**
     * 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');
            }
        }
    }

    /**
     * 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>
     *
     * @throws \Throwable
     */
    public static function getRange(
        int|false $start,
        int $offset,
        string $orderBy,
        ?string $userName = '',
        ?string $email = '',
        ?string $host = '',
        ?string $role = '',
        bool $apiRequests = false,
        ?string $createdFrom = '',
        ?string $createdTo = '',
    ): Collection {
        $order = self::getBrowseOrder($orderBy);

        if ($apiRequests) {
            UserRequest::clearApiRequests(false);

            $query = '
                SELECT users.*, roles.name AS rolename, COUNT(user_requests.id) AS apirequests
                FROM users
                INNER JOIN roles ON roles.id = users.roles_id
                LEFT JOIN user_requests ON user_requests.users_id = users.id
                WHERE users.id != 0 %s %s %s %s %s %s
                AND email != \'sharing@nZEDb.com\'
                GROUP BY users.id
                ORDER BY %s %s %s';
        } else {
            $query = '
                SELECT users.*, roles.name AS rolename
                FROM users
                INNER JOIN roles ON roles.id = users.roles_id
                WHERE 1=1 %s %s %s %s %s %s
                ORDER BY %s %s %s';
        }

        return static::fromQuery(
            sprintf(
                $query,
                $userName ? 'AND users.username LIKE '.escapeString("%{$userName}%") : '',
                $email ? 'AND users.email LIKE '.escapeString("%{$email}%") : '',
                $host ? 'AND users.host LIKE '.escapeString("%{$host}%") : '',
                $role ? "AND users.roles_id = {$role}" : '',
                $createdFrom ? 'AND users.created_at >= '.escapeString("{$createdFrom} 00:00:00") : '',
                $createdTo ? 'AND users.created_at <= '.escapeString("{$createdTo} 23:59:59") : '',
                $order[0],
                $order[1],
                $start === false ? '' : "LIMIT {$offset} OFFSET {$start}"
            )
        );
    }

    /**
     * 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',
            '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(Password::getRepository()->createNewToken()),
        ]);

        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::find($id)?->increment('grabs', $num);
    }

    // ===== 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,
    ): bool|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|false {
        $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.
     *
     * @return array<int>
     *
     * @throws \Exception
     */
    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, false)) {
                $excludedRoots[] = $rootId;
            }
        }

        return Category::whereIn('root_categories_id', $excludedRoots)
            ->pluck('id')
            ->toArray();
    }

    /**
     * Get excluded categories for API request.
     *
     * @throws \Exception
     */
    public static function getCategoryExclusionForApi(Request $request): array
    {
        $apiToken = $request->input('api_token') ?? $request->input('apikey');
        $user = static::findByRssToken($apiToken);

        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::whereVerified(0)
            ->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';
    }

    // ===== Legacy Constants (Deprecated - Use Enums) =====

    /** @deprecated Use SignupError::BAD_USERNAME->value instead */
    public const ERR_SIGNUP_BADUNAME = SignupError::BAD_USERNAME->value;

    /** @deprecated Use SignupError::BAD_PASSWORD->value instead */
    public const ERR_SIGNUP_BADPASS = SignupError::BAD_PASSWORD->value;

    /** @deprecated Use SignupError::BAD_EMAIL->value instead */
    public const ERR_SIGNUP_BADEMAIL = SignupError::BAD_EMAIL->value;

    /** @deprecated Use SignupError::USERNAME_IN_USE->value instead */
    public const ERR_SIGNUP_UNAMEINUSE = SignupError::USERNAME_IN_USE->value;

    /** @deprecated Use SignupError::EMAIL_IN_USE->value instead */
    public const ERR_SIGNUP_EMAILINUSE = SignupError::EMAIL_IN_USE->value;

    /** @deprecated Use SignupError::BAD_INVITE_CODE->value instead */
    public const ERR_SIGNUP_BADINVITECODE = SignupError::BAD_INVITE_CODE->value;

    /** @deprecated Use SignupError::SUCCESS->value instead */
    public const SUCCESS = SignupError::SUCCESS->value;

    /** @deprecated Use UserRole::USER->value instead */
    public const ROLE_USER = UserRole::USER->value;

    /** @deprecated Use UserRole::ADMIN->value instead */
    public const ROLE_ADMIN = UserRole::ADMIN->value;

    /** @deprecated Use UserRole::DISABLED->value instead */
    public const ROLE_DISABLED = UserRole::DISABLED->value;

    /** @deprecated Use UserRole::MODERATOR->value instead */
    public const ROLE_MODERATOR = UserRole::MODERATOR->value;

    /** @deprecated Use QueueType::NONE->value instead */
    public const QUEUE_NONE = QueueType::NONE->value;

    /** @deprecated Use QueueType::SABNZBD->value instead */
    public const QUEUE_SABNZBD = QueueType::SABNZBD->value;

    /** @deprecated Use QueueType::NZBGET->value instead */
    public const QUEUE_NZBGET = QueueType::NZBGET->value;
}
