extern alias engine;
using BattleAmbient = engine::SVSim.BattleEngine.Ambient.BattleAmbient;
using BattleAmbientContext = engine::SVSim.BattleEngine.Ambient.BattleAmbientContext;
using System.Reflection;
using System.Runtime.Serialization;
using engine::SVSim.BattleEngine.Rng;
using SVSim.BattleNode.Protocol;
using NetworkBattleReceiver = engine::NetworkBattleReceiver;
using NetworkBattleDefine = engine::NetworkBattleDefine;
using BattleManagerBase = engine::BattleManagerBase;
using BattlePlayerBase = engine::BattlePlayerBase;
using BattleCardBase = engine::BattleCardBase;
using UnitBattleCard = engine::UnitBattleCard;
using ClassBattleCardBase = engine::ClassBattleCardBase;
using CardCreatorBase = engine::CardCreatorBase;
using CostAddModifier = engine::CostAddModifier;
using SBattleLoad = engine::SBattleLoad;
using CardTemplate = engine::CardTemplate;
using GameObject = engine::UnityEngine.GameObject;
using RealTimeNetworkAgent = engine::RealTimeNetworkAgent;
using Gungnir = engine::Gungnir;
using NetworkNullLogger = engine::NetworkNullLogger;
using ToolboxGame = engine::Wizard.ToolboxGame;
using GameMgr = engine::GameMgr;
using BattleUIContainer = engine::BattleUIContainer;
using BackGroundBase = engine::BackGroundBase;
using NullPlayerEmotion = engine::Wizard.Battle.Player.Emotion.NullPlayerEmotion;
using NetworkMulliganPhase = engine::Wizard.Battle.Phase.NetworkMulliganPhase;
using MulliganInfoControl = engine::Wizard.Battle.Mulligan.MulliganInfoControl;
using UIWidget = engine::UIWidget;
using UISprite = engine::UISprite;
using NullDetailPanelControl = engine::NullDetailPanelControl;
using DetailPanelControl = engine::DetailPanelControl;
using BattleLogManager = engine::Wizard.Battle.UI.BattleLogManager;

namespace SVSim.BattleNode.Sessions.Engine;

/// <summary>One authoritative engine per BattleSession, seated as both players (design ND2). A faithful
/// SHADOW: it mirrors each client's resolved play, never overrides/rejects/originates (ND1). Ingest is
/// the engine's own NetworkBattleReceiver.ReceivedMessage (ND4); isPlayer selects the seat (F-N-2).
///
/// The headless wiring here is the production analogue of the test HeadlessFixture
/// (NewNetworkEmitBattle / SeedDeck / InitLeaderLife / InitCardTemplates). It deliberately omits the
/// emit-only RealTimeNetworkAgent scaffolding the test uses for the SEND path — the shadow engine only
/// RECEIVES (F-N-2), so no socket-agent is constructed. The engine's global init (CardMaster, GameMgr,
/// Wizard.Data) is the caller's responsibility (the test does HeadlessEngineEnv.EnsureInitialized;
/// the live node guards Setup in try/catch so an un-initialized host degrades to a no-op shadow).</summary>
internal sealed class SessionBattleEngine
{
    private const int DefaultLeaderLife = 20;

    private readonly BattleAmbientContext _ctx = new() {
        ViewerId = EngineGlobalInit.ThisViewerId,
        IsForecast = true,
        IsRandomDraw = true,
        // Per-session BattleRecoveryInfo: the receive-conductor deal path runs under IsRecovery
        // (set after mgr construction below) and reads Data.BattleRecoveryInfo.IsMulliganEnd in
        // MulliganMgrBase.StartDeal — null reads NRE. Each session owns its own no-op instance with
        // IsMulliganEnd=false (the default); GetUninitializedObject skips the JsonData ctor. Each
        // SessionBattleEngine carries its own ambient _ctx, so per-session isolation is by construction
        // (the EngineGlobalInit fallback only seeded once-per-process and silently fell over for the
        // second + later session that entered a fresh ambient — diagnosed Task 7).
        RecoveryInfo = (engine::Wizard.BattleRecoveryInfo)FormatterServices
            .GetUninitializedObject(typeof(engine::Wizard.BattleRecoveryInfo)),
    };

    private HeadlessNetworkBattleMgr? _mgr;
    private NetworkBattleReceiver? _receiver;

    /// <summary>True once Setup has built the two-seat battle.</summary>
    public bool IsReady => _mgr is not null;

    /// <summary>Construct the two-seat network battle from both decks + the master seed (design F-N-5).
    /// <paramref name="seatADeck"/>/<paramref name="seatBDeck"/> are the per-side deck orders the node
    /// already computed (BattleSessionState.GetShuffledDeck) and handed each client.
    /// <paramref name="seatAClass"/>/<paramref name="seatBClass"/> are each seat's class ordinal (1..8,
    /// the <c>CardClass</c> int value); they select the leader's class via the all-8-class
    /// ClassCharacterList EngineGlobalInit installs (chara_id == class_id for 1..8). The 3-arg overload
    /// behavior is preserved by the defaults (1/2), matching the test-harness charaIds.
    /// <para>NOTE: GameMgr is now per-session via <see cref="BattleAmbientContext.GameMgr"/>; the leader
    /// chara ids are set on the SESSION's GameMgr (resolved through the ambient by
    /// <c>EngineGlobalInit.WirePerSessionGameMgr</c>), not on a process-wide singleton. This is the Task-7
    /// payoff: concurrent sessions each own their own GameMgr + engine state, so the historical
    /// single-active-engine gate (deleted EngineSessionGate) is no longer needed.</para></summary>
    public void Setup(int masterSeed,
        IReadOnlyList<long> seatADeck, IReadOnlyList<long> seatBDeck,
        int seatAClass = 1, int seatBClass = 2)
    {
        using var _ambient = BattleAmbient.Enter(_ctx);
        SetupInternal(masterSeed, seatADeck, seatBDeck, seatAClass, seatBClass, rng: null);
    }

    /// <summary>TEST/DEBUG SEAM (Phase 4 Option-A viability PROBE — NOT a production fix). Identical to
    /// <see cref="Setup(int, IReadOnlyList{long}, IReadOnlyList{long}, int, int)"/> but installs a logging
    /// RNG source that, on EVERY <c>StableRandom</c>/<c>StableRandomDouble</c> roll, records a roll entry
    /// (call index, API, the seat signals readable from mgr state at roll time, and the live call stack).
    /// Lets a test answer: at roll time, is the ACTING SEAT determinable from mgr state alone, or only from
    /// the stack? No production path calls this.</summary>
    internal IReadOnlyList<RollEntry> DebugSetupWithRollLog(int masterSeed,
        IReadOnlyList<long> seatADeck, IReadOnlyList<long> seatBDeck,
        int seatAClass = 1, int seatBClass = 2)
    {
        using var _ambient = BattleAmbient.Enter(_ctx);
        var log = new List<RollEntry>();
        // The logger needs the mgr to read seat signals at roll time; the mgr is built inside Setup, so the
        // logger reads it lazily via a closure populated right after construction.
        HeadlessNetworkBattleMgr[] mgrBox = { null! };
        var rng = new RollLoggingRandomSource(new SeededRandomSource(masterSeed), log, () => mgrBox[0]);
        SetupInternal(masterSeed, seatADeck, seatBDeck, seatAClass, seatBClass, rng, mgrBox);
        return log;
    }

