const EMPTY = 0;
const MAN = 1;
const BALL = 2;
const MIN_SIZE = 3;
const MAX_SIZE = 21;
const MAX_SIMS = 100000;

let resp = null;
let models = [];
let selectingJump = false;
let setupMode = false;
let setupBaseResp = null;
let setupDirty = false;
let suppressSetupClick = false;
let draggingBall = false;
let aiThinking = false;
let aiError = null;
let aiAutoPlaying = false;
let aiFrozen = false;
let aiTimer = null;
let aiRequestEpoch = 0;
let hoverTarget = null;
let boardPerspectiveFlipped = false;
let aiAiAnalyses = null;
let aiAiAnalysisKey = "";

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",
    "ai-sims-row", "ai-sims", "split-sims-button", "split-sims-fields",
    "ai-sims-p1", "ai-sims-p2", "player1-model-row", "player1-model-select",
    "player2-model-row", "player2-model-select",
    "setup-button", "undo-button", "back-button",
    "forward-button", "continue-button", "ai-play-button", "ai-pause-button",
    "flip-board-button", "new-game-button", "player1-name", "player2-name", "board",
    "end-turn-button", "move-tree", "branch-count", "analysis-current",
    "analysis-last", "analysis-current-title", "analysis-last-title",
    "analysis-source", "last-analysis-source", "tree-toggle",
    "analysis-toggle", "tree-body", "analysis-body",
    "return-current-button", "return-current-card",
    "download-tree-button", "load-tree-button", "load-tree-input",
    "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);
  const data = await response.json();
  if (!response.ok) throw new Error(data.error || `Server error ${response.status}`);
  return data;
}

function aiControlsForPlayer(playerNum = currentPlayerNumber()) {
  return playerNum === 1
    ? {
        modelSelect: els.player1ModelSelect,
      }
    : {
        modelSelect: els.player2ModelSelect,
      };
}

function selectedModelId(playerNum = currentPlayerNumber()) {
  return aiControlsForPlayer(playerNum).modelSelect.value || "";
}

function parseSimsInput(input) {
  const sims = parseInt(input.value, 10);
  return Number.isFinite(sims) ? Math.max(0, sims) : 0;
}

function splitSimsEnabled() {
  return isAiAiMode() && els.splitSimsButton.getAttribute("aria-pressed") === "true";
}

function selectedSims(playerNum = currentPlayerNumber()) {
  const input = splitSimsEnabled()
    ? (playerNum === 1 ? els.aiSimsP1 : els.aiSimsP2)
    : els.aiSims;
  return Math.min(MAX_SIMS, parseSimsInput(input));
}

function normalizeSimsInput(input) {
  input.value = String(Math.min(MAX_SIMS, parseSimsInput(input)));
}

function clampCustomSimsInput(input) {
  if (input.value.trim() === "") return;
  const parsed = parseInt(input.value, 10);
  if (!Number.isFinite(parsed)) {
    input.value = "0";
    return;
  }
  if (parsed > MAX_SIMS) input.value = String(MAX_SIMS);
  if (parsed < 0) input.value = "0";
}

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

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

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

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 currentAiAiAnalysisKey() {
  if (!resp || !isAiAiMode()) return "";
  return JSON.stringify({
    rows: resp.rows,
    cols: resp.cols,
    state: resp.state,
    p1: selectedModelId(1),
    p2: selectedModelId(2),
  });
}

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 === "AI" ||
    trimmed === "Human" ||
    trimmed === `Human ${playerNum}` ||
    trimmed === `Player ${playerNum}`;
}

function isAiAiMode() {
  return els.playMode.value === "ai-ai";
}

function wantsAnalysis() {
  return !!selectedModelId();
}

function isAiTurn() {
  if (!resp || resp.is_terminal) return false;
  const mode = els.playMode.value;
  const player = currentPlayerNumber();
  return (
    mode === "ai-ai" ||
    (mode === "human-ai" && player === 2) ||
    (mode === "ai-human" && player === 1)
  );
}

