<?php

/**
 * 文件功能：聊天室核心控制器
 * 接管原版 INIT.ASP, NEWSAY.ASP, LEAVE.ASP 的所有职责
 *
 * @author   ChatRoom Laravel
 *
 * @version  1.0.0
 */

namespace App\Http\Controllers;

use App\Enums\CurrencySource;
use App\Events\MessageSent;
use App\Events\UserJoined;
use App\Http\Requests\SendMessageRequest;
use App\Jobs\SaveMessageJob;
use App\Models\Autoact;
use App\Models\FriendRequest;
use App\Models\Gift;
use App\Models\PositionDutyLog;
use App\Models\Room;
use App\Models\Sysparam;
use App\Models\User;
use App\Services\AppointmentService;
use App\Services\ChatStateService;
use App\Services\ChatUserPresenceService;
use App\Services\MessageFilterService;
use App\Services\PositionPermissionService;
use App\Services\RoomBroadcastService;
use App\Services\UserCurrencyService;
use App\Services\VipService;
use App\Support\ChatContentSanitizer;
use App\Support\ChatDailyStatusCatalog;
use App\Support\PositionPermissionRegistry;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Redis;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\View\View;
use Intervention\Image\Drivers\Gd\Driver;
use Intervention\Image\ImageManager;

/**
 * 聊天室核心控制器
 * 负责进房、发言、退房、公告与聊天室内各种实时交互。
 */
class ChatController extends Controller
{
    /**
     * 构造聊天室核心控制器所需依赖。
     */
    public function __construct(
        private readonly ChatStateService $chatState,
        private readonly ChatUserPresenceService $chatUserPresenceService,
        private readonly MessageFilterService $filter,
        private readonly VipService $vipService,
        private readonly \App\Services\ShopService $shopService,
        private readonly UserCurrencyService $currencyService,
        private readonly AppointmentService $appointmentService,
        private readonly RoomBroadcastService $broadcast,
        private readonly PositionPermissionService $positionPermissionService,
    ) {}