    private void SetupInternal(int masterSeed,
        IReadOnlyList<long> seatADeck, IReadOnlyList<long> seatBDeck,
        int seatAClass, int seatBClass,
        IRandomSource? rng, HeadlessNetworkBattleMgr[]? mgrBox = null)
    {
        // Prime the engine's process-global statics (CardMaster, Wizard.Data, all-8-class Master,
        // GameMgr/netUser/udid). Idempotent (process-once); makes the LIVE host ready so Setup succeeds
        // here rather than throwing into the shadow's no-op path (Phase 2 N2, carried-risk A).
        EngineGlobalInit.EnsureInitialized();

        // rng defaults to SeededRandomSource(masterSeed) inside the mgr — masterSeed here is the
        // engine's StableRandom seed (parameter name preserved for API compatibility; callers pass
        // BattleSeeds.Stable(rootMasterSeed) so the stream is born aligned with the seed the node
        // ships to both clients in Matched.seed). F-N-5; O-N-2 "bit-aligned anyway".
        var mgr = new HeadlessNetworkBattleMgr(new SessionContentsCreator(masterSeed), rng);
        if (mgrBox is not null) mgrBox[0] = mgr; // publish for the test roll-logger closure (DebugSetupWithRollLog)
        // Recovery mode is the engine's OWN headless replay path: the live view/UI touches on the
        // receive cycle (BattleUIContainer.DisableMenu, turn-control UI, card-view creation, VFX
        // waits) are all gated `!IsRecovery` (BattleUIContainer.cs:130, BattleManagerBase.cs:1499+),
        // so this collapses them to no-ops without changing authoritative state. Set AFTER construction
        // so the ctor still wired the LIVE NetworkBattleReceiver (ND4) rather than the replay receiver.
        // Safe for shadow: the only thing !IsRecovery additionally enables is EMIT, which a pure shadow
        // never does (it never originates a send).
        mgr.IsRecovery = true;

        // Seat each player as the other's opponent (private field on BattlePlayerBase, as the real
        // match-load does). Mirrors HeadlessFixture.NewNetworkEmitBattle.
        BattlePlayerBase player = mgr.GetBattlePlayer(isPlayer: true);
        BattlePlayerBase enemy = mgr.GetBattlePlayer(isPlayer: false);
        SetField(player, "_opponentBattlePlayer", enemy);
        SetField(enemy, "_opponentBattlePlayer", player);
        player.IsSelfTurn = true;
        enemy.IsSelfTurn = false;

        // Participant A always goes first (LoadedHandler gives A TurnState.First). The engine's
        // BattlePlayer = isPlayer=true = seat A, so doesPlayerGoFirst must be true. This controls:
        //   (1) SetupEvolCount: first player gets FIRST_PLAYER_EP (2) + wait 5,
        //       second player gets SECOND_PLAYER_EP (3) + wait 4
        //   (2) IsFirst → BattlePlayer.IsGameFirst / BattleEnemy.IsGameFirst → turn-1 draw count:
        //       first player draws 1, second draws 2 (BattlePlayerBase.TurnStartDrawCard)
        mgr.IsFirst = true;
        mgr.SetupEvolCount(doesPlayerGoFirst: true);

        // The real match-load's SetupInitialGameState(areCardsRandomlyDrawn:true) sets this flag
        // (BattleManagerBase.cs:1110), routing LotteryRandomDrawCard through seeded StableRandom
        // instead of top-of-deck. Without it the shadow draws DeckCardList[0] every time while
        // clients draw seeded-random — desynchronizing the hand and every downstream field.
        BattleManagerBase.IsRandomDraw = true;

        InitLeaderLife(mgr);       // a 0-life leader reads as game-over and silently blocks plays
        InitCardTemplates(mgr);    // play/draw resolution touches the (no-op) card view layer
        InitHeadlessViews(mgr);    // turn/play cycle dereferences UI-container + emotion refs
        SeedBattleLogManager();    // per-frame filter cleanup reads BattleLogManager fusion lists
        InstallHeadlessNetworkAgent(); // turn-flow resolve reads ToolboxGame.RealTimeNetworkAgent

        // Per-session leader class: chara_id == class_id for 1..8 in the all-8-class ClassCharacterList,
        // so writing the seats' class ordinals into the SESSION's GameMgr DataMgr (resolved through the
        // ambient — see Setup remarks) resolves each leader's correct class.
        SetGameMgrCharaIds(seatAClass, seatBClass);

        SeedDeck(mgr, seatADeck, isPlayer: true);
        SeedDeck(mgr, seatBDeck, isPlayer: false);

        // Publish the mgr on the per-session ambient BEFORE wiring the mulligan phase: that ctor
        // chains into MulliganInfoControl.InitMulliganInfo, which reads BattleManagerBase.GetIns()
        // (MulliganInfoControl.cs:259). With the fallback gone (Task 8), an unset ambient.Mgr would
        // resolve to null and NRE on the very next field read. Set ambient.Mgr here so the wiring
        // resolves the per-session mgr cleanly.
        _mgr = mgr;
        _ctx.Mgr = _mgr;

        WireMulliganPhase(mgr);    // wire OperateReceive.OnReceiveDeal -> StartDeal (deal seats the hand)

        // Use the mgr's OWN receiver — the ctor already wired it to the mgr's OperateReceive +
        // NetworkBattleData (NetworkBattleManagerBase.cs:266, non-recovery branch). This is the same
        // receiver the engine's RecoveryDataHandler drives when replaying recorded frames.
        _receiver = mgr.GetNetworkBattleReceiver();
    }

    /// <summary>Ingest one client frame into the engine for the given seat. <paramref name="isPlayerSeat"/>
    /// maps the sender to the engine's player(true)/opponent(false) seat (F-N-2). A throw/reject is
    /// returned as a detected-desync EVENT (ND6), never silently absorbed.</summary>
    public EngineIngestResult Receive(MsgEnvelope env, bool isPlayerSeat)
    {
        using var _ambient = BattleAmbient.Enter(_ctx);
        if (_mgr is null || _receiver is null)
            throw new InvalidOperationException("Receive before Setup.");

        var dict = ToEngineDict((env.Body as RawBody)?.Entries);
        TranslateTargetOwners(dict, isPlayerSeat);
        TranslateChoiceKeyAction(dict);
        var uri = MapUri(env.Uri);

        try
        {
            // Mirror the engine's own recorded-frame replay (RecoveryDataHandler.cs:283): every
            // ingested action resolves through the isHaveSequence ConductReceiveData path, and
            // checkBreakData:false so a partial/handshake frame is not rejected as a break.
            bool accepted = _receiver.ReceivedMessage(
                uri, isHaveSequence: true, dict, isPlayerSeat, handler: null, checkBreakData: false);
            return accepted ? EngineIngestResult.Ok() : EngineIngestResult.Reject($"receiver rejected {env.Uri}");
        }
        catch (Exception ex)
        {
            // Keep the first few frames: a headless-gap NRE/ANE is almost always diagnosable from the
            // call chain (the throwing leaf is often a ThrowHelper, so one frame is too few).
            var site = string.Join(" || ", (ex.StackTrace ?? "").Split('\n').Take(4).Select(s => s.Trim()));
            return EngineIngestResult.Reject($"{env.Uri} threw: {ex.GetType().Name}: {ex.Message} @ {site}");
        }
    }

    // --- live isSelf -> engine-vid target-owner translation (live PvP ingest fidelity) -------------
    //
    // THE GAP this closes: real clients send each targetList entry as {targetIdx, isSelf, selectSkillIndex}
    // (verified in client-send captures, e.g. data_dumps/captures/battle_test/battle-traffic_cl1.ndjson),
    // where `isSelf` is the SENDER's perspective flag (isSelf:1 = target on the sender's own seat;
    // isSelf:0 = target on the OTHER seat). But the engine receive path the node drives is IsRecovery, and
    // its recovery targetList parse (NetworkBattleReceiver.CreateTargetList, isWatch:true branch,
    // NetworkBattleReceiver.cs:2180-2188) derives a target's owner from a `vid` stamp:
    //   isSelf_engine = (vid != PlayerStaticData.UserViewerID)         // UserViewerID == EngineGlobalInit.ThisViewerId
    // and the downstream resolver (NetworkBattleGenericTool.LookForActionDataToTargetCard:133) routes
    //   isSelf_engine == false -> BattlePlayer (engine seat A);  isSelf_engine == true -> BattleEnemy (seat B).
    // So the engine vid encodes the target's ABSOLUTE seat: seat A == ThisViewerId, seat B != it.
    //
    // Without a translation a real `isSelf` frame carries no `vid`, so the recovery parse leaves
    // isSelf_engine=false (vid defaults 0 != ThisViewerId would even read TRUE, but with no key it's the
    // default-0 TargetData) and the target mis-resolves -> a targeted attack/spell/evolution silently
    // misses. We translate on the ENGINE's OWN dict copy only (ToEngineDict re-boxed a fresh dict; the
    // node's relay/mining read the ORIGINAL env.Body, which KnownListBuilder/RecordTokensFrom consume as
    // `isSelf` and must keep), so the node-side isSelf bookkeeping is untouched.
    //
    // ONLY engine-vid field on the live targeted frames: `targetList[].vid`. The recovery parse reads `vid`
    // exclusively in the isWatch:true `targetList` branch (the ONLY `vid` read on the receiver,
    // NetworkBattleReceiver.cs:2182); `oppoTargetList` parses `isSelf` directly (isWatch:false) but the node
    // never sends it. Non-targeted frames (deal/play/turn/mulligan) carry no targetList and pass through
    // unchanged.
    //
    // The (isPlayerSeat, isSelf) -> vid mapping (oracle: the harness's known-good SelfSeatVid/EnemySeatVid):
    //   target is on seat A  <=>  isPlayerSeat == (isSelf == 1)   // sender-relative isSelf -> absolute seat
    //   seat A -> ThisViewerId ;  seat B -> ThisViewerId + 1
    private static void TranslateTargetOwners(Dictionary<string, object> dict, bool isPlayerSeat)
    {
        if (!dict.TryGetValue(TargetListKey, out var raw) || raw is not List<object> entries)
            return;

        foreach (var e in entries)
        {
            if (e is not Dictionary<string, object> entry) continue;
            // Tolerate a vid already present (idempotent): leave the engine shape as-is. The primary
            // contract is the real isSelf shape, but a frame that already carries vid resolves directly.
            if (entry.ContainsKey(VidKey)) continue;
            if (!entry.TryGetValue(IsSelfKey, out var isSelfRaw)) continue;

            bool isSelf = ToInt(isSelfRaw) == 1;
            bool targetOnSeatA = isPlayerSeat == isSelf;
            entry[VidKey] = targetOnSeatA ? EngineGlobalInit.ThisViewerId : EngineGlobalInit.ThisViewerId + 1;
            // Drop isSelf on the ENGINE copy: the isWatch:true recovery parse reads vid, not isSelf, so the
            // key is dead weight on this copy. (The node's relay/mining copy is a different dict and keeps it.)
            entry.Remove(IsSelfKey);
        }
    }