function userCanAct() {
  if (!resp || resp.is_terminal || aiThinking) return false;
  if (!isAtCurrentPosition()) return true;
  return !isAiTurn();
}

function modelOptionsForSize() {
  return models.filter(model => model.rows === currentRows() && model.cols === currentCols());
}

async function loadModels() {
  try {
    const data = await getJSON("/models");
    models = data.models || [];
  } catch (err) {
    models = [];
    aiError = `Could not load models: ${err.message}`;
  }
  updateModelSelect();
}

function updateModelSelect() {
  const selects = [els.player1ModelSelect, els.player2ModelSelect];
  const compatible = modelOptionsForSize();
  selects.forEach(select => {
    const previous = select.value;
    select.innerHTML = "";
    if (compatible.length === 0) {
      const option = document.createElement("option");
      option.value = "";
      option.textContent = `No ${currentRows()}x${currentCols()} model`;
      select.appendChild(option);
      select.disabled = true;
      return;
    }
    compatible.forEach(model => {
      const option = document.createElement("option");
      option.value = model.id;
      option.textContent = model.name;
      select.appendChild(option);
    });
    select.disabled = false;
    select.value = compatible.some(model => model.id === previous) ? previous : compatible[0].id;
  });
  syncNamesForMode();
}

function syncNamesForMode() {
  const mode = els.playMode.value;
  const playerAiName = playerNum => {
    const model = models.find(item => item.id === selectedModelId(playerNum));
    return model ? model.name : "AI";
  };
  if (mode === "human-ai") {
    if (!els.player1Name.value.trim() || els.player1Name.readOnly) els.player1Name.value = "Human";
    els.player2Name.value = playerAiName(2);
  } else if (mode === "ai-human") {
    els.player1Name.value = playerAiName(1);
    if (!els.player2Name.value.trim() || els.player2Name.readOnly) els.player2Name.value = "Human";
  } else if (mode === "ai-ai") {
    els.player1Name.value = `${playerAiName(1)} P1`;
    els.player2Name.value = `${playerAiName(2)} P2`;
  } else {
    if (els.player1Name.readOnly) els.player1Name.value = "Human 1";
    if (els.player2Name.readOnly) els.player2Name.value = "Human 2";
  }
  els.player1Name.readOnly = mode === "ai-human" || mode === "ai-ai";
  els.player2Name.readOnly = mode === "human-ai" || mode === "ai-ai";
}

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 (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];
    return (
      child.meta.kind === "move" &&
      child.meta.action === meta.action &&
      child.meta.player === meta.player
    );
  }) || null;
}

function treeExportPayload() {
  return {
    schema: "phutball-simplified-game-tree",
    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 || payload.schema !== "phutball-simplified-game-tree") {
    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;
  try {
    const payload = JSON.parse(await file.text());
    validateTreePayload(payload);
    clearAiTimer();
    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);
    aiError = null;
    render();
    maybeRunAi();
  } catch (err) {
    aiError = `Could not load game tree: ${err.message}`;
    render();
  } finally {
    els.loadTreeInput.value = "";
  }
}

function clearAiTimer() {
  if (aiTimer !== null) {
    clearTimeout(aiTimer);
    aiTimer = null;
  }
}

function cancelAiWork() {
  clearAiTimer();
  aiRequestEpoch += 1;
  aiThinking = false;
  aiAutoPlaying = false;
}

async function newGame() {
  cancelAiWork();
  aiError = null;
  aiFrozen = false;
  aiAutoPlaying = isAiAiMode();
  aiAiAnalyses = null;
  aiAiAnalysisKey = "";
  selectingJump = false;
  setupMode = false;
  draggingBall = false;
  updateModelSelect();
  try {
    const data = await post("/new_game", {
      rows: currentRows(),
      cols: currentCols(),
      model_id: selectedModelId(),
      include_ai: wantsAnalysis(),
    });
    resp = data;
    resetTree(data);
  } catch (err) {
    aiError = err.message;
  }
  render();
  maybeRunAi();
}

