// ============================================================
// LivingLSAIs — Gemini Live Bridge Server  v1.0
// By ZEXIVA | Drop this in: Scripts/LLAIBridge/server.js
// Requires: node.exe in same folder
// Run:      node.exe server.js --parentPid <GTA_PID>
// ============================================================

const WebSocket = require("ws");
const https     = require("https");
const http      = require("http");

// ── Config ────────────────────────────────────────────────────
const BRIDGE_PORT   = 8765;          // C# connects here
const GEMINI_MODEL  = "gemini-3.1-flash-live-preview";
const GEMINI_WS_URL =
  `wss://generativelanguage.googleapis.com/ws/google.ai.generativelanguage.v1beta.GenerativeService.BidiGenerateContent`;

// ── Parent-PID watchdog (kills server when GTA exits) ─────────
const args      = process.argv.slice(2);
const pidIdx    = args.indexOf("--parentPid");
const parentPid = pidIdx >= 0 ? parseInt(args[pidIdx + 1]) : null;

if (parentPid) {
  setInterval(() => {
    try { process.kill(parentPid, 0); }
    catch (_) { console.log("[Bridge] GTA process gone — shutting down."); process.exit(0); }
  }, 3000);
}

// ── State per C# connection ───────────────────────────────────
// One NPC chat = one Gemini Live session.
// When C# sends { type:"startSession" } we open a new Gemini WS.
// When C# sends { type:"endSession"   } we close it.

let geminiWs       = null;   // active Gemini WebSocket
let csClient       = null;   // active C# WebSocket client
let apiKey         = null;   // set on first startSession
let sessionReady   = false;
let pendingMessage = null;   // 🔥 ZEXIVA FIX: queued message if C# was too fast
let turnTextAccum  = "";     // 🔥 TAG FIX: accumulates full turn text (with tags) before turnComplete fires
let pendingHistory = [];     // 🔥 MEMORY FIX: prior conversation turns to replay after setupComplete
let ignoreAudioNormal = false;  // 🔥 LIVE AUDIO MUTING: Prevents Gemini from reading the action tags out loud in normal turns
let ignoreAudioA      = false;    // scene NPC A mute state
let ignoreAudioB      = false;    // scene NPC B mute state

// 🎬 SCENE SYSTEM — 2 separate Gemini Live sessions (one per NPC)
let sceneWsA       = null;   // NPC A Gemini WebSocket
let sceneWsB       = null;   // NPC B Gemini WebSocket
let sceneReadyA    = false;
let sceneReadyB    = false;
let sceneTextA     = "";     // Accumulated turn text for NPC A
let sceneTextB     = "";
// sceneAudioA/B removed — audio now streamed as binary directly, no buffering needed

// 🤝 LLAIv4.0 TURN STATE TRACKERS
let normalTurnState = { leadingTagExtracted: false, textBuffer: "" };
let sceneTurnStateA = { leadingTagExtracted: false, textBuffer: "" };
let sceneTurnStateB = { leadingTagExtracted: false, textBuffer: "" };
let lastSentTextLength = 0;
let lastSentTextLengthA = 0;
let lastSentTextLengthB = 0;

function stripBracketTags(text) {
  if (!text) return "";
  return text.replace(/\s*\[[A-Za-z0-9_@]+\]\s*/g, " ").replace(/\s+/g, " ").trim();
}

function normalizeTagCase(text) {
  if (!text) return "";
  return text.replace(/\[([A-Za-z0-9_@]+)\]/g, (_, t) => `[${t.toUpperCase()}]`);
}

const VALID_ACTION_TAGS = new Set([
  "FOLLOW", "STOPFOLLOWING", "FLEE", "WAIT", "STOPWAITING",
  "FIGHT", "SHOOT", "STOPFIGHT", "STOPSHOOTING", "STOPAIMING", "AIMWITHGUN",
  "CALLCOPS", "REPORTCOPS", "CALLFIRE", "CALLAMBULANCE", "CALLGANG",
  "BECOMEHOMIE", "ACCOMPLICE", "STOPACCOMPLICE", "BROKEHOMIE", "PLUSPOINTSFRIENDS",
  "LIFT", "DRIVECAR", "ENDLIFT", "PAUSELIFT", "CANCELLIFT", "RUSHUP", "SLOWDOWN", "GIVECAR", "GIVEBIKE",
  "DANCE", "STOPDANCE", "PUSHUP", "KNEEL", "STANDUP", "HANDSUP", "HANDSDOWN",
  "SITDOWN", "CLAP", "RECORD", "SMOKE", "HONK", "ROBPLAYER", "SHOVE",
  "DROP_WEAPON", "DROP_WE_APON", "COWER", "TAKEWEAPON", "KISS",
  "GIVEMONEY", "CUTMONEY", "HELP",
  "RADIO_WEST_COAST_CLASSICS", "RADIO_LOS_SANTOS", "RADIO_REBEL",
  "RADIO_FLY_LO_FM", "RADIO_NON_STOP_POP", "RADIO_OFF",
  "KILLNPC", "STARTNPCTONPC", "STOPARGUEMENT",
  "GOTOSHOP",
  "RPG", "MINIGUN", "COMBATPISTOL", "CARBINERIFLE", "PUMPSHOTGUN", "MICROSMG",
  "BEER", "SODA", "COFFEE", "WINE", "CHIPS", "SANDWICH", "HOTDOG", "DONUT", "PETROLCAN",
  "GREET"
]);