    private const string TargetListKey = "targetList";
    private const string VidKey = "vid";
    private const string IsSelfKey = "isSelf";
    private const string KeyActionKey = "keyAction";
    private const string SelectCardKey = "selectCard";
    private const string CardIdKey = "cardId";

    // --- live Choice-keyAction shape translation (live PvP ingest fidelity) ------------------------
    //
    // THE GAP this closes: a Choice play's wire keyAction entry on the SENDER's send is the wrapped
    // shape `{type:1, cardId:<choiceCardId>, selectCard:{cardId:[<chosenId>...], open:0|1}}` (verified
    // in client-send captures, e.g. data_dumps/captures/battle_test/cl1/battle-traffic.ndjson live
    // Resonance play). The engine's receive parser (NetworkBattleReceiver.cs:1202) reads the
    // `selectCard` value through `ConvertToListInt`, which does `value as List<object>` — a Dictionary
    // value casts to null and the inner `foreach (... in null)` throws NRE. The whole
    // ConvertReceiveDataToMakeData is wrapped in a swallow-catch (NetworkBattleReceiver.cs:1255-1260)
    // that logs to Debug.LogError + LocalLog — both shimmed/no-op'd headlessly — and returns false.
    // SessionBattleEngine.Receive calls ReceivedMessage with checkBreakData:false, so the false isn't
    // surfaced; the engine continues with `choiceIdList=[]`, the choice never resolves, and the played
    // card never moves from hand to board. Then any LATER frame that addresses the un-resolved card
    // by Index sees a stale hand entry — silently for a turn or two, until a TARGETED play looks for
    // it on the board (where it should be per wire) and gets `null` from LookForActionDataToTargetCard
    // → ActionProcessor.PlayCard:407 NRE on `selectedCard.SelfBattlePlayer`.
    //
    // OPPONENT-FACING relay shape is different: the node strips selectCard entirely from the opponent
    // broadcast (verified: cl2 receives `keyAction:[{type:1, cardId:127011010}]`), so the opponent
    // never needs this transform. Only the shadow engine — which ingests the SENDER's raw send — does.
    //
    // The fix: walk keyAction on the ENGINE's own dict copy (TranslateTargetOwners' pattern) and
    // unwrap selectCard. `{cardId:[121011010], open:0}` → `[121011010]`. The `open` flag (was this
    // choice revealed to the opponent) is irrelevant to the engine's resolution. The flat-list shape
    // is what `ConvertToListInt` consumes successfully, AND what the existing test harness
    // (NodeNativeBattleHarness.ChoicePlayBody) already supplies — that test passes, proving the rest
    // of the Choice resolution path works given the right shape. Idempotent: an already-flat list
    // (no wrapping dict) is left alone, so a future relay frame that happens to carry the flat form
    // also resolves directly.
    //
    // Live regression: bid 131549100204, B's Resonance (127011010) play of idx 20 at error.txt:1642.
    // Without the unwrap, idx 20 stays in B's hand; later A's 6-cost bounce targets B's "board" idx 20,
    // engine can't find it on the board, ActionProcessor.PlayCard NRE's at the foreach over a list
    // containing a null target.
    private static void TranslateChoiceKeyAction(Dictionary<string, object> dict)
    {
        if (!dict.TryGetValue(KeyActionKey, out var raw) || raw is not List<object> entries)
            return;

        foreach (var e in entries)
        {
            if (e is not Dictionary<string, object> entry) continue;
            if (!entry.TryGetValue(SelectCardKey, out var sel)) continue;
            // Already-flat (a List): no transform needed. Idempotent guard.
            if (sel is List<object>) continue;
            // Wrapped (a Dict): unwrap to the inner cardId list.
            if (sel is Dictionary<string, object> wrap
                && wrap.TryGetValue(CardIdKey, out var inner)
                && inner is List<object> flat)
            {
                entry[SelectCardKey] = flat;
            }
            else
            {
                // Unrecognized shape — drop the key so the parse doesn't NRE; the play will resolve
                // with an empty choice list, and the divergence (if any) will surface downstream
                // rather than crash the receiver.
                entry.Remove(SelectCardKey);
            }
        }
    }

    // The decoded wire value may be a boxed long/int/bool depending on the codec; normalize to int.
    private static int ToInt(object v) => v switch
    {
        bool b => b ? 1 : 0,
        long l => (int)l,
        int i => i,
        _ => Convert.ToInt32(v),
    };

    // --- live board-state reads (N1 oracle surface; design F-N-4 board-state reads) ----------------
    // Each returns LIVE engine state off the seated player, mirroring the Phase-1 oracle reads
    // (VanillaFollowerOracleTests: player.Pp, player.HandCardList.Count, ClassAndInPlayCardList,
    // leader == the Class card). seat:true == player, false == opponent (F-N-2).
    //
    // INVARIANT (two accessor bands, different null-engine policy):
    //   • This "oracle" band (down to EvolveWaitTurnCount) goes through Seat(), which THROWS if the
    //     engine isn't seated for this session. It is TEST-ONLY — called solely from the
    //     node-native harness/tests, where the engine is always seated. Do NOT call these from a wire
    //     handler.
    //   • The wire-path band below (PlayedCardCost/Spellboost/Clan/Tribe/Id) DEGRADES to a fallback
    //     when _mgr is null (Setup failed and the ComputeFrames try/catch swallowed it, ND6), so a
    //     non-engine session never crashes. Production handlers read ONLY that band.

    public int LeaderLife(bool playerSeat) { using var _ambient = BattleAmbient.Enter(_ctx); return Seat(playerSeat).Class.Life; }
    public int Pp(bool playerSeat) { using var _ambient = BattleAmbient.Enter(_ctx); return Seat(playerSeat).Pp; }
    public int HandCount(bool playerSeat) { using var _ambient = BattleAmbient.Enter(_ctx); return Seat(playerSeat).HandCardList.Count; }
    public int DeckCount(bool playerSeat) { using var _ambient = BattleAmbient.Enter(_ctx); return Seat(playerSeat).DeckCardList.Count; }
    public int Turn(bool playerSeat) { using var _ambient = BattleAmbient.Enter(_ctx); return Seat(playerSeat).Turn; }

    /// <summary>Followers in play, excluding the leader (the Class card occupies one slot of
    /// ClassAndInPlayCardList).</summary>
    public int BoardCount(bool playerSeat) { using var _ambient = BattleAmbient.Enter(_ctx); return Math.Max(0, Seat(playerSeat).ClassAndInPlayCardList.Count - 1); }

    /// <summary>The engine <c>Index</c> of the hand card at the given hand position. The receive-path
    /// Play frame addresses a card by its engine Index (playIdx), which equals deck position + 1 for
    /// a card dealt from the seeded deck.</summary>
    public int HandCardIndex(bool playerSeat, int handPos) { using var _ambient = BattleAmbient.Enter(_ctx); return Seat(playerSeat).HandCardList[handPos].Index; }

    /// <summary>The real <c>CardId</c> (wire identity) of the hand card at <paramref name="handPos"/>. Lets a
    /// test locate a specific card in a SHUFFLED opening hand by identity (then read its <see cref="HandCardIndex"/>
    /// to drive a play), without depending on which shuffled position the card landed at.</summary>
    public int HandCardId(bool playerSeat, int handPos) { using var _ambient = BattleAmbient.Enter(_ctx); return Seat(playerSeat).HandCardList[handPos].CardId; }