    /**
     * 进入房间初始化 (等同于原版 INIT.ASP)
     *
     * @param  int  $id  房间ID
     * @return View|JsonResponse
     */
    public function init(int $id)
    {
        $room = Room::findOrFail($id);
        $user = Auth::user();

        $this->ensureUserCanEnterRoom($room, $user);

        // 房间人气 +1（每次访问递增，复刻原版人气计数）
        $room->increment('visit_num');

        // 用户进房时间刷新
        $user->update(['in_time' => now()]);

        // 0. 判断是否已经是当前房间的在线状态
        $hasKey = $this->chatState->isUserInRoom($id, $user->username);
        // 增强校验：判断心跳是否还存在。如果遇到没有启动队列任务的情况，离线任务未能清理脏数据，心跳必定过期。
        $isHeartbeatAlive = (bool) \Illuminate\Support\Facades\Redis::exists("room:{$id}:alive:{$user->username}");

        // 如果虽然在名单里，但心跳早已丢失（可能直接关浏览器且队列未跑），视为全新进房
        if ($hasKey && ! $isHeartbeatAlive) {
            $this->chatState->userLeave($id, $user->username); // 强制洗净状态
            $hasKey = false;
        }

        $isAlreadyInRoom = $hasKey;

        // 1. 先将用户从其他所有房间的在线名单中移除（切换房间时旧记录自动清理）
        // 避免直接跳转页面时 leave 接口未触发导致"幽灵在线"问题
        $oldRoomIds = $this->chatState->getUserRooms($user->username);
        foreach ($oldRoomIds as $oldRoomId) {
            if ($oldRoomId !== $id) {
                $this->chatState->userLeave($oldRoomId, $user->username);
            }
        }

        // 2. 将当前用户加入到 Redis 房间在线列表（包含 VIP 和管理员信息）
        $userData = $this->chatUserPresenceService->build($user);
        $this->chatState->userJoin($id, $user->username, $userData);
        // 记录重新加入房间的精确时间戳（微秒），用于防抖判断（刷新的时候避免闪退闪进播报）
        \Illuminate\Support\Facades\Redis::set("room:{$id}:join_time:{$user->username}", microtime(true));

        // 3. 广播和初始化欢迎（仅限初次进入）
        $newbieEffect = null;
        $initialPresenceTheme = null;
        $initialWelcomeMessage = null;

        if (! $isAlreadyInRoom) {
            // 广播 UserJoined 事件，通知房间内的其他人
            broadcast(new UserJoined($id, $user->username, $userData))->toOthers();

            // 新人首次进入：赠送 6666 金币、播放满场烟花、发送全场欢迎通告
            if (! $user->has_received_new_gift) {
                // 通过统一积分服务发放新人礼包 6666 金币并记录流水
                $this->currencyService->change(
                    $user, 'gold', 6666, CurrencySource::NEWBIE_BONUS, '新人首次入场婿赠的 6666 金币大礼包', $id,
                );
                $user->update(['has_received_new_gift' => true]);

                // 发送新人专属欢迎公告
                $newbieMsg = [
                    'id' => $this->chatState->nextMessageId($id),
                    'room_id' => $id,
                    'from_user' => '系统公告',
                    'to_user' => '大家',
                    'content' => "🎉 缤纷礼花满天飞，热烈欢迎新朋友 【{$user->username}】 首次驾临本聊天室！系统已自动赠送 6666 金币新人大礼包！",
                    'is_secret' => false,
                    'font_color' => '#b91c1c',
                    'action' => '',
                    'welcome_user' => $user->username,
                    'sent_at' => now()->toDateTimeString(),
                ];
                $this->chatState->pushMessage($id, $newbieMsg);
                broadcast(new MessageSent($id, $newbieMsg));

                // 广播烟花特效给此时已在房间的其他用户
                broadcast(new \App\Events\EffectBroadcast($id, 'fireworks', $user->username))->toOthers();

                // 传给前端，让新人自己的屏幕上也燃放烟花
                $newbieEffect = 'fireworks';
            }

            // superlevel 管理员进入：触发全房间烟花 + 系统公告，其他人走通用播报
            // 每次进入先清理掉历史中旧的欢迎消息，保证同一个人只保留最后一条
            $this->chatState->removeOldWelcomeMessages($id, $user->username);

            // 统一走通用进场播报逻辑，管理员不再发送单独的特殊登录提示。
            [$text, $color] = $this->broadcast->buildEntryBroadcast($user);
            $vipPresencePayload = $this->broadcast->buildVipPresencePayload($user, 'join');

            $generalWelcomeMsg = [
                'id' => $this->chatState->nextMessageId($id),
                'room_id' => $id,
                'from_user' => '进出播报',
                'to_user' => '大家',
                'content' => "<span style=\"color: {$color}; font-weight: bold;\">{$text}</span>",
                'is_secret' => false,
                'font_color' => $color,
                'action' => empty($vipPresencePayload) ? 'system_welcome' : 'vip_presence',
                'welcome_user' => $user->username,
                'sent_at' => now()->toDateTimeString(),
            ];

            // 当会员等级带有专属主题时，把横幅与特效字段并入系统消息，供前端展示豪华进场效果。
            if (! empty($vipPresencePayload)) {
                $generalWelcomeMsg = array_merge($generalWelcomeMsg, $vipPresencePayload);
                $initialPresenceTheme = $vipPresencePayload;
            }

            // 把当前这次进房生成的欢迎消息带回前端，确保用户自己也一定能看到。
            $initialWelcomeMessage = $generalWelcomeMsg;

            $this->chatState->pushMessage($id, $generalWelcomeMsg);
            // 修复：之前使用了 ->toOthers() 导致自己看不到自己的进场提示
            broadcast(new MessageSent($id, $generalWelcomeMsg));

            // 会员专属特效需要单独广播给其他在线成员，自己则在页面初始化后本地补播。
            if (! empty($vipPresencePayload['presence_effect'])) {
                broadcast(new \App\Events\EffectBroadcast($id, $vipPresencePayload['presence_effect'], $user->username))->toOthers();
            }
        }

        // 6. 获取历史消息并过滤，只保留和当前用户相关的消息用于初次渲染
        // 规则：公众发言（to_user=大家 或空）保留；私聊/系统通知只保留涉及本人的
        $allHistory = $this->chatState->getNewMessages($id, 0);
        $username = $user->username;
        $historyMessages = array_values(array_filter($allHistory, function ($msg) use ($username) {
            $toUser = $msg['to_user'] ?? '';
            $fromUser = $msg['from_user'] ?? '';
            $isSecret = ! empty($msg['is_secret']);

            // 公众发言（对大家说）：所有人都可以看到
            if ($toUser === '大家' || $toUser === '') {
                return true;
            }

            // 私信 / 悄悄话：只显示发给自己或自己发出的
            if ($isSecret) {
                return $fromUser === $username || $toUser === $username;
            }

            // 对特定人说话：只显示发给自己或自己发出的（含系统通知）
            return $fromUser === $username || $toUser === $username;
        }));

        // 7. 如果用户有在职職务，开始记录这次入场的心跳登录 (仅初次)
        if (! $isAlreadyInRoom) {
            $activeUP = $user->activePosition;
            if ($activeUP) {
                PositionDutyLog::create([
                    'user_id' => $user->id,
                    'user_position_id' => $activeUP->id,
                    'login_at' => now(),
                    'ip_address' => request()->ip(),
                    'room_id' => $id,
                ]);
            }

            // 8. 好友上线通知：向此房间内在线的好友推送慧慧话
            $this->notifyFriendsOnline($id, $user->username);
        }

        // 9. 检查是否有未处理的求婚
        $pendingProposal = \App\Models\Marriage::with(['user', 'ringItem'])
            ->where('partner_id', $user->id)
            ->where('status', 'pending')
            ->first();

        $pendingProposalData = null;
        if ($pendingProposal) {
            $pendingProposalData = [
                'marriage_id' => $pendingProposal->id,
                'proposer_name' => $pendingProposal->user?->username ?? '',
                'ring_name' => $pendingProposal->ringItem?->name ?? '',
                'ring_icon' => $pendingProposal->ringItem?->icon ?? '',
                'expires_at' => $pendingProposal->expires_at?->diffForHumans() ?? '',
            ];
        }

        // 10. 检查是否有未处理的协议离婚请求（对方发起的）
        $pendingDivorce = \App\Models\Marriage::with(['user', 'partner'])
            ->where('status', 'married')
            ->where('divorce_type', 'mutual')
            ->whereNotNull('divorcer_id')
            ->where('divorcer_id', '!=', $user->id)
            ->where(function ($q) use ($user) {
                $q->where('user_id', $user->id)->orWhere('partner_id', $user->id);
            })
            ->first();

        $pendingDivorceData = null;
        if ($pendingDivorce) {
            $initiator = $pendingDivorce->user_id === $pendingDivorce->divorcer_id ? $pendingDivorce->user : $pendingDivorce->partner;
            $pendingDivorceData = [
                'marriage_id' => $pendingDivorce->id,
                'initiator_name' => $initiator?->username ?? '',
            ];
        }

        // 渲染主聊天框架视图前，先计算当前用户的聊天室顶部管理权限。
        $roomPermissionMap = $this->positionPermissionService->permissionMapForUser($user);

        return view('chat.frame', [
            'room' => $room,
            'user' => $user,
            'weekEffect' => $this->shopService->getActiveWeekEffect($user),
            'newbieEffect' => $newbieEffect,
            'initialPresenceTheme' => $initialPresenceTheme,
            'initialWelcomeMessage' => $initialWelcomeMessage,
            'historyMessages' => $historyMessages,
            'pendingProposal' => $pendingProposalData,
            'pendingDivorce' => $pendingDivorceData,
            'roomPermissionMap' => $roomPermissionMap,
            'hasRoomManagementPermission' => in_array(true, $roomPermissionMap, true),
            'dailyStatusCatalog' => ChatDailyStatusCatalog::groupedOptions(),
            'activeDailyStatus' => $this->chatUserPresenceService->currentDailyStatus($user),
        ]);
    }

    /**
     * 校验当前用户是否允许进入指定房间。
     */
    private function ensureUserCanEnterRoom(Room $room, User $user): void
    {
        if ($room->canUserEnter($user)) {
            return;
        }

        abort(403, $room->entryDeniedMessage($user));
    }

    /**
     * 当用户进入房间时，向该房间内在线的所有好友推送慧慧话通知。
     *
     * @param  int  $roomId  当前房间 ID
     * @param  string  $username  上线的用户名
     */
    private function notifyFriendsOnline(int $roomId, string $username): void
    {
        // 获取所有把我加为好友的人（他们是将我加为好友的关注者）
        $friendUsernames = FriendRequest::where('towho', $username)->pluck('who');
        if ($friendUsernames->isEmpty()) {
            return;
        }

        // 当前房间在线用户列表
        $onlineUsers = $this->chatState->getRoomUsers($roomId);

        foreach ($friendUsernames as $friendName) {
            // 好友就在这个房间里，才发通知
            if (! isset($onlineUsers[$friendName])) {
                continue;
            }

            $msg = [
                'id' => $this->chatState->nextMessageId($roomId),
                'room_id' => $roomId,
                'from_user' => '系统',
                'to_user' => $friendName,
                'content' => "🟢 你的好友 <b>{$username}</b> 上线啊！",
                'is_secret' => true,
                'font_color' => '#16a34a',
                'action' => '',
                'sent_at' => now()->toDateTimeString(),
            ];

            $this->chatState->pushMessage($roomId, $msg);
            broadcast(new MessageSent($roomId, $msg));
        }
    }