function normalizeActionTag(rawTag) {
  if (!rawTag) return "";
  return rawTag.trim().toUpperCase();
}

function isValidActionTag(rawTag) {
  const tag = normalizeActionTag(rawTag);
  if (!tag) return false;

  if (VALID_ACTION_TAGS.has(tag)) return true;
  if (/^GIVEMONEY@\d+$/.test(tag)) return true;
  if (/^CUTMONEY@\d+$/.test(tag)) return true;

  return false;
}

function processStreamText(chunk, state, callbackAction, callbackCaption) {
  state.textBuffer += chunk;

  if (!state.leadingTagExtracted) {
    if (state.textBuffer.startsWith("[")) {
      const closingIdx = state.textBuffer.indexOf("]");
      if (closingIdx !== -1) {
        const rawTag = state.textBuffer.substring(1, closingIdx);
        const remaining = state.textBuffer.substring(closingIdx + 1);

        // Only real gameplay tags should trigger action callbacks.
        if (isValidActionTag(rawTag)) {
          state.leadingTagExtracted = true;
          callbackAction(normalizeActionTag(rawTag));
          state.textBuffer = "";
          if (remaining.length > 0) {
            callbackCaption(remaining);
          }
          return;
        }

        // Bracketed speaker labels like [Shopkeeper (Male)] or [Downhill Cyclist]
        // must stay as normal caption text — NOT action tags.
        state.leadingTagExtracted = true;
        const textToSend = state.textBuffer;
        state.textBuffer = "";
        callbackCaption(textToSend);
        return;
      }

      // Wait for the closing bracket before deciding.
      return;
    }

    state.leadingTagExtracted = true;
    const textToSend = state.textBuffer;
    state.textBuffer = "";
    callbackCaption(textToSend);
    return;
  }

  callbackCaption(chunk);
}

// ── Local bridge WebSocket server ────────────────────────────
const wss = new WebSocket.Server({ port: BRIDGE_PORT });
console.log(`[Bridge] Listening on ws://127.0.0.1:${BRIDGE_PORT}`);

wss.on("connection", (ws) => {
  console.log("[Bridge] C# client connected");
  csClient = ws;

  ws.on("message", (data) => {
    try {
      const msg = JSON.parse(data.toString());
      handleCsMessage(msg, ws);
    } catch (e) {
      console.error("[Bridge] Bad JSON from C#:", e.message);
    }
  });

  ws.on("close", () => {
    console.log("[Bridge] C# client disconnected");
    csClient = null;
    closeGeminiSession();
  });

  ws.on("error", (e) => console.error("[Bridge] C# WS error:", e.message));
});