    /// <summary>The real <c>CardId</c> (wire identity) of the in-play follower at <paramref name="boardPos"/>
    /// (0-based, skipping the leader/Class card at ClassAndInPlayCardList[0] — same convention as
    /// <see cref="BoardCount"/>). Used to assert an opponent reveal seated the substituted card with its
    /// true identity (M-HC-2): before the reveal the slot holds a hidden dummy (cardId 0); after, the
    /// engine-resolved actual card carries the wire cardId.</summary>
    public int InPlayCardId(bool playerSeat, int boardPos)
    {
        using var _ambient = BattleAmbient.Enter(_ctx);
        return Seat(playerSeat).ClassAndInPlayCardList[boardPos + 1].CardId;
    }

    /// <summary>The engine <c>Index</c> of the in-play follower at <paramref name="boardPos"/> (0-based,
    /// leader excluded — same convention as <see cref="BoardCount"/>/<see cref="InPlayCardId"/>). An ATTACK
    /// frame addresses the attacker by this in-play Index (the wire <c>playIdx</c>), so a test reads it after
    /// a follower resolves onto the board to build the attack (M-HC-4a).</summary>
    public int InPlayCardIndex(bool playerSeat, int boardPos)
    {
        using var _ambient = BattleAmbient.Enter(_ctx);
        return Seat(playerSeat).ClassAndInPlayCardList[boardPos + 1].Index;
    }

    /// <summary>The current life/health of the in-play follower at <paramref name="boardPos"/> (0-based,
    /// leader excluded). Reads <see cref="BattleCardBase.Life"/> (skill-resolved current health). Lets an
    /// attack test assert a follower took the attacker's damage (M-HC-4a follower-vs-follower trade).</summary>
    public int InPlayCardLife(bool playerSeat, int boardPos)
    {
        using var _ambient = BattleAmbient.Enter(_ctx);
        return Seat(playerSeat).ClassAndInPlayCardList[boardPos + 1].Life;
    }

    /// <summary>The attack stat of the in-play follower at <paramref name="boardPos"/> (skill-resolved
    /// <see cref="BattleCardBase.Atk"/>). The damage it deals when it attacks.</summary>
    public int InPlayCardAtk(bool playerSeat, int boardPos)
    {
        using var _ambient = BattleAmbient.Enter(_ctx);
        return Seat(playerSeat).ClassAndInPlayCardList[boardPos + 1].Atk;
    }

    /// <summary>True when the in-play follower at <paramref name="boardPos"/> can still attack this turn
    /// (<see cref="BattleCardBase.Attackable"/>). After it attacks (consuming its single attack) this reads
    /// false — the "attacker is spent" assertion (M-HC-4a).</summary>
    public bool InPlayCardAttackable(bool playerSeat, int boardPos)
    {
        using var _ambient = BattleAmbient.Enter(_ctx);
        return Seat(playerSeat).ClassAndInPlayCardList[boardPos + 1].Attackable;
    }

    /// <summary>True once the in-play follower at <paramref name="boardPos"/> (0-based, leader excluded)
    /// has evolved (<see cref="UnitBattleCard.IsEvolution"/>, set true inside the engine's own
    /// <c>UnitBattleCard.Evolution</c> mutation). Only <see cref="UnitBattleCard"/> followers carry the
    /// flag; a non-follower (or the leader) reads false. The evolve test's decisive engine-state assertion
    /// (M-HC-4b).</summary>
    public bool IsEvolved(bool playerSeat, int boardPos)
    {
        using var _ambient = BattleAmbient.Enter(_ctx);
        return (Seat(playerSeat).ClassAndInPlayCardList[boardPos + 1] as UnitBattleCard)?.IsEvolution ?? false;
    }

    /// <summary>The seat's current evolve-point count (<see cref="BattlePlayerBase.CurrentEpCount"/>). An
    /// evolve spends one EP, so the evolve test asserts this decrements by 1. EP is granted at setup by
    /// the engine's <c>SetupEvolCount</c> (2 for the game-first seat, 3 for the second) and unlocks once
    /// <c>EvolveWaitTurnCount</c> has counted down (M-HC-4b).</summary>
    public int EpCount(bool playerSeat) { using var _ambient = BattleAmbient.Enter(_ctx); return Seat(playerSeat).CurrentEpCount; }

    /// <summary>Turns remaining until <paramref name="playerSeat"/> may evolve
    /// (<see cref="BattlePlayerBase.EvolveWaitTurnCount"/>); 0 means evolve is unlocked. Lets a test ramp to
    /// the evolve-enabled turn deterministically (M-HC-4b).</summary>
    public int EvolveWaitTurnCount(bool playerSeat) { using var _ambient = BattleAmbient.Enter(_ctx); return Seat(playerSeat).EvolveWaitTurnCount; }

    /// <summary>The engine-RESOLVED play-time cost of the card whose engine <c>Index</c> == <paramref name="idx"/>
    /// on <paramref name="playerSeat"/> (M-HC-3a). This is the discounted cost the play actually paid —
    /// spellboost reduction, board-dependent modifiers and all — read straight off the engine, so the
    /// opponent-facing knownList carries the SAME cost the engine charged (closing the spellboost
    /// cost-desync BY CONSTRUCTION: no bookkeeping, the engine already knows).
    /// <para>READ-MOMENT: the conductor's <c>ShadowIngest</c> runs <c>engine.Receive</c> (→ resolves the
    /// play) BEFORE the handler runs, so at read time the played card has LEFT the hand — a follower sits
    /// in <c>ClassAndInPlayCardList</c>, a spell in <c>CemeteryList</c>. <see cref="BattleCardBase.PlayCard"/>
    /// captures <c>_playedCost = useCost</c> (== the fully-resolved <c>Cost</c> at the moment of play,
    /// incl. every CostModifier) onto the card object, which persists after the card leaves the hand —
    /// so <see cref="BattleCardBase.PlayedCost"/> is the authoritative play-time discounted cost. We search
    /// the seat's post-resolution zones (in-play, cemetery) by <c>Index</c>, then fall back to the hand
    /// (a not-yet-resolved card, e.g. a degenerate test path) reading the live <c>Cost</c> there.</para>
    /// <para>Degrades to <paramref name="fallback"/> when the engine is not set up (Setup failed and the
    /// ComputeFrames try/catch swallowed it, ND6) or the idx resolves to no card — so a non-engine
    /// session never crashes and a vanilla play simply emits its base cost via the caller's fallback.</para></summary>
    public int PlayedCardCost(bool playerSeat, int idx, int fallback = 0)
    {
        using var _ambient = BattleAmbient.Enter(_ctx);
        if (_mgr is null) return fallback;
        var card = FindByIndex(Seat(playerSeat), idx);
        if (card is null) return fallback;
        // PlayedCost is set (>= 0) once PlayCard resolved the play; before that (a card still in hand on a
        // degenerate path) read the live Cost, which already folds in any registered CostModifier.
        return card.PlayedCost >= 0 ? card.PlayedCost : card.Cost;
    }

    /// <summary>The engine-RESOLVED spellboost (spell-charge) COUNT of the card whose engine <c>Index</c> ==
    /// <paramref name="idx"/> on <paramref name="playerSeat"/> (M-HC-3b). The engine accumulates this count
    /// for real on the receive path (each spell play that targets the card runs the card's own
    /// <c>Skill_spell_charge.AddSpellChargeCount</c>), so this is the same authoritative count prod sends —
    /// emitted on the opponent-facing knownList so the wire stays prod-faithful now that the wire-derived
    /// spellboost bookkeeping is retired (cost itself is engine-sourced via <see cref="PlayedCardCost"/>).
    /// <para>READ-MOMENT (persist-post-play): <see cref="BattleCardBase.SpellChargeCount"/> is set to 0 only
    /// in the ctor (re-init, BattleCardBase.cs:2042) and in <c>ReturnCard</c> (bounce-to-hand,
    /// BattleCardBase.cs:2681); <see cref="BattleCardBase.PlayCard"/> never touches it. So the count PERSISTS
    /// on the played card object after it leaves the hand (follower in-play, spell in cemetery) — the same
    /// persist-after-play property <see cref="BattleCardBase.PlayedCost"/> has. We therefore use the SAME
    /// post-resolution zone search (<see cref="FindByIndex"/>: in-play → cemetery → hand) and read
    /// <c>SpellChargeCount</c> directly — no separate receive-capture is needed.</para>
    /// <para>Degrades to <paramref name="fallback"/> when the engine is not set up or the idx resolves to no
    /// card — so a non-engine session never crashes and a vanilla play emits 0 via the caller's fallback.</para></summary>
    public int PlayedCardSpellboost(bool playerSeat, int idx, int fallback = 0)
    {
        using var _ambient = BattleAmbient.Enter(_ctx);
        if (_mgr is null) return fallback;
        var card = FindByIndex(Seat(playerSeat), idx);
        return card?.SpellChargeCount ?? fallback;
    }

