const EMPTY = 0;
const MAN = 1;
const BALL = 2;
const MIN_SIZE = 3;
const MAX_SIZE = 21;
const ONLINE_POLL_MS = 1000;
const MAX_THINK_SIMS = 100000;
const LOCAL_AI_BASE_URLS = ["http://localhost:8080", "http://127.0.0.1:8080", "http://[::1]:8080"];
const TREE_SCHEMA = "phutball-online-game-tree";
const LEGACY_TREE_SCHEMA = "phutball-simplified-game-tree";

let resp = null;
let selectingJump = false;
let setupMode = false;
let setupBaseResp = null;
let setupDirty = false;
let suppressSetupClick = false;
let draggingBall = false;
let busy = false;
let appError = null;
let hoverTarget = null;
let boardPerspectiveFlipped = false;
let online = {
  roomCode: "",
  playerToken: "",
  playerNum: null,
  version: -1,
  liveResp: null,
  liveNodeId: null,
  moveCount: 0,
  moves: [],
  chat: [],
  players: {},
  pendingUndo: null,
  pendingNewGame: null,
  pollTimer: null,
  error: null,
};
let localAi = {
  connected: false,
  checking: false,
  analyzing: false,
  thinkingSims: 0,
  models: [],
  modelId: "",
  baseUrl: "",
  analysis: null,
  error: null,
  lastKey: "",
  suggestedAction: null,
};

let tree = {};
let rootId = null;
let currentId = null;
let localCurrentId = null;
let nextNodeId = 1;
let preferredChild = new Map();

const els = {};

function initElements() {
  [
    "status", "move-number", "play-mode", "rows-value", "cols-value",
    "rows-down", "rows-up", "cols-down", "cols-up",
    "setup-button", "undo-button", "back-button",
    "forward-button",
    "flip-board-button", "new-game-button", "player1-name", "player2-name", "board",
    "end-turn-button", "move-tree", "tree-toggle",
    "tree-body", "online-panel",
    "online-create-button", "online-player-select", "online-code-input", "online-join-button",
    "online-leave-button", "online-status", "online-undo-request-button",
    "online-undo-accept-button", "online-undo-reject-button",
    "online-new-game-request-button", "online-new-game-accept-button",
    "online-new-game-reject-button", "return-live-button",
    "return-live-card",
    "online-chat-card", "online-chat-messages", "online-chat-input", "online-chat-send-button",
    "download-tree-button", "load-tree-button", "load-tree-input",
    "ai-assist-card", "ai-refresh-button", "ai-toggle", "ai-assist-body",
    "ai-assist-status", "ai-download-box",
    "ai-model-row", "ai-model-select", "ai-analysis-current",
    "ai-think-row", "ai-think-button", "ai-sims-select",
    "turn-indicator", "info-panel-title", "goal-top", "goal-bottom",
  ].forEach(id => {
    els[toCamel(id)] = document.getElementById(id);
  });
}

function toCamel(value) {
  return value.replace(/-([a-z])/g, (_, char) => char.toUpperCase());
}