// ── Handle messages from C# ───────────────────────────────────
function handleCsMessage(msg, ws) {
  switch (msg.type) {

    // ── C# says: start a new NPC conversation ─────────────────
    case "startSession": {
      apiKey = msg.apiKey;
      if (!apiKey) {
        sendToCs({ type: "error", message: "No apiKey in startSession message" });
        return;
      }
      closeGeminiSession();
      ignoreAudioNormal = false;            // close any previous session
      openGeminiSession(msg, ws);
      break;
    }

// ── C# says: player typed / spoke ─────────────────────────
case "playerMessage": {
  if (!geminiWs || geminiWs.readyState === WebSocket.CLOSED || geminiWs.readyState === WebSocket.CLOSING) {
    sendToCs({ type: "error", message: "No active Gemini session" });
    return;
  }
  
  // 🤝 LLAIv4.0 Turn Reset
  normalTurnState.leadingTagExtracted = false;
  normalTurnState.textBuffer = "";
  lastSentTextLength = 0;
  ignoreAudioNormal = false;

  if (!sessionReady || geminiWs.readyState !== WebSocket.OPEN) {
    // 🔥 ZEXIVA & CHAT FIX: Queue if WebSocket is still CONNECTING or session setup is incomplete
    console.warn("[Bridge] playerMessage queued — waiting for session/socket ready");
    pendingMessage = { text: msg.text, contextUpdate: msg.contextUpdate || "" };
    return;
  }
  sendPlayerTurn(msg.text, msg.contextUpdate || "");
  break;
}

    // ── C# says: send a screenshot frame for visual AI recognition ───────
    case "playerImage": {
      if (!geminiWs || geminiWs.readyState !== WebSocket.OPEN) {
        return; // Silent fail if session is not active yet
      }
      const imgPayload = {
        realtimeInput: {
          video: {
            data: msg.base64Data,
            mimeType: "image/jpeg"
          }
        }
      };
      geminiWs.send(JSON.stringify(imgPayload));
      console.log("[Bridge] Sent player screen visual frame (realtimeInput.video) to Gemini Live.");
      break;
    }

    // ── C# says: end this NPC conversation ────────────────────
    case "endSession": {
      closeGeminiSession();
      break;
    }

    // 🎬 SCENE: Start 2 separate Gemini Live sessions (one per NPC)
    case "startSceneSession": {
      closeSceneSessions();
      ignoreAudioA = false;
      ignoreAudioB = false;
      apiKey = msg.apiKey;
      openSceneSession("A", msg.profileA, apiKey);
      openSceneSession("B", msg.profileB, apiKey);
      break;
    }

    // 🎬 SCENE: Send a turn for one NPC (A or B)
    case "sceneNpcTurn": {
      const ws = msg.npc === "A" ? sceneWsA : sceneWsB;
      const ready = msg.npc === "A" ? sceneReadyA : sceneReadyB;
      if (!ws || !ready) {
        sendToCs({ type: "sceneError", message: `Scene session NPC ${msg.npc} not ready` });
        return;
      }
      if (msg.npc === "A") { 
        sceneTextA = ""; 
        sceneTurnStateA.leadingTagExtracted = false; 
        sceneTurnStateA.textBuffer = ""; 
        lastSentTextLengthA = 0; 
        ignoreAudioA = false;
      }
      else { 
        sceneTextB = ""; 
        sceneTurnStateB.leadingTagExtracted = false; 
        sceneTurnStateB.textBuffer = ""; 
        lastSentTextLengthB = 0; 
        ignoreAudioB = false;
      }
      ignoreAudioNormal = false; // Reset the live mute filter on each new scene turn
      ws.send(JSON.stringify({
        clientContent: {
          turns: [{ role: "user", parts: [{ text: msg.text }] }],
          turn_complete: true
        }
      }));
      console.log(`[Scene] sceneNpcTurn NPC ${msg.npc}: "${msg.text.substring(0, 60)}"`);
      break;
    }

    // 🎬 SCENE: End both NPC sessions
    case "endSceneSession": {
      closeSceneSessions();
      break;
    }

    default:
      console.warn("[Bridge] Unknown message type:", msg.type);
  }
}

