using System.Net.WebSockets;
using System.Text;
using System.Text.Json;
using MessagePack;
using Microsoft.Extensions.Logging.Abstractions;
using NUnit.Framework;
using SVSim.BattleNode.Bridge;
using SVSim.BattleNode.Protocol;
using SVSim.BattleNode.Sessions;
using SVSim.BattleNode.Sessions.Participants;
using SVSim.BattleNode.Wire;
using SVSim.UnitTests.BattleNode.Infrastructure;
namespace SVSim.UnitTests.BattleNode.Sessions.Participants;
///
/// Regression tests for the "hand" SIO event handler. The active bug at
/// docs/audits/battle-node-sio-events-2026-06-02.md ยง"Active bug" was:
/// client-stocked SELECT_SKILL_URI / SLIDE_OBJECT_URI hand emits arrive
/// with an ack-id; without a server ack, the client's stockEmitMessageMgr deadlocks
/// behind them and every subsequent emit (PlayActions, TurnEndActions, TurnEnd) is queued
/// but never transmitted. These tests drive a hand frame through the WS read loop and
/// inspect the outbound text-frame queue for the SIO ack.
///
[TestFixture]
public class RealParticipantHandEventTests
{
// Any 32-char ASCII string is a valid AES key for NodeCrypto โ DecryptForNode reads the
// key from the first 32 chars of the encrypted blob.
private const string TestKey = "abcdefghijklmnopqrstuvwxyz012345";
[Test]
public async Task Stocked_hand_event_acks_with_body_pubSeq()
{
var ws = new TestWebSocket();
var p = new RealParticipant(ws, viewerId: 906_243_102L, FixtureCtx(),
NullLogger.Instance);
// Client-shape hand body for a SELECT_SKILL emit: StockHandData[0] = uri_int
// (HAND_URI_TYPE.SELECT_SKILL_URI = 2), top-level "try" + "pubSeq" added by
// EmitMsgUriPack. The handler reads top-level "pubSeq" โ body contents otherwise
// don't matter for ack semantics.
const long expectedPubSeq = 42L;
var body = $"{{\"StockHandData\":[2,906243102,\"u\",1,0,{expectedPubSeq}],\"try\":0,\"pubSeq\":{expectedPubSeq}}}";
EnqueueHandFrame(ws, ackId: 26, body: body);
ws.CompleteIncoming();
await p.RunAsync(CancellationToken.None);
var ackFrame = FindAckFrame(ws, ackId: 26);
Assert.That(ackFrame, Is.Not.Null,
$"Expected an SIO Ack frame for ackId=26 in outbound sends; got: [{string.Join(", ", AllTextSends(ws))}]");
Assert.That(ackFrame, Does.Contain($"[{expectedPubSeq}]"),
"Ack arg must echo the body's pubSeq so the client's stockEmitMessageMgr.GetSelectData lookup succeeds.");
}
[Test]
public async Task Hand_event_without_ackId_is_swallowed_silently_no_ack_sent()
{
// Fire-and-forget hand emits (TOUCH_URI, SELECT_OBJECT_URI, TURN_END_READY_URI) arrive
// without an ack-id and don't block the client's emit queue. We should swallow them
// without trying to decode or ack.
var ws = new TestWebSocket();
var p = new RealParticipant(ws, viewerId: 1, FixtureCtx(),
NullLogger.Instance);
var body = "{\"StockHandData\":[1,1,\"u\",0],\"try\":0}";
EnqueueHandFrame(ws, ackId: null, body: body);
ws.CompleteIncoming();
await p.RunAsync(CancellationToken.None);
// Only the EIO Open handshake should be in Sends; no Ack frame.
var ackFrames = AllTextSends(ws).Where(s => s.StartsWith("43")).ToList();
Assert.That(ackFrames, Is.Empty,
$"No-ack-id hand frame must not produce an Ack; got: [{string.Join(", ", ackFrames)}]");
}
[Test]
public async Task Hand_event_with_missing_pubSeq_falls_back_to_ack_arg_0()
{
// If a stocked hand frame ever arrives without a pubSeq, we still ack so the
// client doesn't softlock โ but with arg=0 (the client's GetSelectData lookup
// will miss and OnAck fires with null selectData, which is the same path as a
// normal cache-miss; not great, but not a deadlock).
var ws = new TestWebSocket();
var p = new RealParticipant(ws, viewerId: 1, FixtureCtx(),
NullLogger.Instance);
var body = "{\"StockHandData\":[2,1,\"u\"],\"try\":0}"; // no pubSeq
EnqueueHandFrame(ws, ackId: 99, body: body);
ws.CompleteIncoming();
await p.RunAsync(CancellationToken.None);
var ackFrame = FindAckFrame(ws, ackId: 99);
Assert.That(ackFrame, Is.Not.Null,
"Missing-pubSeq fallback should still ack (arg=0), not silently swallow.");
Assert.That(ackFrame, Does.Contain("[0]"),
"Fallback ack arg should be 0.");
}
// -----------------------------------------------------------------------
// Helpers
// -----------------------------------------------------------------------
///
/// Enqueue an SIO BinaryEvent("hand", {placeholder}) text frame followed by its single
/// binary attachment (NodeCrypto-encrypted msgpack-string of ).
/// Wire format mirrors what EmitFrontStockData:717-720 emits.
///
private static void EnqueueHandFrame(TestWebSocket ws, int? ackId, string body)
{
// Text frame: 4 (EIO Message) + 5 (SIO BinaryEvent) + 1- (1 attachment) + ackId + json
var ackPart = ackId.HasValue ? ackId.Value.ToString() : "";
var text = $"451-{ackPart}[\"hand\",{{\"_placeholder\":true,\"num\":0}}]";
ws.EnqueueIncoming(Encoding.UTF8.GetBytes(text), WebSocketMessageType.Text);
// Binary attachment: EIO Message prefix (0x04) + msgpack-string(NodeCrypto.Encrypt(json)).
var encrypted = NodeCrypto.EncryptForNode(body, TestKey);
var msgpackBytes = MessagePackSerializer.Serialize(encrypted);
var prefixed = new byte[msgpackBytes.Length + 1];
prefixed[0] = (byte)EngineIoPacketType.Message;
Buffer.BlockCopy(msgpackBytes, 0, prefixed, 1, msgpackBytes.Length);
ws.EnqueueIncoming(prefixed, WebSocketMessageType.Binary);
}
private static IEnumerable AllTextSends(TestWebSocket ws) =>
ws.Sends
.Where(f => f.Type == WebSocketMessageType.Text)
.Select(f => Encoding.UTF8.GetString(f.Payload));
///
/// SIO Ack wire form: EIO Message (4) + SIO Ack (3) + ackId + [arg]. Filter the text
/// sends for that shape.
///
private static string? FindAckFrame(TestWebSocket ws, int ackId) =>
AllTextSends(ws).FirstOrDefault(s => s.StartsWith($"43{ackId}["));
private static MatchContext FixtureCtx() => new(
SelfDeckCardIds: Enumerable.Range(1, 30).Select(_ => 100_011_010L).ToList(),
ClassId: "1", CharaId: "1", CardMasterName: "card_master_node_10015",
CountryCode: "KOR", UserName: "Player", SleeveId: "3000011",
EmblemId: "701441011", DegreeId: "300003", FieldId: 43, IsOfficial: 0,
BattleType: 11);
}