    /// <summary>The engine-RESOLVED card identity (wire <c>cardId</c>) of the card whose engine <c>Index</c> ==
    /// <paramref name="idx"/> on <paramref name="playerSeat"/> (M-HC-4f), read straight off
    /// <see cref="BattleCardBase.CardId"/> — the TRUE id the engine resolved during the conductor's
    /// <c>ShadowIngest</c> (<c>engine.Receive</c> ran BEFORE this read). This is the authoritative identity for
    /// EVERY card the engine seats, retiring the wire-mined idx→cardId bookkeeping for the played card:
    /// <list type="bullet">
    /// <item>a DECK card carries its dealt id (the seeded shuffled-deck identity);</item>
    /// <item>a GENERATED token carries the wire id <c>CreateActualCard</c>/<c>ReplaceReceivedCards</c> stamped on it
    ///   (M-HC-2 proved reveal seats the wire cardId);</item>
    /// <item>a CHOICE/Discover token carries the CHOSEN id (M-HC-4c proved the chosen token lands with its true id);</item>
    /// <item>a COPY/clone token carries the COPIED id (the engine copies the source card at baseIdx).</item>
    /// </list>
    /// Same post-resolution zone search + degrade-to-<paramref name="fallback"/> contract as
    /// <see cref="PlayedCardCost"/>: no engine / no card → <paramref name="fallback"/>, so a non-engine session
    /// (Setup failed and the ComputeFrames try/catch swallowed it, ND6) keeps emitting the deck-map id via
    /// the caller's fallback, never crashing.</summary>
    public long PlayedCardId(bool playerSeat, int idx, long fallback = 0)
    {
        using var _ambient = BattleAmbient.Enter(_ctx);
        if (_mgr is null) return fallback;
        var card = FindByIndex(Seat(playerSeat), idx);
        return card is null ? fallback : card.CardId;
    }

    /// <summary>The engine-RESOLVED clan of the card whose engine <c>Index</c> == <paramref name="idx"/> on
    /// <paramref name="playerSeat"/> (M-HC-4e), as the int <c>ClanType</c> ordinal prod sends on the
    /// knownList entry (e.g. <c>clan:8</c> in the tk2 capture). Reads <see cref="BattleCardBase.Clan"/>, whose
    /// getter returns the skill-applied clan (<c>SkillApplyInformation.ClanSkinInfo.Last()</c> when a skill
    /// changed it, else <c>BaseParameter.Clan</c>) — so a <c>change_affiliation</c> is reflected, which is WHY
    /// the engine value (not the static card-master clan) is the faithful one to emit.
    /// <para>Same post-resolution zone search + degrade-to-<paramref name="fallback"/> contract as
    /// <see cref="PlayedCardCost"/>: no engine / no card → fallback, so a non-engine session never crashes.</para></summary>
    public int PlayedCardClan(bool playerSeat, int idx, int fallback = 0)
    {
        using var _ambient = BattleAmbient.Enter(_ctx);
        if (_mgr is null) return fallback;
        var card = FindByIndex(Seat(playerSeat), idx);
        return card is null ? fallback : (int)card.Clan;
    }

    /// <summary>The engine-RESOLVED tribe of the card whose engine <c>Index</c> == <paramref name="idx"/> on
    /// <paramref name="playerSeat"/> (M-HC-4e), in the EXACT wire string form prod sends: the comma-joined
    /// int <c>TribeType</c> ordinals (e.g. <c>tribe:"7,16"</c> for MACHINE+SCHOOL in the tk2 capture), and
    /// <c>"0"</c> when the card has no tribe (== <c>TribeType.ALL == 0</c> — prod never sends empty/omitted;
    /// the client reads it via <c>item.Value.ToString()</c>, NetworkBattleReceiver.cs:2382). Reads
    /// <see cref="BattleCardBase.Tribe"/>, whose getter folds in any skill-applied tribe CHANGE/ADD over
    /// <c>BaseParameter.Tribe</c> (and drops ALL when the resolved list has ≥2 entries) — so the wire carries
    /// the LIVE tribe, the faithful value over the static card-master one.
    /// <para>Same post-resolution zone search + degrade-to-<paramref name="fallback"/> contract as
    /// <see cref="PlayedCardClan"/>: no engine / no card → <paramref name="fallback"/> (default <c>"0"</c>, the
    /// prod no-tribe form — NEVER empty, which is wire-illegal: prod always sends tribe as a non-empty string,
    /// the client reads it via <c>item.Value.ToString()</c> at NetworkBattleReceiver.cs:2382). The degrade is
    /// LIVE, not dead: a session whose Setup failed (the ComputeFrames try/catch swallowed it, ND6) has
    /// <c>_mgr is null</c> yet still emits a knownList entry (the handler resolves the identity via the
    /// deck-map/mined fallback when the engine read degrades, so BuildPlayedCard still synthesizes an
    /// entry), so this path must hand back a legal wire value.</para></summary>
    public string PlayedCardTribe(bool playerSeat, int idx, string fallback = "0")
    {
        using var _ambient = BattleAmbient.Enter(_ctx);
        if (_mgr is null) return fallback;
        var card = FindByIndex(Seat(playerSeat), idx);
        if (card is null) return fallback;
        var tribe = card.Tribe;
        // Prod's no-tribe form is the single "0" (TribeType.ALL == 0), never an empty string; an empty list
        // (defensive) renders the same "0".
        return tribe is null || tribe.Count == 0
            ? "0"
            : string.Join(",", tribe.Select(t => (int)t));
    }

    // Locate the card with the given engine Index across the seat's post-resolution zones. Order matters
    // only for disambiguation; Index is unique per card so the first hit is the card. In-play (followers)
    // and cemetery (spells) are where a just-resolved play lands; hand is the pre-resolution fallback.
    private static BattleCardBase? FindByIndex(BattlePlayerBase seat, int idx)
    {
        foreach (var c in seat.ClassAndInPlayCardList)
            if (c.Index == idx) return c;
        foreach (var c in seat.CemeteryList)
            if (c.Index == idx) return c;
        foreach (var c in seat.HandCardList)
            if (c.Index == idx) return c;
        return null;
    }

    /// <summary>TEST SEAM (M-HC-3a validation): register a cost-reducing modifier on the hand card at
    /// engine <c>Index</c> == <paramref name="idx"/>, mimicking what card 101314020's <c>when_spell_charge</c>
    /// <c>cost_change add=ADD_CHARGE_COUNT*-1</c> skill does once it has accumulated <paramref name="charge"/>
    /// spellboost charges (each charge adds a <c>CostAddModifier(-1)</c>; the engine's own
    /// <see cref="Skill_cost_change"/> builds exactly this). Used to drive the count→cost resolution
    /// deterministically headless without pumping the (VFX-coupled) spell-charge skill chain through a
    /// real multi-spell sequence — the engine's authentic <see cref="BattleCardBase.Cost"/> getter then
    /// resolves the discount, and <see cref="BattleCardBase.PlayCard"/> captures it as PlayedCost on the
    /// next play. Returns the resolved hand-card Cost AFTER seeding (base − charge) for the caller to pin.
    /// No-op-returns -1 if the engine isn't set up or no hand card has that Index.</summary>
    internal int SeedHandCardSpellboostCost(bool playerSeat, int idx, int charge)
    {
        using var _ambient = BattleAmbient.Enter(_ctx);
        if (_mgr is null) return -1;
        BattleCardBase? card = null;
        foreach (var c in Seat(playerSeat).HandCardList)
            if (c.Index == idx) { card = c; break; }
        if (card is null) return -1;
        for (int i = 0; i < charge; i++)
            card.AddCostModifier(new CostAddModifier(-1), null, eventCall: false);
        card.SetSpellChargeCount(charge); // keep the charge count consistent with the modifiers (cosmetic here)
        return card.Cost;
    }

    // === TEST/DEBUG SEAMS (Phase 4 root-cause verification — NOT a production fix) =================
    // These exist solely to PROVE the post-mulligan reshuffle root cause from a test. They read/poke
    // the engine's XorShift idx-change RNG, which the live recovery path leaves null/inactive (seed -1).
    // No production code path calls them. Remove (or fold into the real seeding) when the fix lands.