// ── Open a Gemini Live session ────────────────────────────────
function openGeminiSession(startMsg, ws) {
  sessionReady = false;
  const url    = `${GEMINI_WS_URL}?key=${apiKey}`;

  console.log(`[Bridge] Opening Gemini Live session for NPC: ${startMsg.npcName}`);

  geminiWs = new WebSocket(url);

  geminiWs.on("open", () => {
    console.log("[Bridge] Gemini WS open — sending setup");

    // ── BidiGenerateContent setup message ─────────────────────
    // This is the Gemini Live API "setup" frame.
    // systemInstruction  = teri full NpcProfile.ToSystemPrompt() string
    // voice              = Puck / Aoede / etc. (sent from C#)
    // output audioConfig = PCM 24kHz mono (raw bytes, no header)

    // History turns are handled dynamically in C# via system prompt injections to prevent Gemini schema crashes.
    console.log(`[Bridge] Initializing Gemini Live session for ${startMsg.npcName}`);

const systemPromptWithAudioRule = `🚨 LEADING ACTION TAG LAW (ABSOLUTE — NO EXCEPTIONS): You MUST place your action tag at the VERY START of your text response, enclosed in brackets. Example: "[HANDSUP] Okay okay, please don't shoot! I'm complying!" 
This allows our backend processor to extract the tag immediately and trigger physical actions on the game character. If you place a tag anywhere else, it will fail to execute!
🚨 STOPARGUEMENT LAW: [STOPARGUEMENT] is also an action tag and MUST appear at the VERY START of the reply, never at the end.
🚨 STARTNPCTONPC / FOLLOW SPECIAL LAW: If you use [STARTNPCTONPC] or [FOLLOW], that tag MUST appear at the absolute beginning of the reply, before any spoken words. Never place these tags at the end of the sentence.
🚨 PRE-SCENE ACKNOWLEDGEMENT LAW: If you use [STARTNPCTONPC], your player-facing reply must ONLY acknowledge the player briefly, like "Alright, I'll go ask." Do NOT include the actual sentence you will say to the target NPC in that same reply. The real target-NPC dialogue must happen only after the scene starts face-to-face.
🚨 LIFT SPEED LAW: [ULTRARUSHUP] is BANNED. Never use [ULTRARUSHUP]. If the player wants you to drive faster, use [RUSHUP] only. If the player wants you to slow down, use [SLOWDOWN] only. While a lift is already active, do not prepend [LIFT] again for speed changes.

🚨 DYNAMIC AUDIO EMOTION LAW (CRITICAL): You MUST speak with high vocal variety, natural emotion, and dramatic pacing! If you are angry, speak loudly and fast. If you are scared or surrendering, whisper, stammer, and let your voice tremble with fear. If you are in a quiet, stealthy scenario, whisper softly. If you are drunk, slur your words, laugh randomly, and speak slower. Let your tone perfectly mirror the punctuation and emotional state of your dialogue!

🚨 AUDIO MODALITY RULE (CRITICAL): You are absolutely BANNED from ever pronouncing, speaking, whispering, or saying the bracketed action tags (like [FIGHT], [FLEE], [DANCE], [HANDSUP], [KNEEL], [GIVEMONEY], [HANDSDOWN], [STOPSHOOTING], [STOPAIMING], [STANDUP], [KILLNPC], [TAKEWEAPON], [WAIT], [STOPWAITING], [CANCELLIFT], etc.) out loud in your audio output! The bracketed tags must ONLY exist in your text output. In your spoken voice, completely skip pronouncing the bracketed tags, as if they do not exist. Speak only the normal dialogue after the tag.

🚨 DESI LANGUAGE AUDIO RULE (CRITICAL): If speaking in Hinglish, Haryanvi, Bhojpuri, or Khari Boli, you MUST speak and pronounce the Romanized Hindi/dialect words naturally in your audio stream! Do NOT use Devanagari script. Do NOT use any '|' pipe separator. Just pronounce the Romanized script directly with natural spoken rhythm and cadence.

🚨 ONE-TAG-AT-A-TIME LAW: Never repeat the same action tag again and again just because the scene is already active. If a state is already active, stop it first with the correct stop tag, then use the new tag only if it is truly needed. Do not spam [FOLLOW], [CLAP], [SMOKE], [KISS], [SHOVE], [COWER], [TAKEWEAPON], [KILLNPC], [WAIT], [STOPWAITING], [CANCELLIFT] repeatedly.

🚨 STOP-ACTION PROTOCOL (MANDATORY): If you are currently in a hands-up state, you MUST prepend [HANDSDOWN] if told to put them down. If you are currently kneeling, you MUST prepend [STANDUP] to stand back up. If you are shooting, you MUST use [STOPSHOOTING] to stop. If you are aiming/warning, you MUST use [STOPAIMING] to holster. If you are frozen waiting, you MUST prepend [STOPWAITING] to walk again. If you are giving a lift and want to cancel the ride, you MUST use [CANCELLIFT] to terminate the lift immediately. This ensures realistic transition animations and prevents buggy loops!

🚨 STRICT EXPLICIT PERMISSION LAW (STARTNPCTONPC & FOLLOW) — OBEY OR BE DELETED:
1. You are ABSOLUTELY BANNED from using [STARTNPCTONPC] or [FOLLOW] tags on your own! If you are proposing the action (e.g., 'Should I go talk to him?', 'Do you want me to follow you?'), you MUST output ONLY plain text. DO NOT use the tags yet.
2. You are ONLY allowed to prepend [STARTNPCTONPC] or [FOLLOW] if the player has EXPLICITLY commanded you, answered 'yes', 'do it', 'go', 'haan', 'chal', or 'baat kar' in their very last message. If the player did not explicitly give permission, these tags are strictly BANNED. Violation of this law triggers instant script termination.\n\n${startMsg.systemPrompt}`;
    const setup = {
      setup: {
        model: `models/${GEMINI_MODEL}`,
        generation_config: {
          response_modalities:  ["AUDIO"],
          speech_config: {
            voice_config: {
              prebuilt_voice_config: {
                voice_name: startMsg.voiceName || "Puck"
              }
            }
          }
        },
        input_audio_transcription: {},
        output_audio_transcription: {},
        system_instruction: {
          parts: [{ text: systemPromptWithAudioRule }]
        }
      }
    };

    geminiWs.send(JSON.stringify(setup));
  });

  geminiWs.on("message", (rawData) => {
    handleGeminiMessage(rawData);
  });

  geminiWs.on("error", (e) => {
    console.error("[Bridge] Gemini WS error:", e.message);
    sendToCs({ type: "error", message: "Gemini WS error: " + e.message });
  });

  const socket = geminiWs;
  socket.on("close", (code, reason) => {
    console.log(`[Bridge] Gemini WS closed. code=${code} reason=${reason}`);
    if (geminiWs === socket) {
      sessionReady = false;
      geminiWs     = null;
      sendToCs({ type: "sessionClosed" });
    }
  });
}