async function sendAction(action, source = "human") {
  if (!resp || aiThinking || (source === "human" && !userCanAct())) return;
  const before = resp;
  try {
    const data = await post("/action", {
      rows: resp.rows,
      cols: resp.cols,
      state: resp.state,
      action,
      model_id: selectedModelId(),
      include_ai: wantsAnalysis(),
    });
    attachVisualBallLanding(data, before, action);
    addTreeChild(data, {
      kind: "move",
      source,
      player: currentPlayerNumber(before),
      action,
      label: describeAction(action, before),
      target: actionTarget(action, before),
      analysis: before.ai_analysis,
    });
    selectingJump = false;
    aiFrozen = false;
    aiError = null;
  } catch (err) {
    aiError = err.message;
  }
  render();
  maybeRunAi();
}

async function sendAiMove(manual = false) {
  const playerNum = currentPlayerNumber();
  if (!resp || resp.is_terminal || aiThinking || setupMode || !selectedModelId(playerNum)) return;
  if (!manual && (!isAiTurn() || aiFrozen)) return;
  const requestEpoch = aiRequestEpoch + 1;
  aiRequestEpoch = requestEpoch;
  aiThinking = true;
  aiError = null;
  render();
  const before = resp;
  try {
    const data = await post("/ai_move", {
      rows: resp.rows,
      cols: resp.cols,
      state: resp.state,
      model_id: selectedModelId(playerNum),
      mcts_sims: selectedSims(playerNum),
    });
    if (requestEpoch !== aiRequestEpoch) return;
    if (Number.isInteger(data.ai_action)) {
      aiAiAnalyses = null;
      aiAiAnalysisKey = "";
      attachVisualBallLanding(data, before, data.ai_action);
      addTreeChild(data, {
        kind: "move",
        source: "ai",
        player: currentPlayerNumber(before),
        action: data.ai_action,
        label: describeAction(data.ai_action, before),
        target: actionTarget(data.ai_action, before),
        analysis: data.ai_search_analysis,
      });
      selectingJump = false;
      aiFrozen = false;
    } else {
      resp = data;
      if (tree[currentId]) tree[currentId].resp = data;
    }
  } catch (err) {
    if (requestEpoch !== aiRequestEpoch) return;
    aiError = err.message;
    aiAutoPlaying = false;
  } finally {
    if (requestEpoch === aiRequestEpoch) aiThinking = false;
  }
  if (requestEpoch === aiRequestEpoch) {
    render();
    maybeRunAi();
  }
}

async function continueGame() {
  if (!isAtCurrentPosition()) return;
  aiFrozen = false;
  if (isAiTurn()) {
    await sendAiMove(true);
  } else {
    render();
  }
}

function playAiAi() {
  aiFrozen = false;
  aiAutoPlaying = true;
  render();
  maybeRunAi();
}

function pauseAiAi() {
  cancelAiWork();
  render();
}

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

function enterSetupMode() {
  setupMode = true;
  setupBaseResp = resp;
  setupDirty = false;
  suppressSetupClick = false;
  selectingJump = false;
  draggingBall = false;
  pauseAiAi();
  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();
  maybeRunAi();
}

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 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;
  cancelAiWork();
  setupMode = false;
  setupBaseResp = null;
  setupDirty = false;
  suppressSetupClick = false;
  currentId = id;
  resp = tree[id].resp;
  selectingJump = false;
  draggingBall = false;
  aiError = null;
  aiFrozen = isAiTurn() || !isAtCurrentPosition();
  aiAiAnalyses = null;
  aiAiAnalysisKey = "";
  render();
}

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

function undoLocalMove() {
  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);
    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 (localCurrentId && tree[localCurrentId]) return localCurrentId;
  return mainLineLeafId();
}

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

