<?php
/** 本地集成测试：独立随机前缀的临时表，不修改员工或业务表。 */
require dirname(__DIR__).'/vendor/autoload.php';
require __DIR__.'/migrate-chat.php';
require __DIR__.'/chat-socket-check.php';
require __DIR__.'/migrate-auth-names.php';
use think\facade\Db;
use app\mobile\service\ChatService;
use think\exception\HttpResponseException;

$app=new \think\App(dirname(__DIR__).'/');$app->initialize();
$database=$app->config->get('database');$original=$database['connections'][$database['default']];
if(!in_array($original['hostname'],['127.0.0.1','localhost'],true))throw new RuntimeException('Tests require a local database');
$child=in_array($argv[1]??'',['race-expire','socket-worker'],true);
$prefix=$child ? ($argv[2]??'') : 'chat_test_'.bin2hex(random_bytes(4)).'_';
if(!preg_match('/^chat_test_[a-f0-9]{8}_$/',$prefix))throw new RuntimeException('Unsafe test prefix');
$original['prefix']=$prefix;$database['connections']['chat_test']=$original;$database['default']='chat_test';
$app->config->set($database,'database');Db::setConfig($app->config);
$app->config->set(['admins'=>['test-101'],'socket_url'=>'wss://chat.example.test/ws'],'chat');
$imageRoot=dirname(__DIR__).'/runtime/'.$prefix.'images';$app->config->set(['image_root'=>$imageRoot],'chat');
$beforeChatConnection=Db::query('SELECT @@character_set_client AS charset, DATABASE() AS db')[0];
$service=new ChatService($app);
if($child){
    if($argv[1]==='race-expire'){$service->expire((int)$argv[3]);exit(0);}
    $port=(int)($argv[3]??0);if($port<1024||$port>65535)throw new RuntimeException('Invalid test port');
    $app->config->set(['socket_listen'=>'websocket://127.0.0.1:'.$port],'chat');
    $argv=[$argv[0],'start'];$_SERVER['argv']=$argv;
    require __DIR__.'/chat-worker.php';exit(0);
}
$tables=['chat_room','chat_member','chat_message','chat_invite','chat_image','chat_report','chat_log','admin_user_mobile','member_list','admin_user','auth'];
$checks=0;
function check($condition,$message){global $checks;if(!$condition)throw new RuntimeException($message);$checks++;}
function rejects($callable,$status){try{$callable();}catch(HttpResponseException $error){check($error->getResponse()->getCode()===$status,'Unexpected rejection status');return;}throw new RuntimeException('Expected rejection did not occur');}
function form($visibility='public',$client='create-1'){return ['title'=>'测试房间','category'=>'出游','visibility'=>$visibility,'durationHours'=>24,'description'=>'同事组队','clientId'=>$client];}
function socketOpen($port,$ticket){
    $socket=stream_socket_client('tcp://127.0.0.1:'.$port,$errno,$error,2);stream_set_timeout($socket,3);
    fwrite($socket,"GET /?ticket=$ticket HTTP/1.1\r\nHost: localhost\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: ".base64_encode(random_bytes(16))."\r\nSec-WebSocket-Version: 13\r\n\r\n");
    $headers='';while(!feof($socket)&&substr($headers,-4)!=="\r\n\r\n"){$byte=fread($socket,1);if($byte==='')break;$headers.=$byte;}
    return [$socket,strpos($headers,'101 Switching Protocols')!==false];
}
function socketSend($socket,$body){$mask=random_bytes(4);$masked='';for($i=0;$i<strlen($body);$i++)$masked.=$body[$i]^$mask[$i%4];$length=strlen($body);$header=$length<126?chr(128|$length):chr(128|126).pack('n',$length);fwrite($socket,chr(129).$header.$mask.$masked);}
function socketRead($socket){$header=fread($socket,2);if(strlen($header)!==2)return null;$length=ord($header[1])&127;if($length===126)$length=unpack('n',fread($socket,2))[1];$body='';while(strlen($body)<$length){$part=fread($socket,$length-strlen($body));if($part==='')break;$body.=$part;}return json_decode($body,true);}
function httpCall($port,$action,$data=[],$token='',$method='POST'){
    $headers="Content-Type: application/json\r\n".($token?"Authorization: Bearer $token\r\n":'');
    $context=stream_context_create(['http'=>['method'=>$method,'header'=>$headers,'content'=>json_encode($data),'ignore_errors'=>true,'timeout'=>5]]);
    $body=file_get_contents("http://127.0.0.1:$port/mobile/chat/$action",false,$context);
    preg_match('/\s(\d{3})\s/',$http_response_header[0],$status);
    return [(int)$status[1],json_decode($body,true)];
}
try{
    $cacheScopeProbe=new class($app) extends \app\mobile\controller\OmsBase { public function probeKey($key){return $this->cacheKey($key);} };
    $originalGzhAppId=$app->env->get('wxoms.gzh_appid','');
    try{
        $app->env->set('wxoms.gzh_appid','scope-test-A');$firstGzhKey=$cacheScopeProbe->probeKey('visitor:gzh-token');$firstSessionKey=$cacheScopeProbe->probeKey('session:test');
        $app->env->set('wxoms.gzh_appid','scope-test-B');
        check($firstGzhKey!==$cacheScopeProbe->probeKey('visitor:gzh-token'),'Service account token caches are isolated by AppID');
        check($firstSessionKey===$cacheScopeProbe->probeKey('session:test'),'Service account token namespace does not change login session keys');
    }finally{$app->env->set('wxoms.gzh_appid',$originalGzhAppId);}
    check($beforeChatConnection['charset']===$original['charset'],'Normal default connection retains global charset before chat');
    $afterChatConnection=Db::query('SELECT @@character_set_client AS charset, DATABASE() AS db')[0];
    check($afterChatConnection['charset']==='utf8mb4'&&$afterChatConnection['db']===$beforeChatConnection['db'],'Chat switches existing connection charset without changing database');
    check($app->config->get('database.connections.chat_test.trigger_sql')===false,'Announcement requests disable successful SQL detail logs');
    check($app->config->get('database.default')==='chat_test'&&$app->config->get('database.connections.chat_test.prefix')===$prefix,'Chat preserves default connection name and prefix');
    foreach(chatSchema($prefix)as$sql)Db::execute($sql);
    Db::execute("CREATE TABLE `{$prefix}admin_user_mobile` (id INT PRIMARY KEY,username VARCHAR(20),user_type INT,openid VARCHAR(80)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci");
    Db::execute("CREATE TABLE `{$prefix}member_list` (id INT PRIMARY KEY,eid VARCHAR(20),ename VARCHAR(120),status INT) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_general_ci");
    Db::execute("CREATE TABLE `{$prefix}admin_user` (id INT PRIMARY KEY,username VARCHAR(20),truename VARCHAR(120),status INT,job INT DEFAULT 0) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci");
    Db::execute("CREATE TABLE `{$prefix}auth` (id INT UNSIGNED PRIMARY KEY,username VARCHAR(20),user_type INT,user_id INT,admin_id INT,status INT,openid VARCHAR(80),gzhOpenid VARCHAR(80),version INT) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci");
    foreach([101,102,103]as$id){Db::name('admin_user_mobile')->insert(['id'=>$id,'username'=>'test-'.$id,'user_type'=>1,'openid'=>'test-openid-'.$id]);Db::name('member_list')->insert(['id'=>$id,'eid'=>'test-'.$id,'ename'=>'测试同事'.$id,'status'=>1]);}
    foreach([201=>103,203=>102,204=>101,205=>101]as$id=>$memberId)Db::name('auth')->insert(['id'=>$id,'username'=>'test-'.$memberId,'user_type'=>1,'user_id'=>$memberId,'admin_id'=>0,'status'=>$id===204?0:1,'openid'=>$id===205?null:'new-auth-'.$id,'gzhOpenid'=>'','version'=>1]);
    $pdo=new PDO('mysql:host='.$original['hostname'].';port='.$original['hostport'].';dbname='.$original['database'].';charset=utf8mb4',$original['username'],$original['password'],[PDO::ATTR_ERRMODE=>PDO::ERRMODE_EXCEPTION]);
    $preview=migrateAuthNames($pdo,$prefix,false);
    check($preview['addNameColumn']&&$preview['fillNames']===4&&!$preview['applied'],'Auth name migration previews linked employee names');
    $migration=migrateAuthNames($pdo,$prefix,true);
    $schemaFile=$original['schema_cache_path'].$original['database'].'.'.$prefix.'auth.php';if(is_file($schemaFile))unlink($schemaFile);
    Db::connect('chat_test',true);
    check($migration['applied']&&Db::name('auth')->where('id',201)->value('name')==='测试同事103','Auth name migration adds and fills names');
    check(migrateAuthNames($pdo,$prefix,true)['fillNames']===0,'Auth name migration is repeatable');
    Db::name('chat_room')->insert(['id'=>1,'title'=>'公共大厅','category'=>'其他','visibility'=>'public','description'=>'测试','owner_id'=>0,'client_id'=>'hall','status'=>'open','expires_at'=>null,'created_at'=>ChatService::now()]);
    $owner=$service->user(101);$member=$service->user(102);$outsider=$service->user(103);
    check($owner['isAdmin']&&!$member['isAdmin'],'Explicit administrator assignment');
    $token=str_repeat('a',64);$service->storeSession($token,['id'=>101,'openid'=>'test-openid-101','expiresAt'=>ChatService::now()+60000],60);
    check($service->authenticate($token)['id']===101,'Verified session');
    rejects(function()use($service){$service->authenticate(str_repeat('b',64));},401);
    Db::name('admin_user_mobile')->where('id',101)->update(['openid'=>'changed']);
    rejects(function()use($service,$token){$service->authenticate($token);},401);
    Db::name('admin_user_mobile')->where('id',101)->update(['openid'=>'test-openid-101']);
    Db::name('member_list')->where('id',103)->update(['status'=>0]);
    rejects(function()use($service){$service->user(103);},401);
    Db::name('member_list')->where('id',103)->update(['status'=>1]);
    check($service->perform('hall',$owner,[])['room']['memberStatus']==='joined','Default hall membership');
    $service->perform('hall',$member,[]);
    check(Db::query('SELECT @@character_set_client AS charset')[0]['charset']==='utf8mb4','Default connection supports four-byte emoji');
    $directory=$service->perform('mentionMembers',$owner,['roomId'=>1,'search'=>'测试同事103']);
    check(count($directory['members'])===1&&$directory['members'][0]['id']===ChatService::AUTH_ID_OFFSET+201,'Hall mention directory uses auth names before colleagues enter hall');
    rejects(function()use($service,$owner){$service->perform('send',$owner,['roomId'=>1,'type'=>'text','clientId'=>'hall-legacy-mention','content'=>'拒绝旧账号','mentionIds'=>[103]]);},403);
    $mentionMessage=$service->perform('send',$owner,['roomId'=>1,'type'=>'text','clientId'=>'hall-mention-new','content'=>'😀👍🎉 @测试同事103','mentionIds'=>[ChatService::AUTH_ID_OFFSET+201]])['message'];
    check($mentionMessage['content']==='😀👍🎉 @测试同事103','Emoji round trip through database');
    check(Db::name('chat_member')->where(['room_id'=>1,'user_id'=>ChatService::AUTH_ID_OFFSET+201,'status'=>'joined'])->value('attention')==1,'Mention initializes auth hall membership and recipient attention');
    $listener=stream_socket_server('tcp://127.0.0.1:0',$errno,$error);$httpPort=(int)substr(strrchr(stream_socket_get_name($listener,false),':'),1);fclose($listener);
    $command=escapeshellarg(PHP_BINARY).' -S 127.0.0.1:'.$httpPort.' '.escapeshellarg(__DIR__.'/test-chat-http-router.php');
    $previousTestPrefix=getenv('OMS_CHAT_TEST_PREFIX');putenv('OMS_CHAT_TEST_PREFIX='.$prefix);
    $httpWorker=proc_open($command,[0=>['pipe','r'],1=>['pipe','w'],2=>['pipe','w']],$httpPipes,null,null,['bypass_shell'=>true]);fclose($httpPipes[0]);
    putenv($previousTestPrefix===false?'OMS_CHAT_TEST_PREFIX':'OMS_CHAT_TEST_PREFIX='.$previousTestPrefix);
    $ready=false;for($i=0;$i<30;$i++){$probe=@stream_socket_client('tcp://127.0.0.1:'.$httpPort,$errno,$error,0.1);if($probe){fclose($probe);$ready=true;break;}usleep(100000);}check($ready,'HTTP controller startup');
    check(httpCall($httpPort,'hall')[0]===401,'HTTP authentication required');
    $normalCharset=httpCall($httpPort,'charsetProbe')[1]['data'];
    check($normalCharset['charset']===$original['charset'],'Normal HTTP request uses global connection charset');
    check(httpCall($httpPort,'hall',[],$token,'GET')[0]===405,'HTTP method validation');
    list($httpStatus,$httpBody)=httpCall($httpPort,'hall',[],$token);check($httpStatus===200&&$httpBody['data']['room']['id']===1,'HTTP authenticated hall response');
    list($httpStatus,$httpBody)=httpCall($httpPort,'mentionMembers',['roomId'=>1,'search'=>'','cursor'=>null,'limit'=>20],$token);
    check($httpStatus===200&&count($httpBody['data']['members'])===2,'HTTP mention directory excludes legacy, disabled and unbound accounts');
    list($httpStatus,$httpBody)=httpCall($httpPort,'create',form('private','http-create'),$token);check($httpStatus===200&&$httpBody['data']['room']['visibility']==='private','HTTP JSON request parsing');
    $httpRoom=$httpBody['data']['room']['id'];
    $nativeOriginToken=bin2hex(random_bytes(32));
    $nativeSourceKey='wxoms-v3:'.$original['database'].':session:'.hash('sha256',$nativeOriginToken);
    \think\facade\Cache::store('redis')->set($nativeSourceKey,['id'=>201,'version'=>1,'expires_at'=>time()+3600],3600);
    list($nativeStatus,$nativeBody)=httpCall($httpPort,'sessionV2',[],$nativeOriginToken);
    check($nativeStatus===200&&$nativeBody['data']['user']['id']===ChatService::AUTH_ID_OFFSET+201,'New mini-program uses verified OmsAuth session');
    $nativeUser=$service->authenticate($nativeBody['data']['token']);
    check($nativeUser['employeeNo']==='test-103','New identity resolves employee without legacy binding');
    $directory=$service->perform('mentionMembers',$owner,['roomId'=>1,'search'=>'测试同事103']);
    check(count($directory['members'])===1&&$directory['members'][0]['id']===$nativeUser['id'],'Hall directory only lists auth identity');
    $page=$service->perform('mentionMembers',$owner,['roomId'=>1,'limit'=>1]);
    $pageTwo=$service->perform('mentionMembers',$owner,['roomId'=>1,'limit'=>1,'cursor'=>$page['nextCursor']]);
    check($page['members'][0]['id']!==$pageTwo['members'][0]['id'],'Hall colleague directory paginates');
    Db::name('admin_user')->insert(['id'=>104,'username'=>'admin-104','truename'=>'质量同事😀','status'=>1]);
    Db::name('admin_user_mobile')->insert(['id'=>104,'username'=>'admin-104','user_type'=>2,'openid'=>'legacy-admin-openid']);
    Db::name('auth')->insert(['id'=>202,'username'=>'admin-104','user_type'=>2,'user_id'=>0,'admin_id'=>104,'status'=>1,'openid'=>'native-admin-openid','gzhOpenid'=>'bound','version'=>1]);
    check(migrateAuthNames($pdo,$prefix,true)['fillNames']===1,'Auth name migration fills admin names without replacing existing names');
    $directory=$service->perform('mentionMembers',$owner,['roomId'=>1,'search'=>'质量同事']);
    check(count($directory['members'])===1&&$directory['members'][0]['id']===ChatService::AUTH_ID_OFFSET+202&&$directory['members'][0]['name']==='质量同事😀','Auth directory searches stored admin names including emoji');
    $nativeRoom=$service->perform('create',$nativeUser,form('public','native-create'))['room'];
    check($nativeRoom['ownerId']===$nativeUser['id']&&$nativeRoom['ownerName']==='测试同事103','New identity room ownership');
    $nativeSearch=$service->perform('members',$nativeUser,['roomId'=>$nativeRoom['id'],'search'=>'测试同事103']);
    check(count($nativeSearch['members'])===1&&$nativeSearch['members'][0]['id']===$nativeUser['id'],'New identity member search');
    $service->perform('close',$nativeUser,['roomId'=>$nativeRoom['id']]);
    \think\facade\Cache::store('redis')->delete($nativeSourceKey);
    rejects(function()use($service,$nativeBody){$service->authenticate($nativeBody['data']['token']);},401);
    if(!is_dir($imageRoot))mkdir($imageRoot,0700,true);
    $uploadFixture=$imageRoot.'/fixture.png';file_put_contents($uploadFixture,base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/l9sAAAAASUVORK5CYII='));
    $curl=curl_init('http://127.0.0.1:'.$httpPort.'/mobile/chat/image');curl_setopt_array($curl,[CURLOPT_POST=>true,CURLOPT_RETURNTRANSFER=>true,CURLOPT_TIMEOUT=>5,CURLOPT_HTTPHEADER=>['Authorization: Bearer '.$token],CURLOPT_POSTFIELDS=>['roomId'=>$httpRoom,'image'=>new CURLFile($uploadFixture,'image/png','image.png')]]);
    $imageBody=json_decode(curl_exec($curl),true);check(curl_getinfo($curl,CURLINFO_HTTP_CODE)===200&&!empty($imageBody['data']['imageId']),'HTTP image upload');curl_close($curl);
    $imageMessage=$service->perform('send',$owner,['roomId'=>$httpRoom,'type'=>'image','clientId'=>'image-message','imageId'=>$imageBody['data']['imageId'],'mentionIds'=>[]])['message'];
    parse_str(parse_url($imageMessage['imageUrl'],PHP_URL_QUERY),$imageQuery);
    check(httpCall($httpPort,'imageContent?ticket='.$imageQuery['ticket'],[],'','GET')[0]===200,'Private image authorized download');
    rejects(function()use($service){$service->imageContent(str_repeat('c',64));},403);
    $service->perform('close',$owner,['roomId'=>$httpRoom]);
    check(httpCall($httpPort,'room',['roomId'=>'invalid'],$token)[0]===400,'HTTP invalid room parameter');
    $normalAfterChat=httpCall($httpPort,'charsetProbe')[1]['data'];
    check($normalAfterChat['charset']===$original['charset']&&$normalAfterChat['db']===$original['database'],'Normal request after chat remains on original charset and database');
    proc_terminate($httpWorker);fclose($httpPipes[1]);fclose($httpPipes[2]);proc_close($httpWorker);$httpWorker=null;
    $listener=stream_socket_server('tcp://127.0.0.1:0',$errno,$error);$port=(int)substr(strrchr(stream_socket_get_name($listener,false),':'),1);fclose($listener);
    $command=escapeshellarg(PHP_BINARY).' '.escapeshellarg(__FILE__).' socket-worker '.escapeshellarg($prefix).' '.$port;
    $socketWorker=proc_open($command,[0=>['pipe','r'],1=>['pipe','w'],2=>['pipe','w']],$workerPipes,null,null,['bypass_shell'=>true]);fclose($workerPipes[0]);
    $ready=false;for($i=0;$i<30;$i++){$probe=@stream_socket_client('tcp://127.0.0.1:'.$port,$errno,$error,0.1);if($probe){fclose($probe);$ready=true;break;}usleep(100000);}check($ready,'WebSocket worker startup');
    $ticketUrl=$service->perform('connect',$member,['roomId'=>1])['url'];parse_str(parse_url($ticketUrl,PHP_URL_QUERY),$ticketQuery);
    list($chatSocket,$accepted)=socketOpen($port,$ticketQuery['ticket']);check($accepted,'Verified WebSocket handshake');
    check(socketRead($chatSocket)['sendViaSocket']===true,'Worker announces supported send protocol');
    socketSend($chatSocket,'{"type":"ping"}');check(socketRead($chatSocket)['type']==='pong','WebSocket heartbeat');
    check(strpos(chatSocketProbe($service,$member,1,'ws://127.0.0.1:'.$port.'/oms-chat'),'101 + pong')===0,'Deployment probe verifies handshake and heartbeat without sending a message');
    list($reusedSocket,$reused)=socketOpen($port,$ticketQuery['ticket']);check(!$reused,'Handshake ticket cannot be replayed');fclose($reusedSocket);
    $service->perform('send',$owner,['roomId'=>1,'type'=>'text','clientId'=>'socket-message','content'=>'实时消息','mentionIds'=>[]]);
    check(socketRead($chatSocket)['type']==='sync','Cross-process WebSocket message notification');
    $memberToken=bin2hex(random_bytes(32));$service->storeSession($memberToken,['id'=>102,'openid'=>'test-openid-102','expiresAt'=>ChatService::now()+60000],60);
    $secondUrl=$service->perform('connect',$member,['roomId'=>1])['url'];parse_str(parse_url($secondUrl,PHP_URL_QUERY),$secondTicket);
    list($secondSocket,$secondAccepted)=socketOpen($port,$secondTicket['ticket']);check($secondAccepted&&socketRead($secondSocket)['type']==='ready','Same account can connect on a second device');
    $socketPayload=['roomId'=>1,'type'=>'text','clientId'=>'socket-send-ack','content'=>str_repeat('实时发送 😀👍🎉',40),'mentionIds'=>[]];
    $start=microtime(true);socketSend($chatSocket,json_encode(['type'=>'send','token'=>$memberToken,'data'=>$socketPayload]));$ack=socketRead($chatSocket);
    check($ack['type']==='send-result'&&$ack['code']===0&&$ack['data']['message']['content']===$socketPayload['content'],'WebSocket send acknowledged after saving');
    echo 'Local WebSocket save acknowledgement: '.round((microtime(true)-$start)*1000).' ms'.PHP_EOL;
    check(socketRead($chatSocket)['type']==='sync'&&socketRead($secondSocket)['type']==='sync','Same account devices both receive message notification');
    check(count(array_filter($service->perform('messages',$member,['roomId'=>1])['messages'],function($message){return $message['clientId']==='socket-send-ack';}))===1,'Second device can retrieve the newly sent message');
    socketSend($chatSocket,json_encode(['type'=>'send','token'=>$memberToken,'data'=>$socketPayload]));check(socketRead($chatSocket)['code']===0,'WebSocket retry returns saved message');
    check(Db::name('chat_message')->where('client_id','socket-send-ack')->count()===1,'WebSocket and HTTP retries remain idempotent');
    // 消费幂等重试触发的同步事件，随后只验证拒绝响应。
    socketRead($chatSocket);socketRead($secondSocket);
    socketSend($chatSocket,json_encode(['type'=>'send','token'=>$token,'data'=>array_merge($socketPayload,['clientId'=>'wrong-user'])]));check(socketRead($chatSocket)['status']===403,'Socket cannot send as a different authenticated account');
    socketSend($chatSocket,json_encode(['type'=>'send','token'=>$memberToken,'data'=>array_merge($socketPayload,['clientId'=>'wrong-room','roomId'=>999]) ]));check(socketRead($chatSocket)['status']===403,'Socket cannot send to a different room');
    socketSend($chatSocket,json_encode(['type'=>'send','token'=>str_repeat('b',64),'data'=>array_merge($socketPayload,['clientId'=>'expired-token'])]));check(socketRead($chatSocket)['status']===401,'Socket checks authentication on each message');
    fclose($secondSocket);
    fclose($chatSocket);proc_terminate($socketWorker);fclose($workerPipes[1]);fclose($workerPipes[2]);proc_close($socketWorker);$socketWorker=null;
    $private=$service->perform('create',$owner,form('private'))['room'];$roomId=$private['id'];
    $privateDirectory=$service->perform('mentionMembers',$owner,['roomId'=>$roomId]);
    check(count($privateDirectory['members'])===1&&$privateDirectory['members'][0]['id']===$owner['id'],'Private mention directory cannot expose non-members');
    check($service->perform('create',$owner,form('private'))['room']['id']===$roomId,'Idempotent room creation');
    rejects(function()use($service,$outsider,$roomId){$service->perform('room',$outsider,['roomId'=>$roomId]);},403);
    rejects(function()use($service,$outsider,$roomId){$service->perform('messages',$outsider,['roomId'=>$roomId]);},403);
    $invite=$service->perform('invite',$owner,['roomId'=>$roomId])['token'];
    check($service->perform('room',$member,['invite'=>$invite])['room']['id']===$roomId,'Invitation preview');
    $service->perform('join',$member,['roomId'=>$roomId,'invite'=>$invite]);
    rejects(function()use($service,$member,$roomId){$service->perform('messages',$member,['roomId'=>$roomId]);},403);
    $service->perform('approve',$owner,['roomId'=>$roomId,'memberId'=>102,'accepted'=>true]);
    check($service->perform('room',$member,['roomId'=>$roomId])['room']['memberStatus']==='joined','Private room approval');
    $found=$service->perform('members',$owner,['roomId'=>$roomId,'search'=>'测试同事102']);
    check(count($found['members'])===1&&$found['members'][0]['id']===102,'Member name search');
    $sent=['roomId'=>$roomId,'type'=>'text','clientId'=>'send-1','content'=>'周末出游','mentionIds'=>[102]];
    $message=$service->perform('send',$owner,$sent)['message'];
    check($service->perform('send',$owner,$sent)['message']['id']===$message['id'],'Idempotent sending');
    check(Db::name('chat_member')->where(['room_id'=>$roomId,'user_id'=>102])->value('attention')==1,'Mention attention');
    $service->perform('read',$member,['roomId'=>$roomId,'sequence'=>0]);
    check(Db::name('chat_member')->where(['room_id'=>$roomId,'user_id'=>102])->value('attention')==1,'Unread mention is not cleared by an old read position');
    check($service->perform('attention',$member,[])['attention'],'Cross-page reminder state');
    $service->perform('read',$member,['roomId'=>$roomId,'sequence'=>$message['id']]);
    check(Db::name('chat_member')->where(['room_id'=>$roomId,'user_id'=>102])->value('attention')==0,'Reading the mention clears attention');
    $queriesBefore=Db::getQueryTimes();
    $history=$service->perform('messages',$member,['roomId'=>$roomId]);
    $coldQueries=Db::getQueryTimes()-$queriesBefore;$queriesBefore=Db::getQueryTimes();
    $cachedHistory=$service->perform('messages',$member,['roomId'=>$roomId]);
    check($cachedHistory['messages']===$history['messages'] && Db::getQueryTimes()-$queriesBefore<$coldQueries,'Redis page cache avoids message queries');
    $idle=$service->perform('messages',$member,['roomId'=>$roomId,'cursor'=>$history['cursor']]);
    check(!$idle['messages'] && $idle['cursor']===$history['cursor'],'Unchanged cursor returns empty increment');
    check(count($history['messages'])===1,'Member history access');
    $reply=$service->perform('send',$member,['roomId'=>$roomId,'type'=>'text','clientId'=>'reply-1','content'=>'好的','replyId'=>$message['id'],'mentionIds'=>[]])['message'];
    rejects(function()use($service,$member,$roomId,$message){$service->perform('recall',$member,['roomId'=>$roomId,'messageId'=>$message['id']]);},403);
    $service->perform('recall',$owner,['roomId'=>$roomId,'messageId'=>$message['id']]);
    $updates=$service->perform('messages',$member,['roomId'=>$roomId,'cursor'=>$history['cursor']]);
    check(count($updates['messages'])===2,'Recall updates original and quoted message');
    $replyUpdate=array_values(array_filter($updates['messages'],function($item)use($reply){return $item['id']===$reply['id'];}))[0];
    check($replyUpdate['reply']['recalled'],'Quote recall synchronization');
    $newMention=$service->perform('send',$owner,['roomId'=>$roomId,'type'=>'text','clientId'=>'mention-2','content'=>'新提及','mentionIds'=>[102]])['message'];
    $service->perform('read',$member,['roomId'=>$roomId,'sequence'=>$reply['id']]);
    check(Db::name('chat_member')->where(['room_id'=>$roomId,'user_id'=>102])->value('attention')==1,'A stale read cannot erase a newer mention');
    $page=$service->perform('messages',$member,['roomId'=>$roomId,'limit'=>1]);check($page['hasMore']&&$page['messages'][0]['id']===$newMention['id'],'Latest message pagination');
    $older=$service->perform('messages',$member,['roomId'=>$roomId,'before'=>$page['olderCursor'],'limit'=>1]);check($older['messages'][0]['id']===$reply['id'],'Older message pagination');
    $service->perform('mute',$owner,['roomId'=>$roomId,'memberId'=>102,'muted'=>true]);
    rejects(function()use($service,$member,$roomId){$service->perform('send',$member,['roomId'=>$roomId,'type'=>'text','clientId'=>'muted-1','content'=>'拒绝','mentionIds'=>[]]);},403);
    $service->perform('mute',$owner,['roomId'=>$roomId,'memberId'=>102,'muted'=>false]);
    $service->perform('report',$member,['roomId'=>$roomId,'messageId'=>$message['id'],'reason'=>'测试举报']);
    rejects(function()use($service,$member){$service->perform('adminReports',$member,[]);},403);
    check(count($service->perform('adminReports',$owner,[])['reports'])===1,'Administrator report review');
    $service->perform('resolveReport',$owner,['roomId'=>$roomId,'reportId'=>1,'resolution'=>'dismiss']);
    check(Db::name('chat_report')->find(1)['status']==='resolved','Report handling audit');
    $oldExpiry=$private['expiresAt'];$service->perform('extend',$owner,['roomId'=>$roomId,'hours'=>24]);
    check((int)Db::name('chat_room')->find($roomId)['expires_at']===$oldExpiry+86400000,'Extension starts at previous expiry');
    rejects(function()use($service,$member,$roomId){$service->perform('extend',$member,['roomId'=>$roomId,'hours'=>24]);},403);
    $public=$service->perform('create',$owner,form('public','create-2'))['room'];
    $service->perform('join',$member,['roomId'=>$public['id']]);
    Db::name('chat_room')->where('id',$public['id'])->update(['member_count'=>100]);
    rejects(function()use($service,$outsider,$public){$service->perform('join',$outsider,['roomId'=>$public['id']]);},409);
    Db::name('chat_room')->where('id',$public['id'])->update(['member_count'=>2]);
    $publicRooms=$service->perform('rooms',$outsider,['scope'=>'public']);
    check(count($publicRooms['rooms'])===1&&$publicRooms['rooms'][0]['id']===$public['id'],'Private room excluded from discovery');
    $service->perform('create',$owner,form('public','create-3'));
    rejects(function()use($service,$owner){$service->perform('create',$owner,form('public','create-4'));},409);
    Db::name('chat_message')->where('id',$reply['id'])->update(['created_at'=>ChatService::now()-120001]);
    rejects(function()use($service,$member,$roomId,$reply){$service->perform('recall',$member,['roomId'=>$roomId,'messageId'=>$reply['id']]);},409);
    $service->perform('remove',$owner,['roomId'=>$roomId,'memberId'=>102]);
    rejects(function()use($service,$member,$roomId){$service->perform('messages',$member,['roomId'=>$roomId]);},403);
    rejects(function()use($service,$member,$roomId,$invite){$service->perform('join',$member,['roomId'=>$roomId,'invite'=>$invite]);},403);
    Db::name('chat_room')->where('id',$roomId)->update(['expires_at'=>ChatService::now()+3000000]);
    $service->maintain();$service->maintain();
    check(Db::name('chat_message')->where('room_id',$roomId)->whereLike('content','%1 小时内%')->count()===1,'One hour reminder is idempotent');
    Db::name('chat_room')->where('id',$roomId)->update(['expires_at'=>ChatService::now()+300000]);$service->maintain();
    check(Db::name('chat_message')->where('room_id',$roomId)->whereLike('content','%10 分钟内%')->count()===1,'Ten minute reminder');
    Db::name('chat_room')->where('id',$roomId)->update(['expires_at'=>ChatService::now()-1]);
    $processes=[];
    for($i=0;$i<2;$i++){
        $command=escapeshellarg(PHP_BINARY).' '.escapeshellarg(__FILE__).' race-expire '.escapeshellarg($prefix).' '.(int)$roomId;
        $process=proc_open($command,[0=>['pipe','r'],1=>['pipe','w'],2=>['pipe','w']],$pipes,null,null,['bypass_shell'=>true]);
        fclose($pipes[0]);
        $processes[]=[$process,$pipes];
    }
    foreach($processes as$childProcess){$output=stream_get_contents($childProcess[1][1]);$childError=stream_get_contents($childProcess[1][2]);fclose($childProcess[1][1]);fclose($childProcess[1][2]);$code=proc_close($childProcess[0]);if($code!==0)fwrite(STDERR,$output.$childError);check($code===0,'Concurrent expiry worker status '.$code);}
    check(Db::name('chat_message')->where(['room_id'=>$roomId,'type'=>'system'])->whereLike('content','%到期关闭%')->count()===1,'Concurrent expiry creates one close event');
    check(Db::name('chat_room')->find($roomId)['status']==='closed','Automatic room closure');
    rejects(function()use($service,$owner,$roomId){$service->perform('extend',$owner,['roomId'=>$roomId,'hours'=>24]);},409);
    rejects(function()use($service,$owner,$roomId){$service->perform('send',$owner,['roomId'=>$roomId,'type'=>'text','clientId'=>'closed-1','content'=>'拒绝','mentionIds'=>[]]);},409);
    check(count($service->perform('messages',$owner,['roomId'=>$roomId])['messages'])>=3,'Closed history remains readable');
    rejects(function()use($service,$outsider,$invite){$service->perform('room',$outsider,['invite'=>$invite]);},403);
    $service->perform('close',$owner,['roomId'=>$public['id']]);
    Db::name('chat_room')->where('id',$public['id'])->update(['closed_at'=>ChatService::now()-31*86400000]);
    $service->maintain();check(!Db::name('chat_room')->find($public['id']),'History retention cleanup');
    echo "Chat integration checks passed: $checks\n";
}catch(\Throwable $error){
    foreach($error->getTrace()as$frame)if(isset($frame['file'])&&basename($frame['file'])==='test-chat.php')fwrite(STDERR,'Test call at line '.$frame['line'].PHP_EOL);
    if($error instanceof \think\db\exception\PDOException){$info=$error->getData()['PDO Error Info']??[];fwrite(STDERR,'Test database driver: '.($info['Driver Error Code']??'unknown').'; '.substr((string)($info['Driver Error Message']??''),0,250).PHP_EOL);}
    fwrite(STDERR,'Chat test failed after '.$checks.' checks: '.get_class($error).' at '.basename($error->getFile()).':'.$error->getLine().($error instanceof RuntimeException||$error instanceof \think\exception\ErrorException?' '.$error->getMessage():'').PHP_EOL);
    $failed=true;
}finally{
    if(!empty($httpWorker)&&is_resource($httpWorker)){proc_terminate($httpWorker);foreach([1,2]as$i)if(is_resource($httpPipes[$i]))fclose($httpPipes[$i]);proc_close($httpWorker);}
    if(!empty($socketWorker)&&is_resource($socketWorker)){proc_terminate($socketWorker);foreach([1,2]as$i)if(is_resource($workerPipes[$i]))fclose($workerPipes[$i]);proc_close($socketWorker);}
    // 只删除本次随机测试前缀创建的表。
    foreach($tables as$table)Db::execute("DROP TABLE IF EXISTS `{$prefix}{$table}`");
    $keys=$service->redis()->keys($service->key('*'));if($keys)$service->redis()->del($keys);
    if(!empty($nativeSourceKey))\think\facade\Cache::store('redis')->delete($nativeSourceKey);
    if(is_dir($imageRoot)){foreach(scandir($imageRoot)as$file)if($file==='fixture.png'||preg_match('/^[a-f0-9]{64}\.(jpg|png|gif)$/',$file))unlink($imageRoot.'/'.$file);rmdir($imageRoot);}
}
exit(empty($failed)?0:1);