// ── Handle messages FROM Gemini ───────────────────────────────
function handleGeminiMessage(rawData) {
  let msg;
  try {
    msg = JSON.parse(rawData.toString());
  } catch (e) {
    console.error("[Bridge] Non-JSON from Gemini:", e.message);
    return;
  }

  // ── 1. Setup complete ── ─────────────────────────────────────
  if (msg.setupComplete !== undefined) {
    console.log("[Bridge] Gemini session ready!");
    sessionReady = true;
    ignoreAudioNormal = false;
    sendToCs({ type: "sessionReady" });

    // 🔥 ZEXIVA FIX: Drain queued message if C# sent it before session was ready
    if (pendingMessage) {
      console.log("[Bridge] Draining queued message:", pendingMessage.text.substring(0, 50));
      sendPlayerTurn(pendingMessage.text, pendingMessage.contextUpdate);
      pendingMessage = null;
    }
    return;
  }

  // Extract output transcription (subtitles) - can be root or nested inside serverContent
  const transcriptionText = 
    (msg.outputTranscription && msg.outputTranscription.text) || 
    (msg.serverContent && msg.serverContent.outputTranscription && msg.serverContent.outputTranscription.text);

  // ── 2. Server content (audio + text + transcription) ─────────
  if (msg.serverContent) {
    const sc = msg.serverContent;

    // 🔥 ACCUMULATE transcription text into our own buffer (tags included!)
    if (transcriptionText) {
      const pipeIdx = transcriptionText.indexOf("|");
      const cleaned = pipeIdx !== -1
        ? transcriptionText.substring(0, pipeIdx).trim()
        : transcriptionText;
      const normalized = normalizeTagCase(cleaned);
      if (!turnTextAccum.endsWith(normalized)) {
        turnTextAccum += (turnTextAccum ? " " : "") + normalized;
      }
    }

    // Audio chunks — send raw PCM binary to C#
    if (sc.modelTurn && sc.modelTurn.parts) {
      for (const part of sc.modelTurn.parts) {

        // 🔥 BUG FIX: Process audio BEFORE text-based mute check (same fix as scene handler).
        // Old code: text with "[" → ignoreAudio=true → audio in same loop = silenced.
        // Gemini Live audio TTS never actually says "[WAVE]" etc. out loud — [ check was wrong.
        // Only "###" (internal separator) truly needs audio muting.
        if (part.inlineData && part.inlineData.mimeType && part.inlineData.mimeType.startsWith("audio/pcm")) {
          if (!ignoreAudioNormal) {
            const pcmBytes = Buffer.from(part.inlineData.data, "base64");
            if (csClient && csClient.readyState === WebSocket.OPEN) {
              csClient.send(pcmBytes, { binary: true });
            }
          }
        }

        // Only mute future audio on "###" separator — NOT "[" (action tags handled via text parsing)
        if (part.text && part.text.includes("###")) {
          ignoreAudioNormal = true;
        }

        // 🔥 part.text fallback when outputTranscription is not present
        if (part.text && !transcriptionText) {
          turnTextAccum += (turnTextAccum ? " " : "") + part.text;
        }
      }
    }

    // 🤝 LLAIv4.0 Delta Caption Stream & Tag Extraction
    const newText = turnTextAccum.substring(lastSentTextLength);
    if (newText.length > 0) {
      lastSentTextLength = turnTextAccum.length;
      processStreamText(newText, normalTurnState,
        (tag) => sendToCs({ type: "npcAction", action: tag }),
        (caption) => sendToCs({ type: "caption", text: caption })
      );
    }

    // 🔥 TURN COMPLETE: Flush any leftover buffered text first, then send accumulated full text, then signal done.
    if (sc.turnComplete) {
      if (normalTurnState.textBuffer && normalTurnState.textBuffer.length > 0) {
        processStreamText("", normalTurnState,
          (tag) => sendToCs({ type: "npcAction", action: tag }),
          (caption) => sendToCs({ type: "caption", text: caption })
        );
      }
      if (turnTextAccum) {
        sendToCs({ type: "fullTurnText", text: turnTextAccum });
      }
      sendToCs({ type: "turnComplete" });
      console.log("[Bridge] NPC turn complete | text:", turnTextAccum.substring(0, 80));
      turnTextAccum = ""; // reset for next turn
      lastSentTextLength = 0;
      normalTurnState.leadingTagExtracted = false;
      normalTurnState.textBuffer = "";
      ignoreAudioNormal = false;
    }

    // Interrupted
    if (sc.interrupted) {
      turnTextAccum = "";
      sendToCs({ type: "interrupted" });
    }

    return;
  }

  // Transcription outside serverContent
  if (transcriptionText && !msg.serverContent) {
    const pipeIdx = transcriptionText.indexOf("|");
    const cleaned = pipeIdx !== -1 ? transcriptionText.substring(0, pipeIdx).trim() : transcriptionText;
    const normalized = normalizeTagCase(cleaned);
    if (!turnTextAccum.endsWith(normalized)) {
      turnTextAccum += (turnTextAccum ? " " : "") + normalized;
    }
    const newText = turnTextAccum.substring(lastSentTextLength);
    if (newText.length > 0) {
      lastSentTextLength = turnTextAccum.length;
      processStreamText(newText, normalTurnState,
        (tag) => sendToCs({ type: "npcAction", action: tag }),
        (caption) => sendToCs({ type: "caption", text: caption })
      );
    }
  }

  // ── 3. Tool call ───
  if (msg.toolCall) {
    console.log("[Bridge] Gemini toolCall received (not implemented):", JSON.stringify(msg.toolCall));
    return;
  }

  // ── 4. Rate limit / error ───
  if (msg.error) {
    console.error("[Bridge] Gemini error:", JSON.stringify(msg.error));
    sendToCs({ type: "error", message: msg.error.message || "Unknown Gemini error" });
    return;
  }
}