function returnToCurrentPosition() {
  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];
  }
}

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 || aiThinking) 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() {
  els.status.classList.toggle("error", !!aiError);
  if (aiError) {
    els.status.textContent = aiError;
  } else if (!resp) {
    els.status.textContent = "Loading";
  } else if (setupMode) {
    els.status.textContent = draggingBall ? "Drop the ball on an intersection" : "Modify position: add men or drag the ball";
  } else if (aiThinking) {
    els.status.textContent = `${playerName(currentPlayerNumber())} is thinking`;
  } else if (!isAtCurrentPosition()) {
    els.status.textContent = "Exploring a branch. Current Position returns to the main line.";
  } else if (resp.is_terminal) {
    els.status.textContent = `${playerName(resp.winner)} wins`;
  } else if (aiFrozen && isAiTurn()) {
    els.status.textContent = `${playerName(currentPlayerNumber())} ready. Press Continue Game.`;
  } else if (isAiAiMode() && !aiAutoPlaying) {
    els.status.textContent = `${playerName(currentPlayerNumber())} ready. Press Play or Continue Game.`;
  } 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);
  const branchNodes = Object.values(tree).filter(node => node.childIds.length > 1).length;
  els.branchCount.textContent = `${branchNodes + 1} line${branchNodes === 0 ? "" : "s"}`;

  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 = null;
      renderBoard();
    });
  }
  return button;
}

function renderAnalysis() {
  if (isAiAiMode()) {
    refreshAiAiAnalyses();
    els.analysisCurrentTitle.textContent = "Current P1 analysis";
    els.analysisLastTitle.textContent = "Current P2 analysis";
    els.analysisSource.textContent = playerName(1);
    els.lastAnalysisSource.textContent = playerName(2);
    renderAnalysisBox(els.analysisCurrent, aiAiAnalyses?.p1, `Current ${playerName(1)}`, null, resp);
    renderAnalysisBox(els.analysisLast, aiAiAnalyses?.p2, `Current ${playerName(2)}`, null, resp);
    return;
  }
  els.analysisCurrentTitle.textContent = "This position";
  els.analysisLastTitle.textContent = "Last move";
  renderAnalysisBox(els.analysisCurrent, resp?.ai_analysis, "This position", null, resp);
  const node = currentNode();
  const last = node?.meta?.kind === "root" ? null : node?.meta?.analysis;
  const lastSourceResp = node?.parentId ? tree[node.parentId]?.resp : null;
  els.lastAnalysisSource.textContent = node?.meta?.kind === "move"
    ? (last?.label || node.meta.label)
    : "Move";
  renderAnalysisBox(
    els.analysisLast,
    last,
    "Last move",
    node?.meta?.kind === "move" ? node.meta.label : null,
    lastSourceResp,
  );
  els.analysisSource.textContent = resp?.ai_analysis?.label || "Policy";
}

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