    /**
     * 发送消息 (等同于原版 NEWSAY.ASP)
     *
     * @param  int  $id  房间ID
     */
    public function send(SendMessageRequest $request, int $id): JsonResponse
    {
        $data = $request->validated();
        $user = Auth::user();
        $imagePayload = null;

        if ($response = $this->ensureUserCanActInRoom($id, $user, '请先进入当前房间后再发言。')) {
            return $response;
        }

        // 0. 检查用户是否被禁言（Redis TTL 自动过期）
        $muteKey = "mute:{$id}:{$user->username}";
        if (Redis::exists($muteKey)) {
            $ttl = Redis::ttl($muteKey);
            $minutes = ceil($ttl / 60);

            return response()->json([
                'status' => 'error',
                'message' => "您正在禁言中，还需等待约 {$minutes} 分钟。",
            ], 403);
        }

        // 0.5 检查接收方是否在线（防幽灵消息）
        $toUser = $data['to_user'] ?? '大家';
        if ($toUser !== '大家' && ! in_array($toUser, ['系统公告', '系统传音', '系统播报', '送花播报', '进出播报', '钓鱼播报', '星海小博士', 'AI小班长'])) {
            // Redis 保存的在线列表
            $isOnline = Redis::hexists("room:{$id}:users", $toUser);
            if (! $isOnline) {
                // 使用 200 状态码，避免 Nginx 拦截非 2xx 响应后触发重定向导致 405 Method Not Allowed
                return response()->json([
                    'status' => 'error',
                    'message' => "【{$toUser}】目前已离开聊天室或不在线，消息未发出。",
                ], 200);
            }
        }

        // 1. 过滤净化消息体；若本次只发图片，则允许文本内容为空。
        $rawContent = (string) ($data['content'] ?? '');
        $pureContent = $rawContent !== '' ? $this->filter->filter($rawContent) : '';
        if ($pureContent === '' && ! $request->hasFile('image')) {
            return response()->json(['status' => 'error', 'message' => '消息内容不能为空或不合法。'], 422);
        }

        // 2. 若带图片，则生成原图与缩略图并按日期目录保存。
        if ($request->hasFile('image')) {
            $imagePayload = $this->storeChatImage($request->file('image'), $user->id);
        }

        // 2. 封装消息对象
        $messageData = [
            'id' => $this->chatState->nextMessageId($id), // 分布式安全自增序号
            'room_id' => $id,
            'from_user' => $user->username,
            'to_user' => $data['to_user'] ?? '大家',
            'content' => $pureContent,
            'is_secret' => $data['is_secret'] ?? false,
            'font_color' => $data['font_color'] ?? '',
            'action' => $data['action'] ?? '',
            'message_type' => $imagePayload ? 'image' : 'text',
            'sent_at' => now()->toDateTimeString(),
        ];
        if ($imagePayload !== null) {
            $messageData = array_merge($messageData, $imagePayload);
        }

        // 3. 压入 Redis 缓存列表 (防炸内存，只保留最近 N 条)
        $this->chatState->pushMessage($id, $messageData);

        // 4. 立刻向 WebSocket 发射广播，前端达到 0 延迟渲染
        broadcast(new MessageSent($id, $messageData));

        // 5. 丢进异步列队，慢慢持久化到 MySQL，保护数据库连接池
        SaveMessageJob::dispatch($messageData);

        // 6. 如果用户更换了字体颜色，顺便保存到 s_color 字段，下次进入时恢复
        $chosenColor = $data['font_color'] ?? '';
        if ($chosenColor && $chosenColor !== ($user->s_color ?? '')) {
            $user->s_color = $chosenColor;
            $user->save();
        }

        // 7. 聊天给魅力值（仅对指定用户的非悄悄话公开发言有效）
        $toUser = $data['to_user'] ?? '大家';
        $isSecret = $data['is_secret'] ?? false;
        if ($toUser !== '大家' && ! $isSecret) {
            $this->grantChatCharm($user, $toUser);
        }

        return response()->json(['status' => 'success']);
    }

    /**
     * 保存聊天图片并生成原图、缩略图两份资源。
     *
     * @return array<string, string>
     */
    private function storeChatImage(UploadedFile $image, int $userId): array
    {
        $manager = new ImageManager(new Driver);
        $extension = strtolower($image->extension() ?: 'jpg');
        $datePath = now()->format('Y-m-d');
        $basename = 'chat_'.$userId.'_'.Str::uuid();
        $originalPath = "chat-images/{$datePath}/{$basename}_original.{$extension}";
        $thumbPath = "chat-images/{$datePath}/{$basename}_thumb.{$extension}";

        // 原图仅做缩边处理，避免超大文件直接灌进磁盘。
        $originalImage = $manager->read($image);
        $originalImage->scaleDown(width: 1600, height: 1600);
        Storage::disk('public')->put($originalPath, (string) $originalImage->encode());

        // 缩略图限制在 220px 范围内，聊天室里只展示轻量小图。
        $thumbImage = $manager->read($image);
        $thumbImage->scaleDown(width: 220, height: 220);
        Storage::disk('public')->put($thumbPath, (string) $thumbImage->encode());

        return [
            'image_path' => $originalPath,
            'image_thumb_path' => $thumbPath,
            'image_original_name' => $image->getClientOriginalName(),
            'image_url' => Storage::url($originalPath),
            'image_thumb_url' => Storage::url($thumbPath),
        ];
    }