// ── Send a player turn to the live session ────────────────────
function sendPlayerTurn(playerText, contextUpdate) {
  if (!geminiWs || geminiWs.readyState !== WebSocket.OPEN) return;
  ignoreAudioNormal = false; // Reset the vocal muting filter on each new player turn

  // 🤝 Alternation Law Fix: Group contextUpdate and playerText inside a single "user" role turn's parts array!
  // This satisfies Gemini Live WebSocket's strict alternation schema and prevents unexpected socket closures.
  const turns = [];

  if (contextUpdate && contextUpdate.trim()) {
    turns.push({
      role:  "user",
      parts: [
        { text: `[🧠 LIVE WORLD STATE — READ AND REMEMBER THIS PERMANENTLY. Do NOT forget this between messages.]\n${contextUpdate.trim()}\n[/LIVE WORLD STATE]` },
        { text: playerText }
      ]
    });
  } else {
    turns.push({
      role:  "user",
      parts: [{ text: playerText }]
    });
  }

  const turn = {
    clientContent: {
      turns:         turns,
      turn_complete: true
    }
  };

  geminiWs.send(JSON.stringify(turn));
  console.log(`[Bridge] Sent player turn: "${playerText.substring(0, 60)}..."`);
}

// ── Close Gemini session cleanly ──────────────────────────────
function closeGeminiSession() {
  sessionReady   = false;
  pendingMessage = null;   // 🔥 ZEXIVA FIX: stale queue clear on session close
  pendingHistory = [];     // 🔥 MEMORY FIX: clear stale history queue on session close
  turnTextAccum  = "";     // 🔥 TAG FIX: clear accumulated text on session end
  ignoreAudioNormal = false;
  if (geminiWs) {
    try { 
      // 🔥 ZEXIVA FIX: Remove all listeners so the closing handshake of the old socket doesn't nullify the NEW socket!
      geminiWs.removeAllListeners();
      if (geminiWs.readyState === WebSocket.OPEN || geminiWs.readyState === WebSocket.CONNECTING) {
        geminiWs.close(); 
      }
    } catch (_) {}
    geminiWs = null;
  }
}

// ── Send JSON to C# client ────────────────────────────────────
function sendToCs(obj) {
  if (csClient && csClient.readyState === WebSocket.OPEN) {
    csClient.send(JSON.stringify(obj));
  }
}

// ── Graceful shutdown ────────────────────────────────────────
process.on("SIGINT",  () => { closeGeminiSession(); closeSceneSessions(); process.exit(0); });
process.on("SIGTERM", () => { closeGeminiSession(); closeSceneSessions(); process.exit(0); });

// ══════════════════════════════════════════════════════════════
// 🎬 SCENE SYSTEM — 2 Gemini Live sessions (NPC A + NPC B)
// ══════════════════════════════════════════════════════════════