function renderAnalysisBox(container, analysis, fallbackLabel, actionLabel = null, sourceResp = resp) {
  container.innerHTML = "";
  if (!analysis || !analysis.available) {
    const line = document.createElement("div");
    line.className = "analysis-muted";
    line.textContent = analysis?.error || `No ${fallbackLabel.toLowerCase()} analysis.`;
    container.appendChild(line);
    return;
  }
  if (actionLabel) {
    const move = document.createElement("div");
    move.className = "analysis-line";
    move.innerHTML = `<span>Move played</span><strong>${escapeHTML(actionLabel)}</strong>`;
    container.appendChild(move);
  }
  const value = document.createElement("div");
  value.className = "analysis-line";
  value.innerHTML = `<span>Value</span><strong>${valueFromPlayerOnePerspective(analysis.value, sourceResp).toFixed(3)}</strong>`;
  container.appendChild(value);
  (analysis.moves || []).slice(0, 5).forEach((move, i) => {
    const line = document.createElement("div");
    line.className = "analysis-line";
    const pct = `${(100 * Number(move.policy || 0)).toFixed(1)}%`;
    line.innerHTML = `<span>${i + 1}. ${escapeHTML(move.label)}</span><strong>${pct}</strong>`;
    container.appendChild(line);
  });
}

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

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 mode = els.playMode.value;
  const aiMode = mode !== "human-human";
  const split = splitSimsEnabled();
  const exploring = !isAtCurrentPosition();
  document.body.classList.toggle("exploring-position", exploring);
  els.infoPanelTitle.textContent = "Game Info";
  els.analysisToggle.style.display = "";
  els.moveNumber.textContent = String(Math.max(0, plyForNode(currentId)));
  els.aiSimsRow.style.display = aiMode ? "" : "none";
  els.splitSimsButton.style.display = isAiAiMode() ? "" : "none";
  els.splitSimsButton.classList.toggle("active", split);
  els.splitSimsButton.textContent = split ? "Shared" : "Split";
  els.splitSimsFields.style.display = split ? "inline-flex" : "none";
  els.aiSims.disabled = split;
  els.aiSims.classList.toggle("disabled", split);
  els.setupButton.classList.toggle("active", setupMode);
  els.setupButton.textContent = setupMode ? "Done" : "Modify Position";
  const undoTarget = tree[currentPositionId()];
  els.undoButton.disabled = !undoTarget || !undoTarget.parentId || aiThinking;
  els.backButton.disabled = !node || !node.parentId || aiThinking;
  els.forwardButton.disabled = !node || node.childIds.length === 0 || aiThinking;
  els.continueButton.disabled = exploring || !resp || resp.is_terminal || aiThinking || setupMode || !isAiTurn() || !selectedModelId();
  els.aiPlayButton.style.display = isAiAiMode() ? "inline-block" : "none";
  els.aiPauseButton.style.display = isAiAiMode() ? "inline-block" : "none";
  els.aiPlayButton.disabled = exploring || !isAiAiMode() || aiThinking || setupMode || resp?.is_terminal || aiAutoPlaying || !selectedModelId();
  els.aiPauseButton.disabled = !isAiAiMode() || !aiAutoPlaying;
  els.flipBoardButton.classList.toggle("active", boardPerspectiveFlipped);
  els.flipBoardButton.setAttribute("aria-pressed", boardPerspectiveFlipped ? "true" : "false");
  els.newGameButton.disabled = aiThinking;
  els.setupButton.disabled = aiThinking;
  els.playMode.closest("label").style.display = "";
  const showP1Ai = mode === "ai-human" || mode === "ai-ai";
  const showP2Ai = mode === "human-ai" || mode === "ai-ai";
  els.player1ModelRow.style.display = showP1Ai ? "" : "none";
  els.player2ModelRow.style.display = showP2Ai ? "" : "none";
  els.setupButton.style.display = "";
  els.returnCurrentCard.classList.toggle("show", !!resp && exploring);
  els.returnCurrentButton.disabled = !resp || !exploring;
  els.continueButton.textContent = isAiAiMode() ? "Step AI" : "Continue Game";
  els.continueButton.style.display = isAiAiMode() || mode === "human-ai" || mode === "ai-human" ? "" : "none";
  els.endTurnButton.classList.toggle("show", !!(userCanAct() && resp?.is_mid_jump && resp.can_end_turn));
}

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