    /**
     * 自动挂机存点心跳与经验升级 (新增)
     * 替代原版定时 iframe 刷新的 save.asp。
     *
     * @param  int  $id  房间ID
     */
    public function heartbeat(Request $request, int $id): JsonResponse
    {
        $user = Auth::user();
        if (! $user) {
            return response()->json(['status' => 'error'], 401);
        }

        // 1. 心跳奖励：通过 Redis 限制最小间隔（默认30秒），防止频繁点击
        $cooldownKey = "heartbeat_exp:{$user->id}";
        $canGainReward = ! Redis::exists($cooldownKey);
        $actualExpGain = 0;
        $actualJjbGain = 0;

        if ($canGainReward) {
            // 经验奖励（支持固定值 "1" 或范围 "1-10"）
            $expGain = $this->parseRewardValue(Sysparam::getValue('exp_per_heartbeat', '1'));
            $expMultiplier = $this->vipService->getExpMultiplier($user);
            $actualExpGain = (int) round($expGain * $expMultiplier);
            $user->exp_num += $actualExpGain;

            // 金币奖励（支持固定值 "1" 或范围 "1-5"）
            $jjbGain = $this->parseRewardValue(Sysparam::getValue('jjb_per_heartbeat', '0'));
            if ($jjbGain > 0) {
                $jjbMultiplier = $this->vipService->getJjbMultiplier($user);
                $actualJjbGain = (int) round($jjbGain * $jjbMultiplier);
                $user->jjb = ($user->jjb ?? 0) + $actualJjbGain;
            }

            // 设置冷却（30秒内不再给奖励）
            Redis::setex($cooldownKey, 30, 1);
        }

        // 2. 使用 sysparam 表中可配置的等级-经验阈值计算等级
        // 管理员（superlevel 及以上）不参与自动升降级，等级由后台手动设置
        $superLevel = (int) Sysparam::getValue('superlevel', '100');
        $leveledUp = $this->calculateNewLevel($user, $superLevel);

        $user->save(); // 存点入库

        // 手动心跳存点：同步更新在职用户的勤务时长
        $this->tickDutyLog($user, $id);

        // 3. 将新的等级反馈给当前用户的在线名单上
        // 确保刚刚升级后别人查看到的也是最准确等级
        $this->chatState->userJoin($id, $user->username, $this->chatUserPresenceService->build($user));

        // 4. 如果突破境界，向全房系统喊话广播！
        if ($leveledUp) {
            // 生成炫酷广播消息发向该频道
            $sysMsg = [
                'id' => $this->chatState->nextMessageId($id),
                'room_id' => $id,
                'from_user' => '系统传音',
                'to_user' => '大家',
                'content' => "🌟 天道酬勤！恭喜侠客【{$user->username}】挂机苦修，境界突破至 LV.{$user->user_level}！",
                'is_secret' => false,
                'font_color' => '#d97706', // 琥珀橙色
                'action' => '大声宣告',
                'sent_at' => now()->toDateTimeString(),
            ];

            $this->chatState->pushMessage($id, $sysMsg);
            broadcast(new MessageSent($id, $sysMsg));

            // 落库
            SaveMessageJob::dispatch($sysMsg);
        }

        // 5. 随机事件触发（复刻原版 autoact 系统，概率可在后台配置）
        $autoEvent = null;
        $eventChance = (int) Sysparam::getValue('auto_event_chance', '10');
        if ($eventChance > 0 && rand(1, 100) <= $eventChance) {
            $autoEvent = Autoact::randomEvent();
            if ($autoEvent) {
                // 计算会员倍率加成（仅正向奖励有效）
                $expMul = $this->vipService->getExpMultiplier($user);
                $jjbMul = $this->vipService->getJjbMultiplier($user);

                $finalExp = $autoEvent->exp_change > 0 ? (int) round($autoEvent->exp_change * $expMul) : $autoEvent->exp_change;
                $finalJjb = $autoEvent->jjb_change > 0 ? (int) round($autoEvent->jjb_change * $jjbMul) : $autoEvent->jjb_change;

                $bonusExp = ($autoEvent->exp_change > 0 && $finalExp > $autoEvent->exp_change) ? $finalExp - $autoEvent->exp_change : 0;
                $bonusJjb = ($autoEvent->jjb_change > 0 && $finalJjb > $autoEvent->jjb_change) ? $finalJjb - $autoEvent->jjb_change : 0;

                // 经验变化：通过 UserCurrencyService 写日志
                if ($finalExp !== 0) {
                    $this->currencyService->change(
                        $user,
                        'exp',
                        $finalExp,
                        CurrencySource::AUTO_EVENT,
                        "随机事件：{$autoEvent->text_body}",
                        $id,
                    );
                }

                // 金币变化：通过 UserCurrencyService 写日志
                if ($finalJjb !== 0) {
                    $this->currencyService->change(
                        $user,
                        'gold',
                        $finalJjb,
                        CurrencySource::AUTO_EVENT,
                        "随机事件：{$autoEvent->text_body}",
                        $id,
                    );
                }

                // 重新从数据库读取最新属性（service 已原子更新，需刷新本地对象）
                $user->refresh();

                // 重新计算等级（经验可能因事件而变化，但管理员不参与自动升降级）
                if ($this->calculateNewLevel($user, $superLevel)) {
                    $leveledUp = true; // 随机事件触发了升级，补充标记以便广播
                    $user->save();
                }

                // 构建会员额外加成文案（参考钓鱼系统，确保不弄错）
                $bonusParts = [];
                if ($bonusExp > 0) {
                    $bonusParts[] = "+经验{$bonusExp}";
                }
                if ($bonusJjb > 0) {
                    $bonusParts[] = "+金币{$bonusJjb}";
                }

                $eventContent = $autoEvent->renderText($user->username);
                if (! empty($bonusParts)) {
                    $eventContent .= '（'.$user->vipName().'追加：'.implode('，', $bonusParts).'）';
                }

                // 广播随机事件消息到聊天室
                $eventMsg = [
                    'id' => $this->chatState->nextMessageId($id),
                    'room_id' => $id,
                    'from_user' => '星海小博士',
                    'to_user' => '大家',
                    'content' => $eventContent,
                    'is_secret' => false,
                    'font_color' => match ($autoEvent->event_type) {
                        'good' => '#16a34a',  // 绿色（好运）
                        'bad' => '#dc2626',  // 红色（坏运）
                        default => '#7c3aed',  // 紫色（中性）
                    },
                    'action' => '',
                    'sent_at' => now()->toDateTimeString(),
                ];

                $this->chatState->pushMessage($id, $eventMsg);
                broadcast(new MessageSent($id, $eventMsg));
                SaveMessageJob::dispatch($eventMsg);
            }
        }

        // 确定用户称号：管理员 > VIP 名称 > 普通会员
        $title = '普通会员';
        if ($user->isVip()) {
            $title = $user->vipName() ?: '会员';
        } elseif ($user->user_level >= $superLevel) {
            $title = '管理员';
        }
        $identitySummary = $this->chatUserPresenceService->buildIdentitySummary($user);

        return response()->json([
            'status' => 'success',
            'data' => [
                'exp_num' => $user->exp_num,
                'jjb' => $user->jjb ?? 0,
                'exp_gain' => $actualExpGain,
                'jjb_gain' => $actualJjbGain,
                'user_level' => $user->user_level,
                'title' => $title,
                'identity_summary' => $identitySummary['inline'],
                'department_name' => $identitySummary['department_name'],
                'position_name' => $identitySummary['position_name'],
                'vip_name' => $identitySummary['vip_name'],
                'vip_icon' => $identitySummary['vip_icon'],
                'leveled_up' => $leveledUp,
                'is_max_level' => $user->user_level >= $superLevel,
                'auto_event' => $autoEvent ? $autoEvent->renderText($user->username) : null,
            ],
        ]);
    }