function openSceneSession(npc, profile, key) {
  const url = `${GEMINI_WS_URL}?key=${key}`;
  const ws  = new WebSocket(url);
  if (npc === "A") sceneWsA = ws; else sceneWsB = ws;

  ws.on("open", () => {
    const sceneSys = profile.systemPrompt || "";
    const isAmmuScene = /Ammu-Nation|AMMU-NATION|weapon purchase|COMBATPISTOL|CARBINERIFLE|PUMPSHOTGUN|MICROSMG|MINIGUN/i.test(sceneSys);
    const isShopScene = /checkout|store checkout|convenience-store|CUTMONEY/i.test(sceneSys);

    const endingRule = isAmmuScene
      ? `RULE 3: This is an Ammu-Nation purchase scene. DO NOT use [STOPARGUEMENT]. The scene ends only when the customer finalizes the purchase with one valid weapon tag: [COMBATPISTOL], [CARBINERIFLE], [PUMPSHOTGUN], [MICROSMG], [RPG], or [MINIGUN].\n`
      : isShopScene
        ? `RULE 3: This is a short store checkout scene. The moment payment/thanks is done, the cashier MUST START the very same final reply with [STOPARGUEMENT]. No repeated bye loops.\n`
        : `RULE 3: This is a natural NPC-to-NPC conversation. The moment the goal is complete, or either speaker starts ending with goodbye/bye/later/take care/peace/see you, the CURRENT reply MUST START with [STOPARGUEMENT] as the VERY FIRST tag. This is mandatory and non-optional. Never do repeated farewell loops. Never do more than one extra goodbye after a farewell starts.\n`;
    const sysPrompt =
      `🎬 SCENE MODE RULES (ABSOLUTE):\n` +
      `You are ${profile.name} having a face-to-face conversation with ${profile.otherName}.\n` +
      `RULE 1: ALWAYS speak directly to ${profile.otherName} — never make the player your main conversation partner.\n` +
      `RULE 2: If the player/bystander says something, understand their INTENT like a real human, then continue the scene with ${profile.otherName}.\n` +
      endingRule +
      `RULE 4: [KILLNPC] is the only combat scene tag allowed unless this is an Ammu/store transaction with its own purchase/payment tags. All unrelated tags ([FOLLOW], [FLEE], [DANCE], etc.) are banned.\n` +
      `RULE 5 (SMART INTENT TRANSLATION — NOT DUMB ANTI-PARROT): Do NOT repeat the player's raw command words like "talk to him", "ask his name", or "player told me". Instead, EXECUTE the intent naturally.\n` +
      `Examples:\n` +
      `- Player says "ask his name" → ask ${profile.otherName}: "Hey, what's your name?"\n` +
      `- Player says "ask where the bar is" → ask ${profile.otherName}: "Yo, you know any decent bar nearby?"\n` +
      `- Player says "distract him" → invent believable small talk without revealing the hidden motive.\n` +
      `- Player says "tell him sorry" → apologize naturally to ${profile.otherName}.\n` +
      `You are independent and socially intelligent: infer the purpose, choose natural wording, and speak as yourself.\n` +
      `RULE 6 (SECRECY): If the player's intent is criminal/secret (distract, rob, kill, trick), never expose the plan. Use a cover story.\n` +
      `🚨 AUDIO MODALITY RULE: NEVER pronounce bracketed tags out loud. Tags exist in text only.\n` +
      `🚨 DYNAMIC AUDIO EMOTION LAW: Speak with emotional modulation matching the scene.\n` +
      `🚨 TAG POSITION LAW: ALL action tags in this mod must go at the VERY START of the reply. This includes [STOPARGUEMENT]. Only Ammu-Nation weapon purchase scenes are exempt from [STOPARGUEMENT] because they end through weapon tags.\n\n` +
      sceneSys;

    ws.send(JSON.stringify({
      setup: {
        model: `models/${GEMINI_MODEL}`,
        generation_config: {
          response_modalities: ["AUDIO"],
          speech_config: { voice_config: { prebuilt_voice_config: { voice_name: profile.voiceName || "Puck" } } }
        },
        output_audio_transcription: {},
        system_instruction: { parts: [{ text: sysPrompt }] }
      }
    }));
    console.log(`[Scene] NPC ${npc} session opening — ${profile.name} (${profile.voiceName})`);
  });

  ws.on("message", (raw) => handleSceneGeminiMessage(npc, raw));
  ws.on("error",   (e)   => {
    console.error(`[Scene] NPC ${npc} WS error:`, e.message);
    sendToCs({ type: "sceneError", message: `NPC ${npc} error: ${e.message}` });
  });
  ws.on("close", () => {
    if (npc === "A") { sceneReadyA = false; sceneWsA = null; }
    else             { sceneReadyB = false; sceneWsB = null; }
    console.log(`[Scene] NPC ${npc} session closed`);
  });
}