function maybeRunAi() {
  clearAiTimer();
  if (!isAtCurrentPosition()) return;
  if (!isAiTurn() || setupMode || aiThinking || aiError || aiFrozen) return;
  if (isAiAiMode() && !aiAutoPlaying) return;
  aiTimer = setTimeout(() => {
    aiTimer = null;
    if (isAiTurn() && !setupMode && !aiThinking && !aiError && !aiFrozen) sendAiMove(false);
  }, isAiAiMode() ? 320 : 180);
}

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

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);
  [1, 2].forEach(playerNum => {
    const controls = aiControlsForPlayer(playerNum);
    controls.modelSelect.addEventListener("change", () => {
      aiAutoPlaying = false;
      clearAiTimer();
      aiAiAnalyses = null;
      aiAiAnalysisKey = "";
      syncNamesForMode();
      if (resp && currentPlayerNumber() === playerNum) refreshCurrentAnalysis();
      render();
    });
  });
  [els.aiSims, els.aiSimsP1, els.aiSimsP2].forEach(input => {
    input.addEventListener("change", () => {
      aiAutoPlaying = false;
      clearAiTimer();
      aiAiAnalyses = null;
      aiAiAnalysisKey = "";
      normalizeSimsInput(input);
      render();
    });
    input.addEventListener("input", () => clampCustomSimsInput(input));
  });
  els.splitSimsButton.addEventListener("click", () => {
    aiAutoPlaying = false;
    clearAiTimer();
    const nextSplit = els.splitSimsButton.getAttribute("aria-pressed") !== "true";
    if (nextSplit) {
      els.aiSimsP1.value = els.aiSims.value;
      els.aiSimsP2.value = els.aiSims.value;
    }
    els.splitSimsButton.setAttribute("aria-pressed", nextSplit ? "true" : "false");
    aiAiAnalyses = null;
    aiAiAnalysisKey = "";
    render();
  });
  els.playMode.addEventListener("change", () => {
    clearAiTimer();
    aiAutoPlaying = false;
    aiFrozen = false;
    aiAiAnalyses = null;
    aiAiAnalysisKey = "";
    setupMode = false;
    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.continueButton.addEventListener("click", continueGame);
  els.aiPlayButton.addEventListener("click", playAiAi);
  els.aiPauseButton.addEventListener("click", pauseAiAi);
  els.newGameButton.addEventListener("click", newGame);
  els.downloadTreeButton.addEventListener("click", downloadGameTree);
  els.loadTreeButton.addEventListener("click", () => els.loadTreeInput.click());
  els.loadTreeInput.addEventListener("change", () => loadGameTreeFile(els.loadTreeInput.files?.[0]));
  els.returnCurrentButton.addEventListener("click", returnToCurrentPosition);
  els.endTurnButton.addEventListener("click", () => resp && sendAction(resp.end_turn_action));
  els.player1Name.addEventListener("input", renderStatus);
  els.player2Name.addEventListener("input", renderStatus);
  els.treeToggle.addEventListener("click", () => togglePanel(els.treeBody, els.treeToggle));
  els.analysisToggle.addEventListener("click", () => togglePanel(els.analysisBody, els.analysisToggle));
  els.flipBoardButton.addEventListener("click", () => {
    boardPerspectiveFlipped = !boardPerspectiveFlipped;
    hoverTarget = null;
    render();
  });
}

async function refreshCurrentAnalysis() {
  if (!resp) return;
  const playerNum = currentPlayerNumber();
  try {
    const data = await post("/ai_analysis", {
      rows: resp.rows,
      cols: resp.cols,
      state: resp.state,
      model_id: selectedModelId(playerNum),
    });
    resp.ai_analysis = data.analysis;
    if (tree[currentId]) tree[currentId].resp = resp;
  } catch (err) {
    resp.ai_analysis = { available: false, error: err.message };
  }
}

async function refreshAiAiAnalyses() {
  const key = currentAiAiAnalysisKey();
  if (!key || key === aiAiAnalysisKey) return;
  aiAiAnalysisKey = key;
  aiAiAnalyses = null;
  try {
    const [p1, p2] = await Promise.all([1, 2].map(playerNum => post("/ai_analysis", {
      rows: resp.rows,
      cols: resp.cols,
      state: resp.state,
      model_id: selectedModelId(playerNum),
    })));
    if (key !== currentAiAiAnalysisKey()) return;
    aiAiAnalyses = { p1: p1.analysis, p2: p2.analysis };
    render();
  } catch (err) {
    if (key !== currentAiAiAnalysisKey()) return;
    aiAiAnalyses = {
      p1: { available: false, error: err.message },
      p2: { available: false, error: err.message },
    };
    render();
  }
}

async function boot() {
  initElements();
  els.analysisBody.hidden = false;
  els.analysisToggle.textContent = "Hide";
  bindEvents();
  await loadModels();
  syncNamesForMode();
  [els.aiSims, els.aiSimsP1, els.aiSimsP2].forEach(normalizeSimsInput);
  await newGame();
}

boot();