    /**
     * 处理登录失效后的离场清理。
     *
     * 该接口通过临时签名 URL 调用，即使会话已过期也能安全完成离场结算。
     */
    public function expiredLeave(int $id, int $user): JsonResponse
    {
        $expiredUser = User::find($user);

        if (! $expiredUser) {
            return response()->json(['status' => 'error'], 404);
        }

        $this->dispatchImmediateLeave($id, $expiredUser, '登录失效离开了房间');

        return response()->json(['status' => 'success']);
    }

    /**
     * 返回所有房间的在线人数，供右侧房间面板轮询使用。
     *
     * 使用 ChatStateService::getRoomUsers() 保证与名单逻辑完全一致。
     * 返回 [{ id, name, online, permit_level, door_open }] 数组。
     */
    public function roomsOnlineStatus(): JsonResponse
    {
        $rooms = Room::orderBy('id')->get(['id', 'room_name', 'permit_level', 'door_open']);

        $data = $rooms->map(function (Room $room) {
            // 与名单/心跳使用完全相同的方式读取在线人数
            $onlineCount = count($this->chatState->getRoomUsers($room->id));

            return [
                'id' => $room->id,
                'name' => $room->room_name,
                'online' => $onlineCount,
                'permit_level' => $room->permit_level ?? 0,
                'door_open' => (bool) $room->door_open,
            ];
        });

        return response()->json(['rooms' => $data]);
    }

    /**
     * 离开房间 (等同于原版 LEAVE.ASP)
     *
     * @param  int  $id  房间ID
     */
    public function leave(Request $request, int $id): JsonResponse
    {
        $user = Auth::user();
        if (! $user) {
            return response()->json(['status' => 'error'], 401);
        }

        $leaveTime = microtime(true);
        $isExplicit = strval($request->query('explicit')) === '1';

        if ($isExplicit) {
            // 人工显式点击“离开”时，立即同步执行清算和播报。
            $this->dispatchImmediateLeave($id, $user, '主动离开了房间');
        } else {
            // 不立刻执行离线逻辑，而是给个 3 秒的防抖延迟
            // 这样如果用户只是刷新页面，很快在 init 中又会重新加入房间（记录的 join_time 会大于当前 leave 时的 leaveTime）
            // Job 中就不会执行完整的离线播报和注销流程
            \App\Jobs\ProcessUserLeave::dispatch($id, clone $user, $leaveTime)->delay(now()->addSeconds(3));
        }

        return response()->json(['status' => 'success']);
    }

    /**
     * 立即执行离场清理，并跳过刷新防抖逻辑。
     */
    private function dispatchImmediateLeave(int $id, User $user, string $outInfo): void
    {
        Redis::del("room:{$id}:join_time:{$user->username}");

        $job = new \App\Jobs\ProcessUserLeave($id, clone $user, microtime(true), $outInfo);
        dispatch_sync($job);
    }

    /**
     * 获取可用头像列表（返回 JSON）
     * 扫描 /public/images/headface/ 目录，返回所有可用头像文件名
     */
    public function headfaceList(): JsonResponse
    {
        $dir = public_path('images/headface');
        $files = [];

        if (is_dir($dir)) {
            $all = scandir($dir);
            foreach ($all as $file) {
                // 只包含图片文件
                if (preg_match('/\.(gif|jpg|jpeg|png|bmp)$/i', $file)) {
                    $files[] = $file;
                }
            }
        }

        // 自然排序（1, 2, 3... 10, 11...）
        natsort($files);

        return response()->json(['headfaces' => array_values($files)]);
    }

    /**
     * 修改头像（原版 fw.asp 功能）
     * 用户选择一个头像文件名，更新到 usersf 字段
     */
    public function changeAvatar(Request $request): JsonResponse
    {
        $user = Auth::user();
        $headface = $request->input('headface', '');

        if (empty($headface)) {
            return response()->json(['status' => 'error', 'message' => '请选择一个头像'], 422);
        }

        // 验证文件确实存在
        if (! file_exists(public_path('images/headface/'.$headface))) {
            return response()->json(['status' => 'error', 'message' => '头像文件不存在'], 422);
        }

        // 更新前如为自定义头像，将其从磁盘删除，节约空间
        if ($user->usersf !== $headface) {
            $user->deleteCustomAvatar();
            $user->usersf = $headface;
            $user->save();
        }

        // 将新头像同步到 Redis 在线用户列表中（所有房间）
        // 通过更新 Redis 的用户信息，使得其他用户和自己刷新后都能看到新头像
        $rooms = $this->chatState->getUserRooms($user->username);
        foreach ($rooms as $roomId) {
            // 头像更新后，统一通过在线载荷服务刷新所有扩展字段，避免状态或职务字段丢失。
            $this->chatState->userJoin((int) $roomId, $user->username, $this->chatUserPresenceService->build($user));
        }

        return response()->json([
            'status' => 'success',
            'message' => '头像修改成功！',
            'headface' => $headface,
        ]);
    }