    /// <summary>TEST/DEBUG: is the engine's SELF-seat XorShift idx-change RNG active? Mirrors the gate the
    /// post-mulligan deck reshuffle/re-index checks (<c>BattleMgr.XorShiftRandom(true) != null &amp;&amp;
    /// .IsActive</c>, BattlePlayerBase.cs:3049/3073). Under the live recovery setup
    /// (<c>CreateXorShift(-1,-1)</c> via NullRecoveryManager.IdxChangeSeed == -1) this is FALSE, so the
    /// engine SKIPS the reshuffle the real clients performed.</summary>
    internal bool SelfXorShiftActive
    {
        get { using var _ambient = BattleAmbient.Enter(_ctx); return (_mgr?.XorShiftRandom(isSelf: true)?.IsActive) ?? false; }
    }

    /// <summary>TEST/DEBUG: same as <see cref="SelfXorShiftActive"/> for the OPPONENT seat.</summary>
    internal bool OppoXorShiftActive
    {
        get { using var _ambient = BattleAmbient.Enter(_ctx); return (_mgr?.XorShiftRandom(isSelf: false)?.IsActive) ?? false; }
    }

    /// <summary>DIAGNOSTIC: check if OnReceiveDeal is wired and report deck/hand counts.</summary>
    internal string DiagnoseDealState()
    {
        using var _ambient = BattleAmbient.Enter(_ctx);
        if (_mgr is null) return "mgr=null";
        var or = _mgr.OperateReceive;
        bool dealWired = or.OnReceiveDeal != null;
        var p = _mgr.GetBattlePlayer(true);
        var e = _mgr.GetBattlePlayer(false);
        return $"OnReceiveDeal={(dealWired ? "wired" : "NULL")}, " +
               $"playerDeck={p.DeckCardList.Count}, playerHand={p.HandCardList.Count}, " +
               $"enemyDeck={e.DeckCardList.Count}, enemyHand={e.HandCardList.Count}";
    }

    /// <summary>Seed the opponent seat's XorShift for post-mulligan deck reshuffle. The Ready frame's
    /// <c>idxChangeSeed</c> seeds the self seat (BattlePlayer/A) automatically via the receiver. The
    /// opponent seat (BattleEnemy/B) needs its seed injected separately because the Ready frame sent
    /// to A doesn't carry B's seed. Called from <see cref="BattleSession.ShadowFeedServerFrames"/>
    /// after feeding the Ready.</summary>
    internal void SeedOppoIdxChange(int oppoSeed)
    {
        using var _ambient = BattleAmbient.Enter(_ctx);
        _mgr?.CreateXorShift(-1, oppoSeed);
    }

    /// <summary>TEST/DEBUG: inject BOTH per-seat idxChange seeds at once (the verification seam the
    /// PostMulliganReshuffleRootCauseTests use). Production code uses the Ready frame for the self
    /// seed + <see cref="SeedOppoIdxChange"/> for the opponent seed.</summary>
    internal void DebugSeedIdxChange(int selfSeed, int oppoSeed)
    {
        using var _ambient = BattleAmbient.Enter(_ctx);
        if (_mgr is null) throw new InvalidOperationException("DebugSeedIdxChange before Setup.");
        _mgr.CreateXorShift(selfSeed, oppoSeed);
    }

    /// <summary>TEST/DEBUG: override the engine's process-global <c>BattleManagerBase.IsRandomDraw</c>
    /// flag. Production Setup now sets this true (matching the real match-load's
    /// <c>SetupInitialGameState(areCardsRandomlyDrawn:true)</c>). This seam exists so tests can
    /// force it false to reproduce the old top-of-deck bug. Static field → set per run under
    /// [NonParallelizable].</summary>
    internal void DebugSetRandomDraw(bool value)
    {
        using var _ambient = BattleAmbient.Enter(_ctx);
        BattleManagerBase.IsRandomDraw = value;
    }

    /// <summary>TEST/DEBUG (Phase 4 draw-recompute hypothesis): advance the SHARED <c>_stableRandom</c>
    /// stream by <paramref name="n"/> draws, exactly as <c>OperateReceive.StartOperate</c> does on a
    /// received frame carrying <c>spin=n</c> (OperateReceive.cs:80-83 loops <c>StableRandomDouble()</c>
    /// n times). The live shadow never ingests the Ready frame that carries the wire spin, so its stream
    /// is offset; this applies the pre-roll at the same point the real client would.</summary>
    internal void DebugSpinPreroll(int n)
    {
        using var _ambient = BattleAmbient.Enter(_ctx);
        if (_mgr is null) throw new InvalidOperationException("DebugSpinPreroll before Setup.");
        for (int i = 0; i < n; i++) _mgr.StableRandomDouble();
    }

    /// <summary>TEST/DEBUG: consume one value from the shared <c>_stableRandom</c> stream and return it.
    /// Lets a regression test assert engine seed alignment against the wire — the very first
    /// <c>StableRandom.NextDouble()</c> the engine produces must equal the first <c>NextDouble()</c> of a
    /// fresh <c>System.Random(BattleSeeds.Stable(masterSeed))</c>, since clients seed
    /// <c>_stableRandom = new System.Random(Matched.seed)</c> with the SAME value
    /// (BattleManagerBase.cs:721; Matched.seed == BattleSeeds.Stable(masterSeed),
    /// InitBattleHandler.cs:28).</summary>
    internal double DebugStableRandomDouble()
    {
        using var _ambient = BattleAmbient.Enter(_ctx);
        if (_mgr is null) throw new InvalidOperationException("DebugStableRandomDouble before Setup.");
        return _mgr.StableRandomDouble();
    }

    /// <summary>TEST/DEBUG: read the per-seat <c>cardTotalNum</c> counter that drives auto-assigned
    /// Index for skill-generated tokens (BattleManagerBase.SetupCardIndex uses this when
    /// <c>addIndex == -1</c>). After Setup it must equal <c>deck.Count + 1</c> on both seats (matches
    /// the real client's <c>SBattleLoad.InitPlayer</c> tail, SBattleLoad.cs:1292), so the FIRST
    /// generated token gets Index 41 — clear of deck-loaded indices 1..40 — and matches the wire
    /// <c>add.idx</c>. A stale value of 0 causes tokens to take Index 0, 1, ... and collide.</summary>
    internal int DebugCardTotalNum(bool playerSeat)
    {
        using var _ambient = BattleAmbient.Enter(_ctx);
        return _mgr is null ? -1 : _mgr.GetBattlePlayer(playerSeat).cardTotalNum;
    }

    /// <summary>TEST/DEBUG: the engine's running <c>StableRandom</c>/<c>StableRandomDouble</c> call count
    /// (private <c>BattleManagerBase.stableRandomCount</c>), so a divergence dump can report how far the
    /// shared stream has advanced at the moment of a mismatch.</summary>
    internal int DebugStableRandomCount
    {
        get
        {
            using var _ambient = BattleAmbient.Enter(_ctx);
            return _mgr is null ? -1
                : (int)(typeof(BattleManagerBase)
                    .GetField("stableRandomCount", BindingFlags.Instance | BindingFlags.NonPublic)!
                    .GetValue(_mgr) ?? -1);
        }
    }

    private engine::BattlePlayerBase Seat(bool playerSeat) =>
        (_mgr ?? throw new InvalidOperationException("read before Setup")).GetBattlePlayer(playerSeat);

    private static NetworkBattleDefine.NetworkBattleURI MapUri(NetworkBattleUri uri)
        => Enum.Parse<NetworkBattleDefine.NetworkBattleURI>(uri.ToString());

    // The receiver reads keys via Enum.IsDefined over NetworkParameter and casts nested values to
    // List<object> / Dictionary<string,object>; the node decodes nested data as the nullable
    // List<object?> / Dictionary<string,object?>. Rebox to the non-nullable shape, dropping nulls
    // (the receiver presence-checks keys, so an absent key is the correct encoding of a null).
    private static Dictionary<string, object> ToEngineDict(Dictionary<string, object?>? entries)
    {
        var result = new Dictionary<string, object>();
        if (entries is null) return result;
        foreach (var (k, v) in entries)
            if (v is not null) result[k] = Rebox(v);
        return result;
    }

    private static object Rebox(object v) => v switch
    {
        Dictionary<string, object?> d => d.Where(kv => kv.Value is not null)
                                          .ToDictionary(kv => kv.Key, kv => Rebox(kv.Value!)),
        List<object?> l => l.Where(x => x is not null).Select(x => Rebox(x!)).ToList(),
        _ => v,
    };

    // --- headless wiring (production analogue of HeadlessFixture) -----------------------------------