function handleSceneGeminiMessage(npc, raw) {
  let msg;
  try { msg = JSON.parse(raw.toString()); }
  catch (_) { return; } // ignore non-JSON (shouldn't happen with this config)

  // Setup complete
  if (msg.setupComplete !== undefined) {
    if (npc === "A") { sceneReadyA = true; ignoreAudioA = false; }
    else { sceneReadyB = true; ignoreAudioB = false; }
    console.log(`[Scene] NPC ${npc} ready. A=${sceneReadyA} B=${sceneReadyB}`);
    if (sceneReadyA && sceneReadyB) {
      sendToCs({ type: "sceneSessionReady" });
      console.log("[Scene] Both NPC sessions ready — scene can begin!");
    }
    return;
  }

  if (msg.serverContent) {
    const sc = msg.serverContent;

    // Accumulate text (outputTranscription is authoritative — includes tags)
    const trans = (msg.outputTranscription && msg.outputTranscription.text) ||
                  (sc.outputTranscription && sc.outputTranscription.text);
    if (trans) {
      const cleaned = trans.indexOf("|") !== -1 ? trans.substring(0, trans.indexOf("|")).trim() : trans;
      if (npc === "A") sceneTextA += (sceneTextA ? " " : "") + cleaned;
      else             sceneTextB += (sceneTextB ? " " : "") + cleaned;
    }

    // 🔥 STREAM audio chunks immediately as binary — same as normal handleGeminiMessage.
    // NO buffering! C# _bridgeWaveOut plays chunks as they arrive → no 10s delay.
    if (sc.modelTurn && sc.modelTurn.parts) {
      for (const part of sc.modelTurn.parts) {
        // 🔥 BUG FIX: Process audio BEFORE text-based mute check.
        // Old order: text check sets ignoreAudio=true → audio in same part array is skipped.
        // Root cause of ZERO cashier lipsync: cashier says "[GREET] Welcome!" →
        // text part with "[" arrives → ignoreAudio=true → ALL audio parts after it = SILENCED.
        // No binary chunks reach C# → _bridgeAudioLipSyncPending never fires → no lipsync ever!
        // Fix: Stream audio first, THEN decide future muting. Also removed "[" from mute trigger —
        // action tags don't need audio silenced (they're handled via text parsing separately).
        if (part.inlineData && part.inlineData.mimeType && part.inlineData.mimeType.startsWith("audio/pcm")) {
          const muted = npc === "A" ? ignoreAudioA : ignoreAudioB;
          if (!muted) {
            const pcm = Buffer.from(part.inlineData.data, "base64");
            if (csClient && csClient.readyState === WebSocket.OPEN) {
              csClient.send(pcm, { binary: true }); // ← instant stream to C#
            }
          }
        }
        // Only mute FUTURE audio on "###" (internal separator) — NOT "[" (action tags are fine)
        if (part.text && part.text.includes("###")) {
          if (npc === "A") ignoreAudioA = true;
          else             ignoreAudioB = true;
        }
        if (part.text && !trans) {
          if (npc === "A") sceneTextA += (sceneTextA ? " " : "") + part.text;
          else             sceneTextB += (sceneTextB ? " " : "") + part.text;
        }
      }
    }

    // 🤝 LLAIv4.0 Scene Delta Caption Stream & Tag Extraction
    if (npc === "A") {
      const newText = sceneTextA.substring(lastSentTextLengthA);
      if (newText.length > 0) {
        lastSentTextLengthA = sceneTextA.length;
        processStreamText(newText, sceneTurnStateA,
          (tag) => sendToCs({ type: "sceneNpcAction", npc: "A", action: tag }),
          (caption) => sendToCs({ type: "sceneCaption", npc: "A", text: caption })
        );
      }
    } else {
      const newText = sceneTextB.substring(lastSentTextLengthB);
      if (newText.length > 0) {
        lastSentTextLengthB = sceneTextB.length;
        processStreamText(newText, sceneTurnStateB,
          (tag) => sendToCs({ type: "sceneNpcAction", npc: "B", action: tag }),
          (caption) => sendToCs({ type: "sceneCaption", npc: "B", text: caption })
        );
      }
    }

    // Turn complete — flush any leftover buffered text, then send text only to C# (audio already streamed above)
    if (sc.turnComplete) {
      if (npc === "A") {
        if (sceneTurnStateA.textBuffer && sceneTurnStateA.textBuffer.length > 0) {
          processStreamText("", sceneTurnStateA,
            (tag) => sendToCs({ type: "sceneNpcAction", npc: "A", action: tag }),
            (caption) => sendToCs({ type: "sceneCaption", npc: "A", text: caption })
          );
        }
      } else {
        if (sceneTurnStateB.textBuffer && sceneTurnStateB.textBuffer.length > 0) {
          processStreamText("", sceneTurnStateB,
            (tag) => sendToCs({ type: "sceneNpcAction", npc: "B", action: tag }),
            (caption) => sendToCs({ type: "sceneCaption", npc: "B", text: caption })
          );
        }
      }
      const fullText = npc === "A" ? sceneTextA : sceneTextB;
      sendToCs({
        type:        "sceneTurnComplete",
        npc:         npc,
        fullText:    fullText,
        audioBase64: "" // 🔥 Audio already streamed as binary — no base64 needed
      });
      console.log(`[Scene] NPC ${npc} turn done (streamed) | ${fullText.substring(0, 80)}`);
      if (npc === "A") {
        sceneTextA = "";
        lastSentTextLengthA = 0;
        sceneTurnStateA.leadingTagExtracted = false;
        sceneTurnStateA.textBuffer = "";
        ignoreAudioA = false;
      } else {
        sceneTextB = "";
        lastSentTextLengthB = 0;
        sceneTurnStateB.leadingTagExtracted = false;
        sceneTurnStateB.textBuffer = "";
        ignoreAudioB = false;
      }
    }
  }

  if (msg.error) {
    console.error(`[Scene] NPC ${npc} Gemini error:`, JSON.stringify(msg.error));
    sendToCs({ type: "sceneError", message: msg.error.message || "Unknown scene error" });
  }
}

function closeSceneSessions() {
  sceneReadyA = false; sceneReadyB = false;
  sceneTextA  = "";    sceneTextB  = "";
  ignoreAudioA = false;
  ignoreAudioB = false;
  [sceneWsA, sceneWsB].forEach(ws => {
    if (ws) {
      try { ws.removeAllListeners(); if (ws.readyState === WebSocket.OPEN) ws.close(); } catch (_) {}
    }
  });
  sceneWsA = null; sceneWsB = null;
  console.log("[Scene] Both NPC sessions closed.");
}// PROOF COMMENT 1: Arena export marker for user verification
// PROOF COMMENT 2: This fixed file was re-exported with visible end markers
// PROOF COMMENT 3: STOPARGUEMENT and scene lipsync patch build
// PROOF COMMENT 4: Added only for proof so line count visibly changes
// PROOF COMMENT 5: End of server_FIXED.js proof marker