    /**
     * 上传自定义头像
     */
    public function uploadAvatar(Request $request): JsonResponse
    {
        $request->validate([
            'file' => 'required|image|mimes:jpeg,png,jpg,gif,webp|max:6144',
        ]);

        $user = Auth::user();
        if (! $user) {
            return response()->json(['status' => 'error', 'message' => '未登录'], 401);
        }

        $file = $request->file('file');

        try {
            $manager = new ImageManager(new Driver);

            // 生成相对路径
            $filename = 'custom_'.$user->id.'_'.time().'.'.$file->extension();
            $originalFilename = 'custom_'.$user->id.'_'.time().'_original.'.$file->extension();
            $path = 'avatars/'.$filename;
            $originalPath = 'avatars/'.$originalFilename;

            // 1. 处理原图：限制最大宽度为 1280 以免过大，保存原比例高清大图
            $originalImage = $manager->read($file);
            $originalImage->scaleDown(width: 1280);
            Storage::disk('public')->put($originalPath, (string) $originalImage->encode());

            // 2. 处理缩略图：裁剪正方形并压缩为 112x112
            $thumbImage = $manager->read($file);
            $thumbImage->cover(112, 112);
            Storage::disk('public')->put($path, (string) $thumbImage->encode());

            $dbValue = 'storage/'.$path;

            // 更新前如为自定义头像，将其从磁盘删除，节约空间
            if ($user->usersf !== $dbValue) {
                $user->deleteCustomAvatar();
                $user->usersf = $dbValue;
                $user->save();
            }

            // 同步 Redis 状态
            $rooms = $this->chatState->getUserRooms($user->username);
            foreach ($rooms as $roomId) {
                // 自定义头像上传成功后，同步覆盖在线名单中的全部展示字段。
                $this->chatState->userJoin((int) $roomId, $user->username, $this->chatUserPresenceService->build($user));
            }

            return response()->json([
                'status' => 'success',
                'message' => '头像上传成功！',
                'headface' => $user->headface,
            ]);

        } catch (\Exception $e) {
            return response()->json(['status' => 'error', 'message' => '上传失败: '.$e->getMessage()], 500);
        }
    }

    /**
     * 设置房间公告/祝福语（滚动显示在聊天室顶部）
     * 需要当前在职职务拥有 room.announcement 权限，
     * id=1 站长始终允许操作。
     *
     * @param  int  $id  房间ID
     */
    public function setAnnouncement(Request $request, int $id): JsonResponse
    {
        $user = Auth::user();
        $room = Room::findOrFail($id);

        // 改为统一走职务权限判断，不再给房主单独保留公告特权。
        if (! $this->positionPermissionService->hasPermission($user, PositionPermissionRegistry::ROOM_ANNOUNCEMENT)) {
            return response()->json(['status' => 'error', 'message' => '权限不足，无法修改公告'], 403);
        }

        if (! $this->chatState->isUserInRoom($id, $user->username)) {
            return response()->json(['status' => 'error', 'message' => '请先进入该房间后再修改公告'], 403);
        }

        $request->validate([
            'announcement' => 'required|string|max:500',
        ]);

        $announcementText = trim((string) $request->input('announcement'));

        // 将发送者和发送时间追加到公告文本末尾，持久化存储，无需额外字段。
        $room->announcement = $announcementText.' ——'.$user->username.' '.now()->format('m-d H:i');
        $room->save();

        // 广播公告更新到所有在线用户
        $sysMsg = [
            'id' => $this->chatState->nextMessageId($id),
            'room_id' => $id,
            'from_user' => '系统公告',
            'to_user' => '大家',
            'content' => '📢 '.ChatContentSanitizer::htmlText($user->username).' 更新了房间公告：'.ChatContentSanitizer::htmlText($room->announcement),
            'is_secret' => false,
            'font_color' => '#cc0000',
            'action' => '',
            'sent_at' => now()->toDateTimeString(),
        ];

        $this->chatState->pushMessage($id, $sysMsg);
        broadcast(new MessageSent($id, $sysMsg));

        return response()->json([
            'status' => 'success',
            'message' => '公告已更新！',
            'announcement' => $room->announcement,
        ]);
    }

    /**
     * 送花/礼物：消耗金币给目标用户增加魅力值
     *
     * 根据 gift_id 查找 gifts 表中的礼物类型，读取对应的金币消耗和魅力增量。
     * 送花成功后在聊天室广播带图片的消息。
     */
    public function sendFlower(Request $request): JsonResponse
    {
        $request->validate([
            'to_user' => 'required|string',
            'room_id' => 'required|integer',
            'gift_id' => 'required|integer',
            'count' => 'sometimes|integer|min:1|max:99',
        ]);

        $user = Auth::user();
        if (! $user) {
            return response()->json(['status' => 'error', 'message' => '请先登录'], 401);
        }

        $toUsername = $request->input('to_user');
        $roomId = $request->input('room_id');
        $giftId = $request->integer('gift_id');
        $count = $request->integer('count', 1);

        if ($response = $this->ensureUserCanActInRoom((int) $roomId, $user, '请先进入当前房间后再送礼物。')) {
            return $response;
        }

        // 不能给自己送花
        if ($toUsername === $user->username) {
            return response()->json(['status' => 'error', 'message' => '不能给自己送花哦～']);
        }

        // 查找礼物类型
        $gift = Gift::where('id', $giftId)->where('is_active', true)->first();
        if (! $gift) {
            return response()->json(['status' => 'error', 'message' => '礼物不存在或已下架']);
        }

        // 查找目标用户
        $toUser = User::where('username', $toUsername)->first();
        if (! $toUser) {
            return response()->json(['status' => 'error', 'message' => '用户不存在']);
        }

        if ($response = $this->ensureTargetOnlineInRoom((int) $roomId, (string) $toUsername)) {
            return $response;
        }

        $totalCost = $gift->cost * $count;
        $totalCharm = $gift->charm * $count;

        // 检查金币余额
        if (($user->jjb ?? 0) < $totalCost) {
            return response()->json([
                'status' => 'error',
                'message' => "金币不足！送 {$count} 份【{$gift->name}】需要 {$totalCost} 金币，您当前有 ".($user->jjb ?? 0).' 枚。',
            ]);
        }

        // 扣除金币、增加对方魅力
        $user->jjb = ($user->jjb ?? 0) - $totalCost;
        $user->save();

        $toUser->meili = ($toUser->meili ?? 0) + $totalCharm;
        $toUser->save();

        // 构建礼物图片 URL
        $giftImageUrl = $gift->image ? "/images/gifts/{$gift->image}" : '';

        // 广播送花消息（含图片标记，前端识别后渲染图片）
        $countText = $count > 1 ? " {$count} 份" : '';
        $safeSender = ChatContentSanitizer::htmlText($user->username);
        $safeReceiver = ChatContentSanitizer::htmlText($toUsername);
        $safeGiftName = ChatContentSanitizer::htmlText($gift->name);
        $sysMsg = [
            'id' => $this->chatState->nextMessageId($roomId),
            'room_id' => $roomId,
            'from_user' => '送花播报',
            'to_user' => $toUsername,
            'content' => "{$gift->emoji} 【{$safeSender}】 向 【{$safeReceiver}】 送出了{$countText}【{$safeGiftName}】！魅力 +{$totalCharm}！",
            'is_secret' => false,
            'font_color' => '#e91e8f',
            'action' => '',
            'sent_at' => now()->toDateTimeString(),
            'gift_image' => $giftImageUrl,
            'gift_name' => $gift->name,
        ];

        $this->chatState->pushMessage($roomId, $sysMsg);
        broadcast(new MessageSent($roomId, $sysMsg));
        SaveMessageJob::dispatch($sysMsg);

        return response()->json([
            'status' => 'success',
            'message' => "送花成功！花费 {$totalCost} 金币，{$toUsername} 魅力 +{$totalCharm}",
            'data' => [
                'my_jjb' => $user->jjb,
                'target_charm' => $toUser->meili,
            ],
        ]);
    }