    private static void InitLeaderLife(BattleManagerBase mgr, int life = DefaultLeaderLife)
    {
        ((ClassBattleCardBase)mgr.GetBattlePlayer(true).Class).InitBaseMaxLife(life);
        ((ClassBattleCardBase)mgr.GetBattlePlayer(false).Class).InitBaseMaxLife(life);
    }

    private static void InitCardTemplates(BattleManagerBase mgr)
    {
        mgr.SBattleLoad = new SBattleLoad
        {
            UnitCardTemplate = new CardTemplate(),
            SpellCardTemplate = new CardTemplate(),
            FieldCardTemplate = new CardTemplate(),
        };
        mgr.Battle3DContainer = new GameObject();
        mgr.CardHolder = new GameObject();
        mgr.ECardHolder = new GameObject();
        mgr.PCardPlace = new GameObject();
        mgr.ChoiceCardHolder = new GameObject();
        mgr.EvolveCardHolder = new GameObject();
    }

    // Seed the no-op UI refs the receive/turn cycle dereferences. Under IsRecovery the methods on
    // these (e.g. BattleUIContainer.DisableMenu) no-op, but the receiver still CALLS them, so the
    // references must be non-null. PlayerEmotion is the engine's own NullPlayerEmotion.
    private static void InitHeadlessViews(BattleManagerBase mgr)
    {
        mgr.BattleUIContainer = (BattleUIContainer)FormatterServices.GetUninitializedObject(typeof(BattleUIContainer));
        // Revealed-card creation (ReplaceReceivedCard.CreateActualCard -> CreateBaseCardGameObject)
        // clones the card prefab under _backGround.m_Battle3DContainer — a field distinct from
        // mgr.Battle3DContainer. Seed a no-op BackGround with a non-null container.
        var bg = (BackGroundBase)FormatterServices.GetUninitializedObject(typeof(BackGroundBase));
        SetProperty(bg, "m_Battle3DContainer", new GameObject());
        SetField(mgr, "_backGround", bg);
        // PlayerEmotion is declared on BattlePlayer (the player seat); BattleEnemy has none — set
        // where present.
        TrySetProperty(mgr.GetBattlePlayer(true), "PlayerEmotion", new NullPlayerEmotion());
        TrySetProperty(mgr.GetBattlePlayer(false), "PlayerEmotion", new NullPlayerEmotion());

        // The receive play path runs SetupActionProcessorEvent (BattlePlayerBase.cs:1431/1438), which
        // wires BattleMgr.DetailMgr.DetailPanelControl.UpdateCardDescription* into OnPlayComplete/
        // OnEvolutionComplete. DetailMgr is created in CreateManager but its panel controls are null
        // headless. Seed the engine's own NullDetailPanelControl no-op (IDetailPanelControl) + an
        // uninitialized SubDetailPanelControl (concrete DetailPanelControl, read on other action arms).
        mgr.DetailMgr.DetailPanelControl = new NullDetailPanelControl();
        mgr.DetailMgr.SubDetailPanelControl =
            (DetailPanelControl)FormatterServices.GetUninitializedObject(typeof(DetailPanelControl));
    }

    // Hold a strong reference to the wired mulligan phase: its StartDeal closure is what
    // OperateReceive.OnReceiveDeal invokes, and it stores the mulligan mgr/controls that seat the hand.
    private NetworkMulliganPhase? _mulliganPhase;

    // Wire the receive path's deal handler. In production the phase machine advances to
    // NetworkMulliganPhase, whose Setup/MulliganEventSetting wires OperateReceive.OnReceiveDeal ->
    // MulliganPhaseBase.StartDeal (NetworkMulliganPhase.cs:91). The node never pumps the phase machine
    // (BattleManagerBase.Update is never called), and the node's PhaseCreator yields no NetworkMulligan
    // phase anyway — so construct the phase directly and run MulliganEventSetting() to install that
    // delegate. The phase ctor's Initialize builds the player/opponent mulligan controls (PlayerMlgCtrl
    // via InitMulligan) off the no-op view leaves the shim GameObject lazily materializes. The DEAL
    // mutation (cards deck->hand) happens synchronously inside StartDeal -> CreateMulliganDealList +
    // DrawFirstMulliganCard; the VFX it returns are cosmetic (dropped by HeadlessConductorVfxMgr).
    private void WireMulliganPhase(HeadlessNetworkBattleMgr mgr)
    {
        // The phase ctor's Initialize does NGUITools.AddChild(Battle3DContainer,
        // GetPrefabMgr().Get("Prefab/UI/MulliganInfo")).GetComponent<MulliganInfoControl>(). PrefabMgr.Get
        // returns null for an unregistered prefab (engine logic — not editable), and AddChild(parent,
        // null) -> Instantiate(null) -> null -> NRE on GetComponent. Seed a no-op GameObject under that
        // key so AddChild clones it and the shim GameObject lazily materializes a no-op
        // MulliganInfoControl. Node seed (allowed); the control is never shown/updated headless.
        var prefab = new GameObject();
        SeedMulliganInfoControl(prefab);
        var prefabData = GameMgr.GetIns().GetPrefabMgr().GetPrefabData();
        prefabData["Prefab/UI/MulliganInfo"] = prefab;

        var phase = new NetworkMulliganPhase(mgr, mgr.NetworkSender);
        phase.MulliganEventSetting();
        _mulliganPhase = phase;
    }

    // Materialize a no-op MulliganInfoControl on the prefab GameObject and seed the view-leaf fields the
    // phase ctor's PlayerMulliganView ctor -> MulliganInfoControl.InitMulliganInfo reads:
    //   _partsPlayer/_partsOpponent (private nested MulliganParts) — each needs a non-null _exchangeMark
    //     array (read for .Length in InitMulliganInfo) plus non-null _keepZone/_abandonZone UIWidgets
    //     (read for .gameObject elsewhere on the mulligan path).
    // The shim GameObject lazily creates the MulliganInfoControl but does NOT fill the MulliganParts
    // (it isn't a Component, so WireComponentFields skips it). Node seed (allowed) — pure no-op view leaves.
    private static void SeedMulliganInfoControl(GameObject prefab)
    {
        var ctrl = prefab.GetComponent<MulliganInfoControl>(); // Shim GameObject.GetComponent<T>() lazily materialises a no-op component — not a real Unity scene; this is intentional and will not NRE.
        var partsType = typeof(MulliganInfoControl)
            .GetNestedType("MulliganParts", BindingFlags.NonPublic)
            ?? throw new InvalidOperationException("MulliganInfoControl.MulliganParts nested type not found");
        SetField(ctrl, "_partsPlayer", BuildMulliganParts(partsType));
        SetField(ctrl, "_partsOpponent", BuildMulliganParts(partsType));
    }

    private static object BuildMulliganParts(Type partsType)
    {
        var parts = FormatterServices.GetUninitializedObject(partsType);
        SetField(parts, "_exchangeMark", Array.CreateInstance(typeof(UISprite), 0));
        SetField(parts, "_keepZone", NewUiWidget());
        SetField(parts, "_abandonZone", NewUiWidget());
        return parts;
    }

    // A UIWidget is read for .gameObject (Component.gameObject) on the mulligan path; create one on a
    // fresh GameObject so its gameObject backref resolves.
    private static UIWidget NewUiWidget() => new GameObject().GetComponent<UIWidget>();

    /// <summary>Seat one side's full deck in order (idx == list position + 1). Each card is created
    /// through the engine's own null-view seam and pushed via AddToDeck — the SeedDeck primitive the
    /// test harness proved (HeadlessFixture.SeedDeck).
    /// <para>Mirrors the real client's <c>SBattleLoad.InitPlayer</c>/<c>InitEnemy</c> tail: after
    /// loading the 40-card deck at indices 1..40, set <c>cardTotalNum = deck.Count + 1</c> so the
    /// next skill-generated token gets Index 41 (matches the wire's <c>add.idx</c>). Without this,
    /// <c>cardTotalNum</c> stays at the property default (0) and the auto-assign path
    /// (<c>SetupCardIndex(_, -1)</c> in BattleManagerBase.cs:1770) hands tokens Index 0, 1, ...,
    /// which COLLIDES with deck-loaded cards' Index 1..40. The collision is silent until something
    /// plays the deck card with the colliding Index (e.g. Hoverboarder at deck idx 1 with a token
    /// at engine Index 1): <c>GetBattleCardIdx</c>'s <c>SingleOrDefault</c> finds two matches and
    /// throws "Sequence contains more than one matching element". Also pin
    /// <c>BattleStartDeckCardList</c> like the real client, so any skill that reads the starting
    /// deck (e.g. tribe filters) sees the seeded deck instead of an empty list.</para></summary>
    private static void SeedDeck(BattleManagerBase mgr, IReadOnlyList<long> deck, bool isPlayer)
    {
        BattlePlayerBase owner = mgr.GetBattlePlayer(isPlayer);
        for (int i = 0; i < deck.Count; i++)
        {
            var card = CreateHeadlessCard(mgr, (int)deck[i], index: i + 1, isPlayer);
            owner.AddToDeck(card);
        }
        owner.cardTotalNum = deck.Count + 1;
        owner.BattleStartDeckCardList = new List<BattleCardBase>(owner.DeckCardList);
    }