async function post(url, body) {
  const response = await fetch(url, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  const data = await response.json();
  if (!response.ok) throw new Error(data.error || `Server error ${response.status}`);
  return data;
}

async function getJSON(url) {
  const response = await fetch(url, { cache: url.startsWith("/online/") ? "no-store" : "default" });
  const data = await response.json();
  if (!response.ok) throw new Error(data.error || `Server error ${response.status}`);
  return data;
}

function currentLocalOrigin() {
  const host = window.location.hostname;
  return host === "localhost" || host === "127.0.0.1" || host === "::1"
    ? window.location.origin
    : "";
}

function localAiBaseUrls() {
  return Array.from(new Set([currentLocalOrigin(), ...LOCAL_AI_BASE_URLS].filter(Boolean)));
}

async function localAiFetch(path, options = {}) {
  const defaultBases = localAiBaseUrls();
  const bases = localAi.baseUrl
    ? [localAi.baseUrl, ...defaultBases.filter(url => url !== localAi.baseUrl)]
    : defaultBases;
  let lastError = null;
  for (const baseUrl of bases) {
    try {
      const response = await fetch(`${baseUrl}${path}`, options);
      localAi.baseUrl = baseUrl;
      return response;
    } catch (err) {
      lastError = err;
    }
  }
  throw lastError || new Error("Local AI request failed.");
}

async function localAiGet(path) {
  const response = await localAiFetch(path, { cache: "no-store" });
  const data = await response.json();
  if (!response.ok) throw new Error(data.error || `Local AI error ${response.status}`);
  return data;
}

async function localAiPost(path, body) {
  const response = await localAiFetch(path, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  const data = await response.json();
  if (!response.ok) throw new Error(data.error || `Local AI error ${response.status}`);
  return data;
}

function currentRows() {
  return parseInt(els.rowsValue.textContent, 10);
}

function currentCols() {
  return parseInt(els.colsValue.textContent, 10);
}

function viewRow(row, source = resp) {
  if (!source || !boardPerspectiveFlipped) return row;
  return source.rows - 1 - row;
}

function unviewRow(row, source = resp) {
  return viewRow(row, source);
}

function viewOffRow(row, source = resp) {
  if (!source || !boardPerspectiveFlipped) return row;
  if (row < 0) return source.rows;
  if (row >= source.rows) return -1;
  return viewRow(row, source);
}

function updateGoalLabels() {
  if (!els.goalTop || !els.goalBottom) return;
  els.goalTop.textContent = boardPerspectiveFlipped ? "Player 1 goal" : "Player 2 goal";
  els.goalBottom.textContent = boardPerspectiveFlipped ? "Player 2 goal" : "Player 1 goal";
}

function currentNode() {
  return tree[currentId] || null;
}

function currentAnalysisKey() {
  if (!resp) return "";
  return JSON.stringify({
    node: currentId,
    rows: resp.rows,
    cols: resp.cols,
    state: resp.state,
    modelId: localAi.modelId,
  });
}

function isPublicHttpsPage() {
  return window.location.protocol === "https:" && !["localhost", "127.0.0.1", "::1"].includes(window.location.hostname);
}

function clearLocalAiSuggestion() {
  localAi.suggestedAction = null;
  hoverTarget = null;
}

function currentPlayerNumber(source = resp) {
  return source && source.current_player === 1 ? 1 : 2;
}

function playerName(playerNum) {
  const input = playerNum === 1 ? els.player1Name : els.player2Name;
  return input.value.trim() || `Player ${playerNum}`;
}

function isGenericPlayerName(value, playerNum) {
  const trimmed = String(value || "").trim();
  return trimmed === "" ||
    trimmed === "Human" ||
    trimmed === `Human ${playerNum}` ||
    trimmed === `Player ${playerNum}`;
}

function isOnlineMode() {
  return els.playMode.value === "online";
}

function isAtOnlineLive() {
  return !isOnlineMode() || (!!online.liveNodeId && currentId === online.liveNodeId);
}

function onlineCurrentPlayerNum(source = resp) {
  return source && source.current_player === 1 ? 1 : 2;
}

function canSubmitOnlineLiveMove() {
  return isOnlineMode() &&
    !!online.roomCode &&
    !!online.playerToken &&
    online.playerNum === onlineCurrentPlayerNum(resp) &&
    !!online.players?.["1"] &&
    !!online.players?.["2"] &&
    isAtOnlineLive() &&
    !online.pendingUndo &&
    !online.pendingNewGame;
}

function canPlayViewedPosition() {
  if (!resp || resp.is_terminal || busy) return false;
  if (isOnlineMode()) {
    return canSubmitOnlineLiveMove() ||
      (!!online.roomCode && !!online.playerToken && !isAtOnlineLive());
  }
  return true;
}

function userCanAct() {
  return canPlayViewedPosition();
}

function onlineBlockedMoveStatus() {
  if (!isOnlineMode() || !isAtCurrentPosition() || canSubmitOnlineLiveMove()) return "";
  if (!online.roomCode) return "Create or join an online room";
  if (!online.playerToken) return "Join the room to play";
  if (!online.players?.["1"] || !online.players?.["2"]) return "Waiting for opponent to join";
  if (online.pendingUndo) return "Undo request pending";
  if (online.pendingNewGame) return "New game request pending";
  if (online.playerNum !== onlineCurrentPlayerNum(resp)) return `${playerName(onlineCurrentPlayerNum(resp))}'s turn`;
  return "";
}

function syncNamesForMode() {
  const mode = els.playMode.value;
  if (mode === "online") {
    const p1 = online.players?.["1"];
    const p2 = online.players?.["2"];
    if (online.roomCode) {
      if (!(online.playerNum === 1 && document.activeElement === els.player1Name)) {
        els.player1Name.value = p1?.name || "Player 1";
      }
      if (!(online.playerNum === 2 && document.activeElement === els.player2Name)) {
        els.player2Name.value = p2?.name || "Player 2";
      }
    } else {
      if (isGenericPlayerName(els.player1Name.value, 1) || els.player1Name.readOnly) els.player1Name.value = "Player 1";
      if (isGenericPlayerName(els.player2Name.value, 2) || els.player2Name.readOnly) els.player2Name.value = "Player 2";
    }
  } else {
    if (els.player1Name.readOnly) els.player1Name.value = "Human 1";
    if (els.player2Name.readOnly) els.player2Name.value = "Human 2";
  }
  els.player1Name.readOnly = mode === "online" && !!online.roomCode && online.playerNum !== 1;
  els.player2Name.readOnly = mode === "online" && !!online.roomCode && online.playerNum !== 2;
  if (document.activeElement?.readOnly) document.activeElement.blur();
}

function resetTree(initialResp) {
  nextNodeId = 1;
  tree = {};
  rootId = addNode(null, initialResp, {
    label: "Start",
    kind: "root",
    player: null,
    action: null,
    analysis: null,
  });
  currentId = rootId;
  localCurrentId = rootId;
  preferredChild = new Map();
}

function addNode(parentId, nodeResp, meta) {
  const id = String(nextNodeId++);
  tree[id] = { id, parentId, childIds: [], resp: nodeResp, meta };
  if (parentId && tree[parentId]) tree[parentId].childIds.push(id);
  return id;
}

function upsertMoveNode(parentId, nodeResp, meta) {
  return upsertMoveNodeWithPreference(parentId, nodeResp, meta, true);
}

function upsertMoveNodeWithPreference(parentId, nodeResp, meta, promoteToMain) {
  const existingId = findExistingChild(parentId, meta);
  if (existingId) {
    tree[existingId].resp = nodeResp;
    tree[existingId].meta = {
      ...tree[existingId].meta,
      ...meta,
      analysis: meta.analysis || tree[existingId].meta.analysis,
    };
    if (promoteToMain) preferredChild.set(parentId, existingId);
    return existingId;
  }
  const id = addNode(parentId, nodeResp, meta);
  if (promoteToMain) preferredChild.set(parentId, id);
  return id;
}

function addTreeChild(nodeResp, meta) {
  const parentId = currentId;
  const wasAtCurrent = currentId === currentPositionId();
  const id = upsertMoveNodeWithPreference(parentId, nodeResp, meta, wasAtCurrent);
  currentId = id;
  if (!isOnlineMode() && wasAtCurrent) localCurrentId = id;
  resp = tree[id].resp;
}

function findExistingChild(parentId, meta) {
  const parent = tree[parentId];
  if (!parent || meta.kind !== "move") return null;
  return parent.childIds.find(id => {
    const child = tree[id];
    if (meta.source === "online" && child.meta.source === "online") {
      return child.meta.onlineIndex === meta.onlineIndex;
    }
    return (
      child.meta.kind === "move" &&
      child.meta.action === meta.action &&
      child.meta.player === meta.player
    );
  }) || null;
}

function treeExportPayload() {
  return {
    schema: TREE_SCHEMA,
    version: 1,
    exportedAt: new Date().toISOString(),
    rows: resp?.rows || currentRows(),
    cols: resp?.cols || currentCols(),
    rootId,
    currentId,
    localCurrentId: currentPositionId(),
    nextNodeId,
    preferredChild: Array.from(preferredChild.entries()),
    tree,
  };
}

function downloadGameTree() {
  if (!rootId || !tree[rootId]) return;
  const payload = treeExportPayload();
  const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" });
  const url = URL.createObjectURL(blob);
  const anchor = document.createElement("a");
  const stamp = new Date().toISOString().replace(/[:.]/g, "-");
  anchor.href = url;
  anchor.download = `phutball-game-tree-${payload.rows}x${payload.cols}-${stamp}.json`;
  document.body.appendChild(anchor);
  anchor.click();
  anchor.remove();
  URL.revokeObjectURL(url);
}

function validateTreePayload(payload) {
  if (!payload || ![TREE_SCHEMA, LEGACY_TREE_SCHEMA].includes(payload.schema)) {
    throw new Error("That is not a Phutball game-tree file.");
  }
  if (!payload.tree || !payload.rootId || !payload.currentId || !payload.tree[payload.rootId] || !payload.tree[payload.currentId]) {
    throw new Error("The game-tree file is missing required tree data.");
  }
  Object.entries(payload.tree).forEach(([id, node]) => {
    if (node.id !== id || !node.resp || !node.meta || !Array.isArray(node.childIds)) {
      throw new Error("The game-tree file contains an invalid node.");
    }
  });
}

async function loadGameTreeFile(file) {
  if (!file) return;
  if (isOnlineMode()) {
    appError = "Loading saved game trees is only available in Local mode.";
    els.loadTreeInput.value = "";
    render();
    return;
  }
  try {
    const payload = JSON.parse(await file.text());
    validateTreePayload(payload);
    resetOnlineState();
    setupMode = false;
    selectingJump = false;
    draggingBall = false;
    tree = payload.tree;
    rootId = payload.rootId;
    currentId = payload.currentId;
    nextNodeId = Number(payload.nextNodeId) || (Object.keys(tree).length + 1);
    preferredChild = new Map(payload.preferredChild || []);
    localCurrentId = payload.localCurrentId && tree[payload.localCurrentId]
      ? payload.localCurrentId
      : mainLineLeafId();
    resp = tree[currentId].resp;
    els.rowsValue.textContent = String(payload.rows || resp.rows);
    els.colsValue.textContent = String(payload.cols || resp.cols);
    appError = null;
    render();
  } catch (err) {
    appError = `Could not load game tree: ${err.message}`;
    render();
  } finally {
    els.loadTreeInput.value = "";
  }
}

function clearOnlineTimer() {
  if (online.pollTimer !== null) {
    clearTimeout(online.pollTimer);
    online.pollTimer = null;
  }
}

function onlineStorageKey() {
  return online.roomCode ? `phutball-online-${online.roomCode}` : "";
}

function saveOnlineSession() {
  const key = onlineStorageKey();
  if (!key) return;
  sessionStorage.setItem(key, JSON.stringify({
    roomCode: online.roomCode,
    playerToken: online.playerToken,
    playerNum: online.playerNum,
  }));
}

function savedOnlineSession(code) {
  if (!code) return {};
  try {
    return JSON.parse(sessionStorage.getItem(`phutball-online-${code}`) || "{}");
  } catch {
    return {};
  }
}

function onlineRoomUrl(code = online.roomCode) {
  if (!code) return "";
  const url = new URL(window.location.href);
  url.searchParams.set("room", code);
  return url.toString();
}

async function copyOnlineRoomLink() {
  const url = onlineRoomUrl();
  if (!url) return;
  try {
    await navigator.clipboard.writeText(url);
    online.error = null;
    els.onlineStatus.textContent = `Copied room link: ${url}`;
  } catch {
    window.prompt("Copy room link", url);
  }
}

function applyInitialRoomParam() {
  const params = new URLSearchParams(window.location.search);
  const code = (params.get("room") || "").trim().toUpperCase();
  if (!code) return;
  els.playMode.value = "online";
  els.onlineCodeInput.value = code.slice(0, 4);
  syncNamesForMode();
  render();
}

function applyInitialModeParam() {
  const params = new URLSearchParams(window.location.search);
  const mode = params.get("mode");
  if (mode === "local") {
    els.playMode.value = "human-human";
  } else if (mode === "online") {
    els.playMode.value = "online";
  }
}

function scheduleOnlinePoll(immediate = false) {
  clearOnlineTimer();
  if (!isOnlineMode() || !online.roomCode || !online.playerToken) return;
  online.pollTimer = setTimeout(pollOnlineState, immediate ? 0 : ONLINE_POLL_MS);
}

async function pollOnlineState() {
  clearOnlineTimer();
  if (!isOnlineMode() || !online.roomCode || !online.playerToken) return;
  try {
    const data = await getJSON(`/online/state/${encodeURIComponent(online.roomCode)}?player_token=${encodeURIComponent(online.playerToken)}`);
    applyOnlinePayload(data);
    online.error = null;
  } catch (err) {
    online.error = err.message;
  }
  render();
  scheduleOnlinePoll();
}

function resetOnlineState() {
  clearOnlineTimer();
  online = {
    roomCode: "",
    playerToken: "",
    playerNum: null,
    version: -1,
    liveResp: null,
    liveNodeId: null,
    moveCount: 0,
    moves: [],
    chat: [],
    players: {},
    pendingUndo: null,
    pendingNewGame: null,
    pollTimer: null,
    error: null,
  };
}

function compatibleLocalAiModels() {
  if (!resp) return [];
  return localAi.models.filter(model => model.rows === resp.rows && model.cols === resp.cols);
}

function syncLocalAiModelSelect() {
  if (!els.aiModelSelect) return;
  const compatible = compatibleLocalAiModels();
  const previous = localAi.modelId || els.aiModelSelect.value;
  els.aiModelSelect.innerHTML = "";
  if (compatible.length === 0) {
    const option = document.createElement("option");
    option.value = "";
    option.textContent = resp ? `No ${resp.rows}x${resp.cols} model` : "No model";
    els.aiModelSelect.appendChild(option);
    els.aiModelSelect.disabled = true;
    localAi.modelId = "";
    return;
  }
  compatible.forEach(model => {
    const option = document.createElement("option");
    option.value = model.id;
    option.textContent = model.name;
    els.aiModelSelect.appendChild(option);
  });
  const next = compatible.some(model => model.id === previous) ? previous : compatible[0].id;
  els.aiModelSelect.value = next;
  els.aiModelSelect.disabled = false;
  localAi.modelId = next;
}

async function checkLocalAi() {
  if (isPublicHttpsPage()) {
    localAi.connected = false;
    localAi.checking = false;
    localAi.models = [];
    localAi.modelId = "";
    localAi.analysis = null;
    localAi.error = null;
    render();
    return;
  }
  localAi.checking = true;
  localAi.error = null;
  localAi.analysis = null;
  localAi.lastKey = "";
  clearLocalAiSuggestion();
  render();
  try {
    const health = await localAiGet("/health");
    if (health.service !== "phutball-simplified") {
      throw new Error("Local server is not Phutball Simplified.");
    }
    const data = await localAiGet("/models");
    localAi.connected = true;
    localAi.models = data.models || [];
    syncLocalAiModelSelect();
    localAi.error = null;
  } catch (err) {
    localAi.connected = false;
    localAi.models = [];
    localAi.modelId = "";
    localAi.baseUrl = "";
    localAi.analysis = null;
    localAi.error = `Local AI is not connected: ${err.message}`;
  } finally {
    localAi.checking = false;
  }
  render();
}

async function refreshLocalAiAnalysis(force = false) {
  if (!resp || !localAi.connected || !localAi.modelId || localAi.analyzing) return;
  const key = currentAnalysisKey();
  if (!force && key === localAi.lastKey) return;
  localAi.lastKey = key;
  localAi.analyzing = true;
  localAi.error = null;
  clearLocalAiSuggestion();
  render();
  try {
    const data = await localAiPost("/ai_analysis", {
      rows: resp.rows,
      cols: resp.cols,
      state: resp.state,
      model_id: localAi.modelId,
    });
    localAi.analysis = data.analysis || null;
    const node = currentNode();
    if (node) node.meta.analysis = localAi.analysis;
  } catch (err) {
    localAi.analysis = { available: false, error: err.message };
  } finally {
    localAi.analyzing = false;
  }
  render();
}

async function thinkLocalAiAnalysis() {
  if (!resp || !localAi.connected || !localAi.modelId || localAi.analyzing) return;
  localAi.lastKey = currentAnalysisKey();
  localAi.analyzing = true;
  localAi.thinkingSims = selectedThinkSims();
  localAi.error = null;
  clearLocalAiSuggestion();
  render();
  try {
    const data = await localAiPost("/ai_analysis", {
      rows: resp.rows,
      cols: resp.cols,
      state: resp.state,
      model_id: localAi.modelId,
      mcts_sims: localAi.thinkingSims,
    });
    localAi.analysis = data.analysis || null;
    const node = currentNode();
    if (node) node.meta.analysis = localAi.analysis;
  } catch (err) {
    localAi.analysis = { available: false, error: err.message };
  } finally {
    localAi.analyzing = false;
    localAi.thinkingSims = 0;
  }
  render();
}

function selectedThinkSims() {
  const parsed = parseInt(els.aiSimsSelect.value, 10);
  const sims = Number.isFinite(parsed) ? parsed : 200;
  const clamped = Math.max(0, Math.min(MAX_THINK_SIMS, sims));
  els.aiSimsSelect.value = String(clamped);
  return clamped;
}

function startOnlineGame(data) {
  online.roomCode = data.code || "";
  online.playerToken = data.player_token || online.playerToken;
  online.playerNum = data.player_num || online.playerNum;
  online.version = data.version ?? -1;
  online.liveResp = data.shared;
  online.moves = data.moves || [];
  online.moveCount = online.moves.length;
  online.chat = data.chat || [];
  online.players = data.players || {};
  online.pendingUndo = data.pending_undo || null;
  online.pendingNewGame = data.pending_new_game || null;
  online.error = null;
  localAi.lastKey = "";
  localAi.analysis = null;
  clearLocalAiSuggestion();
  els.rowsValue.textContent = String(data.rows || currentRows());
  els.colsValue.textContent = String(data.cols || currentCols());
  rebuildOnlineTree(data, true);
  syncNamesForMode();
  saveOnlineSession();
  if (online.roomCode) {
    const url = onlineRoomUrl();
    window.history.replaceState(null, "", url);
    navigator.clipboard?.writeText(url).catch(() => {});
  }
  render();
  scheduleOnlinePoll();
}

function onlineMoveMeta(move) {
  return {
    kind: "move",
    source: "online",
    onlineIndex: move.index,
    player: move.player,
    action: move.action,
    label: move.label,
    target: targetFromLabel(move.label),
    analysis: null,
  };
}

function selectedOnlineIndex() {
  const node = currentNode();
  if (!node || node.id === rootId) return -1;
  if (node.meta?.source === "online" && Number.isInteger(node.meta.onlineIndex)) {
    return node.meta.onlineIndex;
  }
  return null;
}

function findOnlineNodeByIndex(index) {
  return Object.values(tree).find(node => node.meta?.source === "online" && node.meta.onlineIndex === index)?.id || null;
}

function sameOnlineMove(left, right) {
  return !!left && !!right &&
    left.index === right.index &&
    left.player === right.player &&
    left.action === right.action &&
    left.label === right.label;
}

function onlineLineIsExtension(previousMoves, nextMoves) {
  if (nextMoves.length < previousMoves.length) return false;
  return previousMoves.every((move, index) => sameOnlineMove(move, nextMoves[index]));
}

function onlineLineIsSingleUndo(previousMoves, nextMoves) {
  if (nextMoves.length !== previousMoves.length - 1) return false;
  return nextMoves.every((move, index) => sameOnlineMove(move, previousMoves[index]));
}

function appendOnlineMoves(data, startIndex) {
  const moves = data.moves || [];
  for (let index = startIndex; index < moves.length; index += 1) {
    const move = moves[index];
    addOnlineLiveNode(move.shared || data.shared, move);
  }
  if (online.liveNodeId && tree[online.liveNodeId]) {
    tree[online.liveNodeId].resp = data.shared;
  }
}

function rebuildOnlineTree(data, jumpToLive = false) {
  const wasAtLive = isAtOnlineLive();
  const selectedIndex = selectedOnlineIndex();
  const rootResp = data.root_shared || data.shared;
  resetTree(rootResp);
  online.liveNodeId = rootId;

  (data.moves || []).forEach(move => {
    const moveResp = move.shared || data.shared;
    online.liveNodeId = upsertMoveNode(online.liveNodeId, moveResp, onlineMoveMeta(move));
  });

  if (jumpToLive || wasAtLive || !currentId || !tree[currentId]) {
    currentId = online.liveNodeId;
    resp = tree[currentId].resp;
  } else if (selectedIndex === -1) {
    currentId = rootId;
    resp = tree[rootId].resp;
  } else if (selectedIndex !== null) {
    const restoredId = findOnlineNodeByIndex(selectedIndex);
    currentId = restoredId || online.liveNodeId;
    resp = tree[currentId].resp;
  } else if (tree[currentId]) {
    resp = tree[currentId].resp;
  }
}

function addOnlineLiveNode(sharedResp, move) {
  if (!online.liveNodeId || !tree[online.liveNodeId]) {
    resetTree(sharedResp);
    online.liveNodeId = rootId;
    return;
  }
  const wasAtLive = currentId === online.liveNodeId;
  const id = upsertMoveNode(online.liveNodeId, sharedResp, onlineMoveMeta(move));
  online.liveNodeId = id;
  if (wasAtLive) {
    currentId = id;
    resp = sharedResp;
  }
}

function rollBackOnlineLiveNode(sharedResp) {
  if (!online.liveNodeId || !tree[online.liveNodeId]) {
    resetTree(sharedResp);
    online.liveNodeId = rootId;
    return;
  }
  const wasAtLive = currentId === online.liveNodeId;
  const oldLiveId = online.liveNodeId;
  const parentId = tree[oldLiveId].parentId || rootId;
  online.liveNodeId = parentId;
  if (tree[parentId]) tree[parentId].resp = sharedResp;
  if (wasAtLive) {
    currentId = parentId;
    resp = sharedResp;
    restorePreferredPathToNode(parentId);
  }
}

function applyOnlinePayload(data) {
  const previousVersion = online.version;
  const wasAtLive = isAtOnlineLive();
  const previousMoves = online.moves || [];
  const previousPendingNewGame = online.pendingNewGame;
  const nextMoves = data.moves || [];
  online.roomCode = data.code || online.roomCode;
  online.playerNum = data.player_num || online.playerNum;
  online.version = data.version ?? online.version;
  online.liveResp = data.shared;
  online.moves = nextMoves;
  online.chat = data.chat || [];
  online.players = data.players || {};
  online.pendingUndo = data.pending_undo || null;
  online.pendingNewGame = data.pending_new_game || null;
  if (data.player_token) online.playerToken = data.player_token;

  if (!resp || !rootId) {
    startOnlineGame({ ...data, player_token: online.playerToken });
    return;
  }

  const nextMoveCount = online.moves.length;
  if (previousVersion !== online.version) {
    if (onlineLineIsExtension(previousMoves, nextMoves)) {
      appendOnlineMoves(data, previousMoves.length);
      if (wasAtLive && currentId === online.liveNodeId) resp = data.shared;
    } else if (!previousPendingNewGame && onlineLineIsSingleUndo(previousMoves, nextMoves)) {
      rollBackOnlineLiveNode(data.shared);
    } else {
      rebuildOnlineTree(data, wasAtLive);
    }
  } else if (wasAtLive) {
    resp = data.shared;
    if (online.liveNodeId && tree[online.liveNodeId]) tree[online.liveNodeId].resp = data.shared;
  }
  if (wasAtLive && currentId === online.liveNodeId) resp = data.shared;
  online.moveCount = nextMoveCount;
  syncNamesForMode();
  saveOnlineSession();
}

async function createOnlineRoom() {
  resetOnlineState();
  appError = null;
  online.error = null;
  const playerNum = Number(els.onlinePlayerSelect.value) === 2 ? 2 : 1;
  const data = await post("/online/create", {
    rows: currentRows(),
    cols: currentCols(),
    player_num: playerNum,
    name: playerName(playerNum),
  });
  startOnlineGame(data);
}

async function joinOnlineRoom() {
  appError = null;
  online.error = null;
  const code = els.onlineCodeInput.value.trim().toUpperCase();
  const stored = savedOnlineSession(code);
  const data = await post("/online/join", {
    code,
    name: playerName(2),
    player_token: stored.playerToken || "",
  });
  startOnlineGame(data);
}

async function leaveOnlineRoom() {
  if (!online.roomCode || !online.playerToken) return;
  const oldCode = online.roomCode;
  try {
    await post("/online/leave", {
      code: online.roomCode,
      player_token: online.playerToken,
    });
    sessionStorage.removeItem(`phutball-online-${oldCode}`);
    localStorage.removeItem(`phutball-online-${oldCode}`);
  } catch (err) {
    online.error = err.message;
    render();
    return;
  }
  resetOnlineState();
  window.history.replaceState(null, "", window.location.pathname);
  await newGame();
  render();
}

async function sendOnlineChat() {
  const text = els.onlineChatInput.value.trim();
  if (!text || !online.roomCode || !online.playerToken) return;
  try {
    const data = await post("/online/chat", {
      code: online.roomCode,
      player_token: online.playerToken,
      text,
    });
    els.onlineChatInput.value = "";
    applyOnlinePayload(data);
    online.error = null;
  } catch (err) {
    online.error = err.message;
  }
  render();
  scheduleOnlinePoll(true);
}

async function sendOnlineAction(action) {
  if (!canSubmitOnlineLiveMove()) return;
  try {
    const data = await post("/online/move", {
      code: online.roomCode,
      player_token: online.playerToken,
      version: online.version,
      action,
    });
    applyOnlinePayload(data);
    selectingJump = false;
    appError = null;
    online.error = null;
  } catch (err) {
    online.error = err.message;
  }
  render();
  scheduleOnlinePoll(true);
}

async function requestOnlineUndo() {
  try {
    const data = await post("/online/undo/request", {
      code: online.roomCode,
      player_token: online.playerToken,
    });
    applyOnlinePayload(data);
    online.error = null;
  } catch (err) {
    online.error = err.message;
  }
  render();
  scheduleOnlinePoll(true);
}

async function respondOnlineUndo(accept) {
  try {
    const data = await post("/online/undo/respond", {
      code: online.roomCode,
      player_token: online.playerToken,
      accept,
    });
    applyOnlinePayload(data);
    online.error = null;
  } catch (err) {
    online.error = err.message;
  }
  render();
  scheduleOnlinePoll(true);
}

async function requestOnlineNewGame() {
  try {
    const data = await post("/online/new_game/request", {
      code: online.roomCode,
      player_token: online.playerToken,
    });
    applyOnlinePayload(data);
    online.error = null;
  } catch (err) {
    online.error = err.message;
  }
  render();
  scheduleOnlinePoll(true);
}

async function respondOnlineNewGame(accept) {
  try {
    const data = await post("/online/new_game/respond", {
      code: online.roomCode,
      player_token: online.playerToken,
      accept,
    });
    applyOnlinePayload(data);
    online.error = null;
  } catch (err) {
    online.error = err.message;
  }
  render();
  scheduleOnlinePoll(true);
}

async function updateOnlineName() {
  if (!isOnlineMode() || !online.roomCode || !online.playerToken || !online.playerNum) return;
  try {
    const data = await post("/online/name", {
      code: online.roomCode,
      player_token: online.playerToken,
      name: playerName(online.playerNum),
    });
    applyOnlinePayload(data);
    online.error = null;
  } catch (err) {
    online.error = err.message;
    render();
  }
}

function returnToLive() {
  const targetId = currentPositionId();
  if (!targetId || !tree[targetId]) return;
  restorePreferredPathToNode(targetId);
  goToNode(targetId);
}

function restorePreferredPathToNode(id) {
  let node = tree[id];
  while (node && node.parentId) {
    preferredChild.set(node.parentId, node.id);
    node = tree[node.parentId];
  }
}

async function newGame() {
  if (!isOnlineMode()) resetOnlineState();
  appError = null;
  localAi.lastKey = "";
  localAi.analysis = null;
  clearLocalAiSuggestion();
  selectingJump = false;
  setupMode = false;
  draggingBall = false;
  try {
    const data = await post("/new_game", {
      rows: currentRows(),
      cols: currentCols(),
    });
    resp = data;
    resetTree(data);
  } catch (err) {
    appError = err.message;
  }
  render();
}

async function sendAction(action, source = "human") {
  if (!resp || busy || (source === "human" && !userCanAct())) return;
  if (isOnlineMode() && canSubmitOnlineLiveMove()) {
    await sendOnlineAction(action);
    return;
  }
  if (isOnlineMode() && isAtOnlineLive()) return;
  const before = resp;
  try {
    const data = await post("/action", {
      rows: resp.rows,
      cols: resp.cols,
      state: resp.state,
      action,
    });
    attachVisualBallLanding(data, before, action);
    localAi.lastKey = "";
    localAi.analysis = null;
    clearLocalAiSuggestion();
    addTreeChild(data, {
      kind: "move",
      source: isOnlineMode() ? "private" : source,
      player: currentPlayerNumber(before),
      action,
      label: describeAction(action, before),
      target: actionTarget(action, before),
      analysis: null,
    });
    selectingJump = false;
    appError = null;
  } catch (err) {
    appError = err.message;
  }
  render();
}

async function setCustomBoard(board) {
  if (!resp || busy) return;
  try {
    const data = await post("/set_state", {
      rows: resp.rows,
      cols: resp.cols,
      state: {
        board,
        is_mid_jump: false,
        current_player: resp.state.current_player,
      },
    });
    resp = data;
    if (tree[currentId]) tree[currentId].resp = setupMode ? tree[currentId].resp : data;
    setupDirty = setupMode || setupDirty;
    selectingJump = false;
    appError = null;
  } catch (err) {
    appError = err.message;
  }
  render();
}

function enterSetupMode() {
  setupMode = true;
  setupBaseResp = resp;
  setupDirty = false;
  suppressSetupClick = false;
  selectingJump = false;
  draggingBall = false;
  render();
}

function finishSetupMode() {
  setupMode = false;
  selectingJump = false;
  draggingBall = false;
  suppressSetupClick = false;
  if (setupDirty && resp) {
    const before = setupBaseResp || resp;
    addTreeChild(resp, {
      kind: "setup",
      source: "human",
      player: currentPlayerNumber(before),
      action: null,
      label: "Setup position",
      analysis: null,
    });
  }
  setupBaseResp = null;
  setupDirty = false;
  render();
}

function describeAction(action, sourceResp) {
  const target = actionTarget(action, sourceResp);
  const placement = sourceResp.legal_placements.find(item => item.action === action);
  if (placement) return `Placed (${placement.row},${placement.col})`;
  const jump = sourceResp.legal_jumps.find(item => item.action === action);
  if (jump) return target ? `Jumped (${target.row},${target.col})` : "Winning jump";
  if (action === sourceResp.end_turn_action) return "Ended jump";
  return `#${action}`;
}

function actionTarget(action, sourceResp) {
  const placement = sourceResp.legal_placements.find(item => item.action === action);
  if (placement) return { row: placement.row, col: placement.col };
  const jump = sourceResp.legal_jumps.find(item => item.action === action);
  if (jump && !jump.off_board) return { row: jump.to_row, col: jump.to_col };
  return null;
}

function actionTargetFromAnalysisMove(move, sourceResp) {
  if (!move || !sourceResp) return null;
  return actionTarget(Number(move.action), sourceResp) || targetFromLabel(move.label);
}

function suggestedLocalAiTarget() {
  const move = (localAi.analysis?.moves || []).find(item => Number(item.action) === localAi.suggestedAction);
  return actionTargetFromAnalysisMove(move, resp);
}

function targetFromLabel(label) {
  const match = String(label || "").match(/\((\d+),(\d+)\)/);
  return match ? { row: Number(match[1]), col: Number(match[2]) } : null;
}

function attachVisualBallLanding(nextResp, beforeResp, action) {
  const jump = beforeResp.legal_jumps.find(item => item.action === action);
  if (!jump || !jump.off_board) return;
  nextResp.visual_ball = {
    row: jump.to_row < 0 ? -1 : beforeResp.rows,
    col: jump.to_col,
  };
}

function goToNode(id) {
  if (!tree[id]) return;
  localAi.lastKey = "";
  localAi.analysis = tree[id].meta?.analysis || null;
  clearLocalAiSuggestion();
  setupMode = false;
  setupBaseResp = null;
  setupDirty = false;
  suppressSetupClick = false;
  currentId = id;
  resp = tree[id].resp;
  selectingJump = false;
  draggingBall = false;
  appError = null;
  render();
}

function goBack() {
  const node = currentNode();
  if (node && node.parentId) goToNode(node.parentId);
}

function undoLocalMove() {
  if (isOnlineMode()) return;
  const target = tree[localCurrentId];
  if (!target || !target.parentId) return;
  localCurrentId = target.parentId;
  restorePreferredPathToNode(localCurrentId);
  goToNode(localCurrentId);
}

function goForward() {
  const node = currentNode();
  if (!node || node.childIds.length === 0) return;
  goToNode(preferredChild.get(node.id) || node.childIds[0]);
}

function lineToLeaf() {
  const line = [];
  let id = rootId;
  const seen = new Set();
  while (id && tree[id] && !seen.has(id)) {
    seen.add(id);
    line.push(id);
    if (isOnlineMode() && currentId === online.liveNodeId && id === online.liveNodeId) break;
    const node = tree[id];
    if (node.childIds.length === 0) break;
    id = preferredChild.get(id) || node.childIds[0];
  }
  return line;
}

function pathToNode(id) {
  const path = [];
  let node = tree[id];
  while (node) {
    path.push(node.id);
    node = tree[node.parentId];
  }
  return path.reverse();
}

function mainLineLeafId() {
  const line = lineToLeaf();
  return line.length > 0 ? line[line.length - 1] : rootId;
}

function currentPositionId() {
  if (isOnlineMode()) {
    if (online.liveNodeId && tree[online.liveNodeId]) return online.liveNodeId;
    return currentId;
  }
  if (localCurrentId && tree[localCurrentId]) return localCurrentId;
  return mainLineLeafId();
}

function isAtCurrentPosition() {
  const targetId = currentPositionId();
  return !!targetId && currentId === targetId;
}

function plyForNode(id) {
  let ply = 0;
  let node = tree[id];
  while (node && node.parentId) {
    ply += 1;
    node = tree[node.parentId];
  }
  return ply;
}

function boardPoint(row, col) {
  const styles = getComputedStyle(els.board);
  const gap = parseFloat(styles.getPropertyValue("--gap"));
  const pad = parseFloat(styles.getPropertyValue("--pad"));
  return { x: pad + col * gap, y: pad + row * gap };
}

function renderBoard() {
  if (!resp) return;
  els.board.style.setProperty("--rows", resp.rows);
  els.board.style.setProperty("--cols", resp.cols);
  els.board.innerHTML = "";

  renderCoordinateLabels();

  for (let r = 0; r < resp.rows; r++) {
    const line = document.createElement("div");
    line.className = `grid-line h${r === 0 || r === resp.rows - 1 ? " goal-grid-line" : ""}`;
    line.style.top = `${boardPoint(r, 0).y}px`;
    els.board.appendChild(line);
  }
  for (let c = 0; c < resp.cols; c++) {
    const line = document.createElement("div");
    line.className = "grid-line v";
    line.style.left = `${boardPoint(0, c).x}px`;
    els.board.appendChild(line);
  }

  const placementMap = new Map(resp.legal_placements.map(item => [`${viewRow(item.row)},${item.col}`, item.action]));
  const jumpMap = new Map(resp.legal_jumps.filter(item => !item.off_board).map(item => [`${viewRow(item.to_row)},${item.to_col}`, item.action]));
  const offJumps = resp.legal_jumps.filter(item => item.off_board);
  const showJumps = selectingJump || resp.is_mid_jump;
  const visualBall = resp.visual_ball || null;

  for (let r = 0; r < resp.rows; r++) {
    for (let c = 0; c < resp.cols; c++) {
      const point = document.createElement("button");
      const val = resp.display_board[unviewRow(r)][c];
      const key = `${r},${c}`;
      const pos = boardPoint(r, c);
      point.type = "button";
      point.className = "point";
      point.style.left = `${pos.x}px`;
      point.style.top = `${pos.y}px`;
      point.setAttribute("aria-label", `Intersection ${r},${c}`);
      if (setupMode) point.classList.add("setup-target");
      if (!setupMode && userCanAct() && !resp.is_mid_jump && !selectingJump && val === EMPTY && placementMap.has(key)) {
        point.classList.add("valid-place");
      }
      if (!setupMode && userCanAct() && showJumps && jumpMap.has(key)) {
        point.classList.add("valid-jump");
      }
      if (val === MAN) {
        const man = document.createElement("span");
        man.className = "piece man";
        point.appendChild(man);
      } else if (val === BALL && !visualBall) {
        const ball = document.createElement("span");
        ball.className = `piece ball${resp.legal_jumps.length ? " clickable" : ""}${draggingBall ? " dragging" : ""}`;
        point.appendChild(ball);
      }
      point.addEventListener("pointerdown", event => handlePointerDown(event, r, c, val));
      point.addEventListener("pointerup", event => handlePointerUp(event, r, c));
      point.addEventListener("click", () => handlePointClick(r, c, val, placementMap, jumpMap));
      els.board.appendChild(point);
    }
  }

  if (userCanAct() && showJumps) {
    offJumps.forEach(jump => {
      const row = viewOffRow(jump.to_row < 0 ? -1 : resp.rows);
      const pos = boardPoint(row, jump.to_col);
      const off = document.createElement("button");
      off.type = "button";
      off.className = "off-point valid-jump";
      off.style.left = `${pos.x}px`;
      off.style.top = `${pos.y}px`;
      off.addEventListener("click", () => sendAction(jump.action));
      els.board.appendChild(off);
    });
  }

  if (visualBall) {
    renderVisualBallLanding(visualBall);
  }
  renderHoverTarget();
}

function renderVisualBallLanding(visualBall) {
  const pos = boardPoint(viewOffRow(visualBall.row), visualBall.col);
  const ball = document.createElement("span");
  ball.className = "piece ball visual-landing";
  ball.style.left = `${pos.x}px`;
  ball.style.top = `${pos.y}px`;
  els.board.appendChild(ball);
}

function renderHoverTarget() {
  if (!hoverTarget || hoverTarget.row < 0 || hoverTarget.col < 0 || hoverTarget.row >= resp.rows || hoverTarget.col >= resp.cols) return;
  const pos = boardPoint(viewRow(hoverTarget.row), hoverTarget.col);
  const marker = document.createElement("span");
  marker.className = "move-hover-target";
  marker.style.left = `${pos.x}px`;
  marker.style.top = `${pos.y}px`;
  els.board.appendChild(marker);
}

function renderCoordinateLabels() {
  for (let c = 0; c < resp.cols; c++) {
    const pos = boardPoint(0, c);
    const label = document.createElement("div");
    label.className = "coord-label col-label";
    label.style.left = `${pos.x}px`;
    label.textContent = String(c);
    els.board.appendChild(label);
  }

  for (let r = 0; r < resp.rows; r++) {
    const pos = boardPoint(r, 0);
    const label = document.createElement("div");
    label.className = "coord-label row-label";
    label.style.top = `${pos.y}px`;
    label.textContent = String(unviewRow(r));
    els.board.appendChild(label);
  }
}

function canonicalRowFromDisplay(row) {
  const displayRow = unviewRow(row);
  return resp.current_player === -1 ? resp.rows - 1 - displayRow : displayRow;
}

function boardCopy() {
  return resp.state.board.map(row => row.slice());
}

function toggleMan(row, col, value) {
  const board = boardCopy();
  const cr = canonicalRowFromDisplay(row);
  if (value === BALL) return;
  board[cr][col] = value === MAN ? EMPTY : MAN;
  setCustomBoard(board);
}

function moveBall(row, col) {
  const board = boardCopy();
  const cr = canonicalRowFromDisplay(row);
  for (let r = 0; r < board.length; r++) {
    for (let c = 0; c < board[r].length; c++) {
      if (board[r][c] === BALL) board[r][c] = EMPTY;
    }
  }
  board[cr][col] = BALL;
  setCustomBoard(board);
}

function handlePointerDown(event, row, col, value) {
  if (!setupMode || value !== BALL) return;
  event.preventDefault();
  draggingBall = true;
  renderStatus();
}

function handlePointerUp(event, row, col) {
  if (!setupMode || !draggingBall) return;
  event.preventDefault();
  draggingBall = false;
  suppressSetupClick = true;
  moveBall(row, col);
}

function handlePointClick(row, col, value, placementMap, jumpMap) {
  if (!resp || busy) return;
  if (setupMode) {
    if (suppressSetupClick) {
      suppressSetupClick = false;
      return;
    }
    if (!draggingBall) toggleMan(row, col, value);
    return;
  }
  if (!userCanAct()) return;
  if (value === BALL) {
    if (resp.is_mid_jump && resp.can_end_turn) {
      sendAction(resp.end_turn_action);
      return;
    }
    if (!resp.is_mid_jump && resp.legal_jumps.length > 0) {
      selectingJump = !selectingJump;
      render();
    }
    return;
  }
  const key = `${row},${col}`;
  if ((selectingJump || resp.is_mid_jump) && jumpMap.has(key)) {
    sendAction(jumpMap.get(key));
  } else if (!resp.is_mid_jump && !selectingJump && placementMap.has(key)) {
    sendAction(placementMap.get(key));
  }
}

function renderStatus() {
  const blockedOnlineStatus = onlineBlockedMoveStatus();
  els.status.classList.toggle("error", !!appError);
  if (appError) {
    els.status.textContent = appError;
  } else if (online.error && isOnlineMode()) {
    els.status.textContent = online.error;
  } else if (!resp) {
    els.status.textContent = "Loading";
  } else if (isOnlineMode() && !online.roomCode) {
    els.status.textContent = "Create or join an online room";
  } else if (!isAtCurrentPosition()) {
    els.status.textContent = isOnlineMode()
      ? "Private exploration. Current Position returns to the shared game."
      : "Exploring a branch. Current Position returns to the main line.";
  } else if (isOnlineMode() && online.pendingUndo) {
    const requester = online.pendingUndo.requester;
    els.status.textContent = requester === online.playerNum ? "Undo requested. Waiting for opponent." : "Opponent requested undo.";
  } else if (blockedOnlineStatus) {
    els.status.textContent = blockedOnlineStatus;
  } else if (setupMode) {
    els.status.textContent = draggingBall ? "Drop the ball on an intersection" : "Modify position: add men or drag the ball";
  } else if (resp.is_terminal) {
    els.status.textContent = `${playerName(resp.winner)} wins`;
  } else if (resp.is_mid_jump) {
    els.status.textContent = `${playerName(currentPlayerNumber())} is jumping`;
  } else if (selectingJump) {
    els.status.textContent = "Choose a jump destination";
  } else {
    els.status.textContent = `${playerName(currentPlayerNumber())}'s turn`;
  }
}

function renderTree() {
  els.moveTree.innerHTML = "";
  if (!rootId) return;
  const line = (isAtCurrentPosition() ? lineToLeaf() : pathToNode(currentId)).slice(1);

  if (line.length === 0) {
    const empty = document.createElement("div");
    empty.className = "empty-tree";
    empty.textContent = "No moves yet.";
    els.moveTree.appendChild(empty);
  } else {
    line.forEach((id, index) => {
      const row = document.createElement("div");
      row.className = "move-row";
      row.appendChild(moveButton(id, false, index + 1));
      const alternatives = siblingAlternatives(id);
      if (alternatives.length > 0) {
        const altWrap = document.createElement("div");
        altWrap.className = "alternatives";
        const label = document.createElement("span");
        label.textContent = "Alternatives:";
        altWrap.appendChild(label);
        alternatives.forEach(altId => altWrap.appendChild(moveButton(altId, true, plyForNode(altId))));
        row.appendChild(altWrap);
      }
      els.moveTree.appendChild(row);
    });
  }

  if (currentId === rootId && tree[rootId].childIds.length > 0) {
    const altWrap = document.createElement("div");
    altWrap.className = "root-alternatives alternatives";
    const label = document.createElement("span");
    label.textContent = "Played from start:";
    altWrap.appendChild(label);
    tree[rootId].childIds.forEach(id => altWrap.appendChild(moveButton(id, true, plyForNode(id))));
    els.moveTree.appendChild(altWrap);
  }
}

function siblingAlternatives(id) {
  const node = tree[id];
  const parent = node && tree[node.parentId];
  if (!parent) return [];
  return parent.childIds.filter(childId => childId !== id);
}

function moveButton(id, alternative, ply) {
  const node = tree[id];
  const button = document.createElement("button");
  button.type = "button";
  button.className = `move-node${id === currentId ? " current" : ""}${alternative ? " alternative" : ""}${node.meta.kind === "setup" ? " setup" : ""}`;
  const player = node.meta.player ? `P${node.meta.player}` : "";
  button.textContent = `${ply}. ${player} ${node.meta.label}`.trim();
  button.addEventListener("click", () => goToNode(id));
  if (!alternative && node.meta.kind === "move") {
    const target = node.meta.target || targetFromLabel(node.meta.label);
    button.addEventListener("mouseenter", () => {
      hoverTarget = target;
      renderBoard();
    });
    button.addEventListener("mouseleave", () => {
      hoverTarget = suggestedLocalAiTarget();
      renderBoard();
    });
  }
  return button;
}

function escapeHTML(value) {
  return String(value)
    .replaceAll("&", "&amp;")
    .replaceAll("<", "&lt;")
    .replaceAll(">", "&gt;")
    .replaceAll('"', "&quot;")
    .replaceAll("'", "&#39;");
}

function renderLocalAiPanel() {
  if (!els.aiAssistCard) return;
  syncLocalAiModelSelect();
  const compatible = compatibleLocalAiModels();
  const publicHttpsPage = isPublicHttpsPage();
  els.aiAssistStatus.classList.toggle("error", !!localAi.error && !localAi.connected && !publicHttpsPage);
  els.aiRefreshButton.style.display = publicHttpsPage ? "none" : "";
  els.aiDownloadBox.classList.toggle("show", !localAi.connected);
  els.aiModelRow.style.display = localAi.connected ? "" : "none";
  els.aiThinkRow.style.display = localAi.connected ? "" : "none";

  if (localAi.checking) {
    els.aiAssistStatus.textContent = "Checking local AI.";
  } else if (!localAi.connected) {
    els.aiAssistStatus.textContent = publicHttpsPage
      ? "For AI Assist, download and run the local companion app."
      : (localAi.error || "Local AI is not running on port 8080.");
  } else if (compatible.length === 0) {
    els.aiAssistStatus.textContent = resp ? `Connected. No ${resp.rows}x${resp.cols} model found.` : "Connected. No compatible model found.";
  } else if (localAi.analyzing) {
    els.aiAssistStatus.textContent = localAi.thinkingSims
      ? `Thinking for ${localAi.thinkingSims} sims.`
      : "Analyzing current position.";
  } else {
    els.aiAssistStatus.textContent = `Connected to local Phutball Simplified${localAi.baseUrl ? ` at ${localAi.baseUrl}` : ""}.`;
  }

  renderLocalAiAnalysis();
  els.aiRefreshButton.disabled = localAi.checking;
  els.aiThinkButton.disabled = !localAi.connected || localAi.analyzing || !localAi.modelId;
  els.aiSimsSelect.disabled = !localAi.connected || localAi.analyzing || !localAi.modelId;
}

function renderLocalAiAnalysis() {
  els.aiAnalysisCurrent.innerHTML = "";
  if (!localAi.connected) {
    const line = document.createElement("div");
    line.className = "ai-analysis-muted";
    line.textContent = isPublicHttpsPage()
      ? "The companion app opens a localhost page that can use private local policy, value, and search analysis."
      : "Run the local app for private policy and value heads.";
    els.aiAnalysisCurrent.appendChild(line);
    return;
  }
  if (!localAi.modelId) {
    const line = document.createElement("div");
    line.className = "ai-analysis-muted";
    line.textContent = resp ? `No ${resp.rows}x${resp.cols} model available.` : "No compatible model available.";
    els.aiAnalysisCurrent.appendChild(line);
    return;
  }
  const analysis = localAi.analysis;
  if (!analysis || !analysis.available) {
    const line = document.createElement("div");
    line.className = "ai-analysis-muted";
    line.textContent = analysis?.error || "No analysis yet.";
    els.aiAnalysisCurrent.appendChild(line);
    return;
  }

  const value = document.createElement("div");
  value.className = "ai-analysis-line";
  value.innerHTML = `<span>Value</span><strong>${valueFromPlayerOnePerspective(analysis.value, resp).toFixed(3)}</strong>`;
  els.aiAnalysisCurrent.appendChild(value);

  (analysis.moves || []).slice(0, 5).forEach((move, index) => {
    const line = document.createElement("button");
    line.type = "button";
    line.className = "ai-analysis-line ai-move-line";
    const policy = `${(Number(move.policy) * 100).toFixed(1)}%`;
    line.innerHTML = `<span>${index + 1}. ${escapeHTML(move.label)}</span><strong>${policy}</strong>`;
    line.addEventListener("mouseenter", () => {
      hoverTarget = actionTargetFromAnalysisMove(move, resp);
      renderBoard();
    });
    line.addEventListener("mouseleave", () => {
      hoverTarget = suggestedLocalAiTarget();
      renderBoard();
    });
    line.addEventListener("click", () => {
      localAi.suggestedAction = Number(move.action);
      hoverTarget = actionTargetFromAnalysisMove(move, resp);
      renderBoard();
    });
    els.aiAnalysisCurrent.appendChild(line);
  });
}

function valueFromPlayerOnePerspective(value, sourceResp) {
  const numeric = Number(value);
  if (!Number.isFinite(numeric)) return numeric;
  return currentPlayerNumber(sourceResp) === 1 ? numeric : -numeric;
}

function renderOnlinePanel() {
  const onlineMode = isOnlineMode();
  els.onlinePanel.classList.toggle("show", onlineMode);
  if (!onlineMode) return;

  const side = online.playerNum ? `You are Player ${online.playerNum}` : "Not seated";
  const code = online.roomCode ? `Room ${online.roomCode}` : "No room";
  const opponentNum = online.playerNum === 1 ? 2 : 1;
  const opponent = online.players?.[String(opponentNum)];
  const genericOpponentName = opponent?.name === `Player ${opponentNum}`;
  const opponentText = opponent
    ? (genericOpponentName ? `Opponent ${opponent.connected ? "connected" : "away"}` : `Opponent: ${opponent.name}${opponent.connected ? "" : " (away)"}`)
    : "Waiting for opponent";
  const undoText = online.pendingUndo
    ? ` Undo requested for ${online.pendingUndo.label}.`
    : "";
  const newGameText = online.pendingNewGame
    ? " New game requested."
    : "";
  els.onlineStatus.textContent = `${code}. ${side}. ${opponentText}.${undoText}${newGameText}`;
  els.onlineCodeInput.value = online.roomCode || els.onlineCodeInput.value.toUpperCase();

  const seated = !!online.roomCode && !!online.playerToken;
  const pending = online.pendingUndo;
  const pendingNewGame = online.pendingNewGame;
  const canRequestUndo = seated &&
    online.moves.length > 0 &&
    !pending &&
    !pendingNewGame &&
    online.moves[online.moves.length - 1]?.player === online.playerNum;
  const canRespondUndo = seated && pending && pending.requester !== online.playerNum;
  const canRequestNewGame = seated && !pendingNewGame;
  const canRespondNewGame = seated && pendingNewGame && pendingNewGame.requester !== online.playerNum;
  els.onlineUndoRequestButton.disabled = !canRequestUndo;
  els.onlineUndoAcceptButton.disabled = !canRespondUndo;
  els.onlineUndoRejectButton.disabled = !canRespondUndo;
  els.onlineNewGameRequestButton.disabled = !canRequestNewGame;
  els.onlineNewGameAcceptButton.disabled = !canRespondNewGame;
  els.onlineNewGameRejectButton.disabled = !canRespondNewGame;
  els.onlineCreateButton.disabled = seated;
  els.onlinePlayerSelect.disabled = seated;
  els.onlineJoinButton.style.display = seated ? "none" : "";
  els.onlineLeaveButton.style.display = seated ? "" : "none";
  els.onlineJoinButton.disabled = seated;
  els.onlineLeaveButton.disabled = !seated;
  els.onlineUndoAcceptButton.style.display = pending ? "" : "none";
  els.onlineUndoRejectButton.style.display = pending ? "" : "none";
  els.onlineNewGameAcceptButton.style.display = pendingNewGame ? "" : "none";
  els.onlineNewGameRejectButton.style.display = pendingNewGame ? "" : "none";
}

function renderOnlineChat() {
  const show = isOnlineMode() && !!online.roomCode && !!online.playerToken;
  els.onlineChatCard.classList.toggle("show", show);
  if (!show) return;

  const hadRenderedMessages = els.onlineChatMessages.childElementCount > 0;
  const wasNearBottom =
    els.onlineChatMessages.scrollHeight - els.onlineChatMessages.scrollTop - els.onlineChatMessages.clientHeight < 24;
  els.onlineChatMessages.innerHTML = "";
  const messages = online.chat || [];
  if (messages.length === 0) {
    const empty = document.createElement("div");
    empty.className = "online-chat-empty";
    empty.textContent = "No messages yet.";
    els.onlineChatMessages.appendChild(empty);
  } else {
    messages.forEach(message => {
      const row = document.createElement("div");
      row.className = "chat-message";
      row.innerHTML = `<strong>${escapeHTML(message.name || `Player ${message.player}`)}</strong><span>${escapeHTML(message.text)}</span>`;
      els.onlineChatMessages.appendChild(row);
    });
  }
  els.onlineChatSendButton.disabled = els.onlineChatInput.value.trim() === "";
  if (!hadRenderedMessages || wasNearBottom) els.onlineChatMessages.scrollTop = els.onlineChatMessages.scrollHeight;
}

function renderTurnIndicator() {
  if (!els.turnIndicator) return;
  if (!resp) {
    els.turnIndicator.textContent = "Loading";
  } else if (resp.is_terminal) {
    els.turnIndicator.textContent = `${playerName(resp.winner)} wins`;
  } else {
    const player = currentPlayerNumber();
    els.turnIndicator.textContent = `Player ${player}${resp.is_mid_jump ? " jumping" : " to move"}`;
  }
}

function renderControls() {
  const node = currentNode();
  const onlineMode = isOnlineMode();
  const undoTarget = tree[currentPositionId()];
  const exploring = !isAtCurrentPosition();
  document.body.classList.toggle("exploring-position", exploring);
  els.infoPanelTitle.textContent = "Game Info";
  els.moveNumber.textContent = String(Math.max(0, plyForNode(currentId)));
  els.setupButton.classList.toggle("active", setupMode);
  els.setupButton.textContent = setupMode ? "Done" : "Modify Position";
  els.undoButton.disabled = onlineMode || !undoTarget || !undoTarget.parentId || busy;
  els.undoButton.style.display = onlineMode ? "none" : "";
  els.backButton.disabled = !node || !node.parentId || busy;
  els.forwardButton.disabled = !node || node.childIds.length === 0 || busy;
  els.flipBoardButton.classList.toggle("active", boardPerspectiveFlipped);
  els.flipBoardButton.setAttribute("aria-pressed", boardPerspectiveFlipped ? "true" : "false");
  els.newGameButton.disabled = busy || onlineMode;
  els.setupButton.disabled = busy || onlineMode;
  els.playMode.closest("label").style.display = "";
  els.setupButton.style.display = onlineMode ? "none" : "";
  const showReturnLive = !!resp && exploring;
  els.returnLiveCard.classList.toggle("show", showReturnLive);
  els.returnLiveButton.disabled = !showReturnLive;
  els.loadTreeButton.style.display = onlineMode ? "none" : "";
  els.loadTreeButton.disabled = onlineMode;
  els.endTurnButton.classList.toggle("show", !!(userCanAct() && resp?.is_mid_jump && resp.can_end_turn));
  renderOnlinePanel();
  renderOnlineChat();
  renderLocalAiPanel();
}

function render() {
  updateGoalLabels();
  renderStatus();
  renderTurnIndicator();
  renderBoard();
  renderTree();
  renderControls();
  refreshLocalAiAnalysis();
}

function bindSize(downEl, upEl, valueEl) {
  downEl.addEventListener("click", () => {
    const next = currentFromEl(valueEl) - 2;
    if (next >= MIN_SIZE) valueEl.textContent = String(next);
  });
  upEl.addEventListener("click", () => {
    const next = currentFromEl(valueEl) + 2;
    if (next <= MAX_SIZE) valueEl.textContent = String(next);
  });
}

function currentFromEl(el) {
  return parseInt(el.textContent, 10);
}

function togglePanel(body, button) {
  const hidden = body.hidden;
  body.hidden = !hidden;
  button.textContent = hidden ? "Hide" : "Show";
}

function bindEvents() {
  bindSize(els.rowsDown, els.rowsUp, els.rowsValue);
  bindSize(els.colsDown, els.colsUp, els.colsValue);
  els.playMode.addEventListener("change", () => {
    setupMode = false;
    if (!isOnlineMode()) {
      resetOnlineState();
    }
    syncNamesForMode();
    render();
  });
  els.setupButton.addEventListener("click", () => {
    if (setupMode) {
      finishSetupMode();
    } else {
      enterSetupMode();
    }
  });
  els.undoButton.addEventListener("click", undoLocalMove);
  els.backButton.addEventListener("click", goBack);
  els.forwardButton.addEventListener("click", goForward);
  els.flipBoardButton.addEventListener("click", () => {
    boardPerspectiveFlipped = !boardPerspectiveFlipped;
    hoverTarget = null;
    render();
  });
  els.newGameButton.addEventListener("click", newGame);
  els.downloadTreeButton.addEventListener("click", downloadGameTree);
  els.loadTreeButton.addEventListener("click", () => {
    if (isOnlineMode()) {
      appError = "Loading saved game trees is only available in Local mode.";
      render();
      return;
    }
    els.loadTreeInput.click();
  });
  els.loadTreeInput.addEventListener("change", () => loadGameTreeFile(els.loadTreeInput.files?.[0]));
  els.returnLiveButton.addEventListener("click", returnToLive);
  els.onlineCreateButton.addEventListener("click", () => {
    createOnlineRoom().catch(err => {
      online.error = err.message;
      render();
    });
  });
  els.onlineJoinButton.addEventListener("click", () => {
    joinOnlineRoom().catch(err => {
      online.error = err.message;
      render();
    });
  });
  els.onlineLeaveButton.addEventListener("click", leaveOnlineRoom);
  els.onlineCodeInput.addEventListener("input", () => {
    els.onlineCodeInput.value = els.onlineCodeInput.value.toUpperCase();
  });
  els.onlineChatSendButton.addEventListener("click", sendOnlineChat);
  els.onlineChatInput.addEventListener("input", renderOnlineChat);
  els.onlineChatInput.addEventListener("keydown", event => {
    if (event.key === "Enter" && !event.shiftKey) {
      event.preventDefault();
      sendOnlineChat();
    }
  });
  els.onlineUndoRequestButton.addEventListener("click", requestOnlineUndo);
  els.onlineUndoAcceptButton.addEventListener("click", () => respondOnlineUndo(true));
  els.onlineUndoRejectButton.addEventListener("click", () => respondOnlineUndo(false));
  els.onlineNewGameRequestButton.addEventListener("click", requestOnlineNewGame);
  els.onlineNewGameAcceptButton.addEventListener("click", () => respondOnlineNewGame(true));
  els.onlineNewGameRejectButton.addEventListener("click", () => respondOnlineNewGame(false));
  els.aiRefreshButton.addEventListener("click", () => {
    checkLocalAi().then(() => refreshLocalAiAnalysis(true));
  });
  els.aiToggle.addEventListener("click", () => togglePanel(els.aiAssistBody, els.aiToggle));
  els.aiModelSelect.addEventListener("change", () => {
    localAi.modelId = els.aiModelSelect.value;
    localAi.lastKey = "";
    localAi.analysis = null;
    clearLocalAiSuggestion();
    render();
  });
  els.aiThinkButton.addEventListener("click", thinkLocalAiAnalysis);
  els.endTurnButton.addEventListener("click", () => resp && sendAction(resp.end_turn_action));
  els.player1Name.addEventListener("input", renderStatus);
  els.player2Name.addEventListener("input", renderStatus);
  els.player1Name.addEventListener("change", updateOnlineName);
  els.player2Name.addEventListener("change", updateOnlineName);
  els.treeToggle.addEventListener("click", () => togglePanel(els.treeBody, els.treeToggle));
}

async function boot() {
  initElements();
  bindEvents();
  applyInitialModeParam();
  syncNamesForMode();
  await newGame();
  checkLocalAi();
  applyInitialRoomParam();
}

boot();