    /**
     * 聊天获取魅力值（方案 B：每条消息触发，Redis 每小时上限控制）
     *
     * 异性聊天给更多魅力，同性少一些。
     * 系统用户（如 AI小班长）不触发魅力奖励。
     * 发送者和接收者都会获得对应魅力值。
     *
     * @param  mixed  $sender  发送消息的用户模型
     * @param  string  $toUsername  接收消息的用户名
     */
    private function grantChatCharm(mixed $sender, string $toUsername): void
    {
        // 系统用户不参与魅力计算
        $systemNames = ['大家', '系统传音', '系统公告', '系统播报', '钓鱼播报', '星海小博士', 'AI小班长', '送花播报'];
        if (in_array($toUsername, $systemNames)) {
            return;
        }

        // 查找接收者
        $receiver = User::where('username', $toUsername)->first();
        if (! $receiver) {
            return;
        }

        // 检查发送者每小时魅力上限（Redis 自动过期）
        $capKey = "charm_cap:{$sender->username}:".date('YmdH');
        $hourlyLimit = (int) Sysparam::getValue('charm_hourly_limit', '20');
        $currentGained = (int) Redis::get($capKey);
        if ($currentGained >= $hourlyLimit) {
            return; // 已达本小时上限
        }

        // 根据性别关系计算魅力增量
        $senderSex = $sender->sex ?? '';
        $receiverSex = $receiver->sex ?? '';
        $isCrossSex = ($senderSex !== $receiverSex) && $senderSex !== '' && $receiverSex !== '';

        $charmSame = (int) Sysparam::getValue('charm_same_sex', '1');
        $charmCross = (int) Sysparam::getValue('charm_cross_sex', '2');
        $charmGain = $isCrossSex ? $charmCross : $charmSame;

        // 不超过本小时剩余额度
        $remaining = $hourlyLimit - $currentGained;
        $charmGain = min($charmGain, $remaining);
        if ($charmGain <= 0) {
            return;
        }

        // 发送者获得魅力
        $sender->meili = ($sender->meili ?? 0) + $charmGain;
        $sender->save();

        // 更新 Redis 计数器（1 小时过期）
        Redis::incrby($capKey, $charmGain);
        Redis::expire($capKey, 3600);
    }

    /**
     * 解析奖励数值配置（支持固定值或范围格式）
     *
     * 支持格式：
     *   "5"    → 固定返回 5
     *   "1-10" → 随机返回 1~10 之间的整数
     *   "0"    → 返回 0（关闭该奖励）
     *
     * @param  string  $value  配置值
     * @return int 解析后的奖励数值
     */
    private function parseRewardValue(string $value): int
    {
        $value = trim($value);

        // 支持范围格式 "min-max"
        if (str_contains($value, '-')) {
            $parts = explode('-', $value, 2);
            $min = max(0, (int) $parts[0]);
            $max = max($min, (int) $parts[1]);

            return rand($min, $max);
        }

        return max(0, (int) $value);
    }

    /**
     * 关闭该用户尚未结束的在职登录记录（结算在线时长）
     * 在用户退出房间或心跳超时时调用
     *
     * @param  int  $userId  用户 ID
     */
    private function closeDutyLog(int $userId): void
    {
        // 将今日开放日志关闭并结算实际时长
        PositionDutyLog::query()
            ->where('user_id', $userId)
            ->whereNull('logout_at')
            ->whereDate('login_at', today())
            ->update([
                'logout_at' => now(),
                'duration_seconds' => DB::raw('GREATEST(0, TIMESTAMPDIFF(SECOND, login_at, NOW()))'),
            ]);

        // 关闭历史遗留的跨天未关闭日志（login_at 非今日）
        // 保留最后一次心跳刷新的 duration_seconds，确保已积累时长不丢失
        PositionDutyLog::query()
            ->where('user_id', $userId)
            ->whereNull('logout_at')
            ->whereDate('login_at', '<', today())
            ->update([
                'logout_at' => DB::raw('login_at + INTERVAL duration_seconds SECOND'),
            ]);
    }

    /**
     * 存点时同步更新或创建在职用户的勤务日志。
     *
     * 逻辑：
     * 1. 用户无在职职务 → 跳过
     * 2. 今日已有开放日志（无 logout_at）→ 刷新 duration_seconds（实时时长）
     * 3. 今日无任何日志 → 新建，login_at 取 user->in_time（进房时间），保证时长不丢失
     *
     * @param  \App\Models\User  $user  当前用户（必须已 fresh/refresh）
     * @param  int  $roomId  所在房间 ID
     */
    private function tickDutyLog(User $user, int $roomId): void
    {
        // 无论有无职务，均记录在线流水
        $activeUP = $user->activePosition;

        // ① 优先找今日未关闭的开放日志，直接刷新时长
        $openLog = PositionDutyLog::query()
            ->where('user_id', $user->id)
            ->whereNull('logout_at')
            ->whereDate('login_at', today())
            ->first();

        if ($openLog) {
            DB::table('position_duty_logs')
                ->where('id', $openLog->id)
                ->update([
                    'duration_seconds' => DB::raw('GREATEST(0, TIMESTAMPDIFF(SECOND, login_at, NOW()))'),
                    // DB::table raw update 不自动刷 updated_at，必须手动设置，
                    // 否则 CloseStaleDutyLogs 会误判此 session 为掉线而提前关闭。
                    'updated_at' => now(),
                ]);

            return;
        }

        // ② 若今日已有「已关闭」的日志段，说明是 CloseStaleDutyLogs 关闭后重建：
        //    必须用 now() 作为 login_at，防止重用旧的 in_time（如今日 00:00）导致
        //    每次重建的 duration_seconds 都从午夜算起，累加成等差数列（产生 249h 等异常值）。
        //    只有今日首次创建（无任何历史日志段）时，才用 in_time 保留真实进房时刻。
        $hasClosedToday = PositionDutyLog::query()
            ->where('user_id', $user->id)
            ->whereDate('login_at', today())
            ->whereNotNull('logout_at')
            ->exists();

        $loginAt = (! $hasClosedToday && $user->in_time && $user->in_time->isToday())
            ? $user->in_time
            : now();

        PositionDutyLog::create([
            'user_id' => $user->id,
            'user_position_id' => $activeUP?->id,
            'login_at' => $loginAt,
            'ip_address' => request()->ip(),
            'room_id' => $roomId,
        ]);
    }