    private static readonly MethodInfo CreateCardWithoutResources =
        typeof(CardCreatorBase).GetMethod("CreateCardWithoutResources",
            BindingFlags.NonPublic | BindingFlags.Static)
        ?? throw new InvalidOperationException("CardCreatorBase.CreateCardWithoutResources not found");

    private static BattleCardBase CreateHeadlessCard(BattleManagerBase mgr, int cardId, int index, bool isPlayer)
    {
        var io = mgr.CreatePlayerInnerOptionsBuilder();
        var card = (BattleCardBase)CreateCardWithoutResources.Invoke(
            null, new object[] { cardId, index, isPlayer, mgr, io })!;
        mgr.GetBattlePlayer(isPlayer).SetupCardEvent(card);
        return card;
    }

    // The per-frame skill-filter cleanup (BattleManagerBase.RemoveUnUseCalledFilterDictionary, run on
    // EVERY receive) reads BattleLogManager.GetInstance().EnemyFusionCard.Contains(...) when a card with a
    // registered CalledCreateFilter is alive — e.g. a follower with a when_play spell_charge/fanfare skill
    // (BattleManagerBase.cs:155). The shim BattleLogManager singleton leaves PlayerFusionCard/EnemyFusionCard
    // null (no UI ran SetUp), so that .Contains NREs. Seed both to empty lists — a pure no-op view-state
    // seed (the fusion log is cosmetic; nothing headless adds to it). Process-global like the other seeds.
    private static void SeedBattleLogManager()
    {
        var log = BattleLogManager.GetInstance();
        log.PlayerFusionCard ??= new List<BattleCardBase>();
        log.EnemyFusionCard ??= new List<BattleCardBase>();
    }

    // The turn-flow + emit bookkeeping reads the global ToolboxGame.RealTimeNetworkAgent (e.g.
    // RealTimeNetworkAgent.GetIsFirstPlayer/GetTurnState, which delegate to GameMgr's
    // NetworkUserInfoData.TurnState; AddActionSequence touches _gungnir). Headless there is no socket
    // agent, so seed a no-op one — mirroring HeadlessFixture.NewNetworkEmitBattle. _notEmit short-
    // circuits the byte-push before any socket I/O; the shadow engine never originates a send anyway.
    // NOTE: this is a process-global; one engine per process is assumed for the shadow (revisit for
    // live multi-session — see design O-N status). Idempotent enough for the per-battle setup.
    private static void InstallHeadlessNetworkAgent()
    {
        var agent = (RealTimeNetworkAgent)FormatterServices.GetUninitializedObject(typeof(RealTimeNetworkAgent));
        agent.SetCurrentMatchingStatus(RealTimeNetworkAgent.MatchingStatus.Prepared);
        SetField(agent, "_gungnir", FormatterServices.GetUninitializedObject(typeof(Gungnir)));
        SetProperty(agent, "NetworkLogger", new NetworkNullLogger());
        SetField(agent, "_notEmit", true);
        ToolboxGame.SetRealTimeNetworkBattle(agent);
    }

    // Write the two seats' class ordinals into the SESSION's GameMgr DataMgr leader chara ids. Mirrors
    // the test seam HeadlessFixture.cs:202-204 (SetField(dm, "_playerCharaId"/"_enemyCharaId", ...)).
    // chara_id == class_id for 1..8 in EngineGlobalInit's all-8-class ClassCharacterList, so the ordinal
    // selects the class. A non-positive ordinal (e.g. CardClass.None == 0) clamps to the default seat
    // (1/2). GameMgr is per-session (BattleAmbientContext.GameMgr); writes resolve through the ambient.
    private static void SetGameMgrCharaIds(int a, int b)
    {
        var dm = GameMgr.GetIns().GetDataMgr();
        SetField(dm, "_playerCharaId", a <= 0 ? 1 : a);
        SetField(dm, "_enemyCharaId", b <= 0 ? 2 : b);
    }

    private static void SetField(object obj, string name, object value)
    {
        var f = obj.GetType().GetField(name,
            BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public)
            ?? throw new InvalidOperationException($"{obj.GetType().Name} has no field '{name}'");
        f.SetValue(obj, value);
    }

    private static void SetProperty(object obj, string name, object value)
    {
        var t = obj.GetType();
        PropertyInfo? p = null;
        while (t is not null && p is null)
        {
            p = t.GetProperty(name, BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public);
            t = t.BaseType;
        }
        (p ?? throw new InvalidOperationException($"{obj.GetType().Name} has no property '{name}'"))
            .SetValue(obj, value);
    }

    private static void TrySetProperty(object obj, string name, object value)
    {
        var t = obj.GetType();
        while (t is not null)
        {
            var p = t.GetProperty(name, BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public);
            if (p is not null) { p.SetValue(obj, value); return; }
            t = t.BaseType;
        }
    }

    // === TEST/DEBUG: per-roll attribution probe (Phase 4 Option-A viability) =======================
    // Captures, at the EXACT moment of each StableRandom*/StableRandomDouble roll, the seat signals the
    // mgr can read from its own state, plus the live call stack. The decisive question: can the acting
    // seat be attributed from mgr STATE alone (a router could route on it), or only by reading the STACK?

    /// <summary>One recorded RNG roll. <paramref name="SelfIsSelfTurn"/>/<paramref name="OppoIsSelfTurn"/>
    /// are the mgr-readable seat-turn flags at roll time; <paramref name="Stack"/> is the trimmed call
    /// stack (the only place the acting seat is sometimes visible).</summary>
    internal sealed record RollEntry(
        int Index, string Api, int Arg,
        bool SelfIsSelfTurn, bool OppoIsSelfTurn,
        string Stack);

    // A logging IRandomSource: delegates to the real seeded source but records each roll. Reads the mgr's
    // seat-turn flags (the richest seat signal a mgr-level StableRandom override can see — there is no
    // "current operating seat" field on the mgr) and the call stack at the call site.
    private sealed class RollLoggingRandomSource : IRandomSource
    {
        private readonly IRandomSource _inner;
        private readonly List<RollEntry> _log;
        private readonly Func<HeadlessNetworkBattleMgr?> _mgr;
        private int _i;

        public RollLoggingRandomSource(IRandomSource inner, List<RollEntry> log, Func<HeadlessNetworkBattleMgr?> mgr)
        {
            _inner = inner; _log = log; _mgr = mgr;
        }

        public double NextUnit() { Record("NextUnit", -1); return _inner.NextUnit(); }
        public int NextSelf(int max) { Record("NextSelf", max); return _inner.NextSelf(max); }

        private void Record(string api, int arg)
        {
            bool selfTurn = false, oppoTurn = false;
            try
            {
                var mgr = _mgr();
                if (mgr is not null)
                {
                    selfTurn = mgr.GetBattlePlayer(true).IsSelfTurn;
                    oppoTurn = mgr.GetBattlePlayer(false).IsSelfTurn;
                }
            }
            catch { /* read-only probe; never let a state read abort the roll */ }

            string stack = TrimStack(System.Environment.StackTrace);
            _log.Add(new RollEntry(_i++, api, arg, selfTurn, oppoTurn, stack));
        }

        // Keep the frames that reveal WHO is rolling (mulligan lottery vs draw vs filter vs spin pre-roll),
        // dropping the logger's own frames and System.Environment.
        private static string TrimStack(string raw)
        {
            var lines = (raw ?? "").Split('\n')
                .Select(s => s.Trim())
                .Where(s => s.Length > 0
                    && !s.Contains("RollLoggingRandomSource")
                    && !s.Contains("Environment.get_StackTrace")
                    && !s.Contains("Environment.GetStackTrace"))
                .Select(Shorten)
                .Take(8);
            return string.Join(" <- ", lines);
        }

        // "at Namespace.Type.Method(args) in file:line N" -> "Type.Method" (keep it scannable).
        private static string Shorten(string frame)
        {
            string s = frame.StartsWith("at ") ? frame.Substring(3) : frame;
            int paren = s.IndexOf('(');
            if (paren >= 0) s = s.Substring(0, paren);
            var parts = s.Split('.');
            return parts.Length >= 2 ? parts[^2] + "." + parts[^1] : s;
        }
    }
}