    /**
     * 根据经验值重新计算用户等级，申升减级均会直接修改 $user->user_level。
     *
     * PHP 对象引用传递，方法内对 $user 的修改会直接反映到调用方。
     * 本方法不负责 save()，由调用方决定何时落库。
     *
     * @param  \App\Models\User  $user  当前用户模型
     * @param  int  $superLevel  管理员等级阈值（达到后不参与自动升降级）
     * @return bool 是否发生了升级（true = 等级提升）
     */
    private function calculateNewLevel(\App\Models\User $user, int $superLevel): bool
    {
        // 管理员等级由后台手动维护，不参与自动升降级
        if ($user->user_level >= $superLevel) {
            return false;
        }

        $newLevel = Sysparam::calculateLevel($user->exp_num);

        // 等级无变化，或计算结果达到管理员阈值（异常情况），均跳过
        if ($newLevel === $user->user_level || $newLevel >= $superLevel) {
            return false;
        }

        $isLeveledUp = $newLevel > $user->user_level;

        // 在职职务成员：等级保护逻辑
        $activeUP = $user->activePosition;
        if ($activeUP) {
            $positionLevel = $activeUP->position->level ?? 0;

            // 职务要求高于当前等级 → 强制补级到职务最低要求
            if ($positionLevel > $user->user_level) {
                $user->user_level = $positionLevel;

                return true; // 等级提升，调用方需保存并广播
            }

            // 降级 且 降后等级低于职务要求 → 阻止
            if (! $isLeveledUp && $newLevel < $positionLevel) {
                return false;
            }
        }

        // PHP 对象引用传递，这里对 $user->user_level 的修改将直接反映到调用方
        $user->user_level = $newLevel;

        return $isLeveledUp;
    }

    /**
     * 用户间赠送金币（任何登录用户均可调用）
     *
     * 从自己的余额中扣除指定金额，转入对方账户，
     * 并以私聊消息的方式仅通知赠送双方。
     */
    public function giftGold(Request $request): JsonResponse
    {
        $request->validate([
            'to_user' => 'required|string',
            'room_id' => 'required|integer',
            'amount' => 'required|integer|min:1|max:999999999',
        ], [
            'amount.max' => '单次赠送金币不能超过 999999999',
            'amount.min' => '单次赠送金币至少为 1',
            'amount.integer' => '金币数量必须是整数',
            'amount.required' => '请输入要赠送的金币数量',
        ]);

        $sender = Auth::user();
        $toName = $request->input('to_user');
        $roomId = $request->integer('room_id');
        $amount = $request->integer('amount');

        if ($response = $this->ensureUserCanActInRoom($roomId, $sender, '请先进入当前房间后再赠送金币。')) {
            return $response;
        }

        // 不能给自己转账
        if ($toName === $sender->username) {
            return response()->json(['status' => 'error', 'message' => '不能给自己赠送哦～']);
        }

        // 查目标用户
        $receiver = User::where('username', $toName)->first();
        if (! $receiver) {
            return response()->json(['status' => 'error', 'message' => '用户不存在']);
        }

        if ($response = $this->ensureTargetOnlineInRoom($roomId, (string) $toName)) {
            return $response;
        }

        // 余额校验
        if (($sender->jjb ?? 0) < $amount) {
            return response()->json([
                'status' => 'error',
                'message' => '金币不足！您当前余额 '.($sender->jjb ?? 0)." 金币，无法赠送 {$amount} 金币。",
            ]);
        }

        // 执行转账（直接操作字段，与 sendFlower 保持一致风格）
        $sender->decrement('jjb', $amount);
        $receiver->increment('jjb', $amount);

        // 写入真正的私聊消息，避免其他旁观用户在公屏看到赠金币通知。
        $giftMsg = [
            'id' => $this->chatState->nextMessageId($roomId),
            'room_id' => $roomId,
            'from_user' => $sender->username,
            'to_user' => $toName,
            'content' => "赠送了你 {$amount} 金币！💝",
            'is_secret' => true,
            'font_color' => '#b45309',
            'action' => '',
            'sent_at' => now()->toDateTimeString(),
            // 接收方收到消息时，在右下角弹到账提示卡片。
            'toast_notification' => [
                'title' => '💰 赠金币到账',
                'message' => '<b>'.ChatContentSanitizer::htmlText($sender->username)."</b> 向你赠送了 <b>{$amount}</b> 枚金币！",
                'icon' => '💰',
                'color' => '#f59e0b',
                'duration' => 8000,
            ],
        ];

        // 推入 Redis + WebSocket + 异步落库，保持与普通私聊一致的展示与历史记录行为。
        $this->chatState->pushMessage($roomId, $giftMsg);
        broadcast(new MessageSent($roomId, $giftMsg));
        SaveMessageJob::dispatch($giftMsg);

        return response()->json([
            'status' => 'success',
            'message' => "赠送成功！已向 {$toName} 赠送 {$amount} 金币。",
            'data' => [
                'my_jjb' => $sender->fresh()->jjb,
                'target_jjb' => $receiver->fresh()->jjb,
            ],
        ]);
    }

    /**
     * 校验用户是否能在指定房间执行聊天动作。
     */
    private function ensureUserCanActInRoom(int $roomId, ?User $user, string $message): ?JsonResponse
    {
        if (! $user) {
            return response()->json(['status' => 'error', 'message' => '请先登录'], 401);
        }

        $room = Room::query()->find($roomId);
        if (! $room) {
            return response()->json(['status' => 'error', 'message' => '房间不存在'], 404);
        }

        if (! $room->canUserEnter($user) || ! $this->chatState->isUserInRoom($roomId, $user->username)) {
            return response()->json(['status' => 'error', 'message' => $message], 403);
        }

        return null;
    }

    /**
     * 校验目标用户是否仍在当前房间在线，避免跨房间赠送和消息注入。
     */
    private function ensureTargetOnlineInRoom(int $roomId, string $targetUsername): ?JsonResponse
    {
        if (! $this->chatState->isUserInRoom($roomId, $targetUsername)) {
            return response()->json(['status' => 'error', 'message' => '目标用户不在当前房间，无法执行该操作'], 403);
        }

        return null;
    }
}
