core/game.ts

import { Board, ROWS, COLUMNS } from "./board.ts";
import { Player } from "./player.ts";
import { GameEvent } from "./game-event.ts";
import { events } from "./event-bus.ts";
import { store, serializeBoard, deserializeBoard, deserializePlayer } from "./store.ts";
import { animationManager } from "./animation-manager.ts";
import { loadBoard } from "../persistence/loader.ts";
import { Registry } from "./registry.ts";
import { Item } from "./item.ts";
import {
  PLAYER as PLAYER_FLAG,
  RANGED_WEAPON,
  VERTICAL,
  PARALYZED,
  TURNED_TO_STONE,
} from "./flags.ts";
import {
  NORTH,
  SOUTH,
  EAST,
  WEST,
  UP,
  DOWN,
  NONE,
  ADJ_DIRECTIONS,
  Direction,
} from "./direction.ts";
import { InFlightItem, Hit, Fade } from "../pieces/effects/effects.ts";
import {
  findPathToTarget,
  getDirectionToCell,
  getDistance,
  Targeting,
} from "../pieces/agents/targeting.ts";
import { randomColor } from "./color.ts";
import { BoardView } from "../ui/board-view.js";
import { Cell } from "./cell.ts";
import { Agent } from "./agent.ts";
import { Piece } from "./piece.ts";
import type { SaveSlot } from "./save-format.ts";

export const STATE = {
  MENU: "menu",
  PLAYING: "playing",
  ANIMATING: "animating",
  GAME_OVER: "game_over",
  WON: "won",
};

export class Game {
  player!: Player;
  board!: Board;
  boardView!: BoardView;
  state: string = STATE.MENU;
  #agentCells: Cell[];
  onGameOver: Function | null;
  onWon: ((s: string) => void) | null;
  constructor() {
    this.#agentCells = [];
    /** Fired when the game transitions to GAME_OVER. */
    this.onGameOver = null;
    /** Fired when the game transitions to WON. */
    this.onWon = null;

    // Listen for player death
    events.onPlayerChanged((player) => {
      if (this.state === STATE.PLAYING && player.health <= 0) {
        this.transitionTo(STATE.GAME_OVER);
        if (this.onGameOver) this.onGameOver();
      }
    });

    // Handle pit-trap fall-through: navigate to the board below (DOWN direction)
    events.onFallThrough((x, y) => {
      if (this.state === STATE.PLAYING) {
        const cell = this.board.getCellAt(x, y);
        if (cell) this.navigateBoard(DOWN, cell);
      }
    });
  }

  /**
   * Start a brand-new game.
   * @param {string} boardPath  e.g. "tutorial/start"
   * @param {string} scenarioURL  base URL for scenario assets
   */
  async newGame(boardPath: string, scenarioURL: string) {
    const { board, startX, startY, startInv } = await loadBoard(boardPath);

    const player = new Player("Player", scenarioURL, boardPath, startX, startY);
    player.bag.setEventFactory(() => new GameEvent(player, this.board, this));

    // Grant starting inventory
    for (const key of startInv) {
      const item = Registry.get(key);
      if (item instanceof Item) {
        player.bag.add(item);
      } else {
        console.warn(`could not find ${key} to add to player inventory`);
      }
    }
    this.setContext(player, board);
    // Transition before landing: a modal message fired by #fireLandEnter
    // snapshots game.state to restore on dismiss, so PLAYING must already
    // be current or the state gets reset to MENU when the player dismisses it.
    this.transitionTo(STATE.PLAYING);

    // Place the player
    const startCell = board.getCellAt(startX, startY);
    if (startCell) {
      startCell.setAgent(player);
      this.#fireLandEnter(startCell);
    }
    this.fireFull();
  }

  /**
   * Load a saved game.
   * @returns {boolean} true if loaded successfully
   */
  async loadGame(slotName: string): Promise<boolean> {
    const save = await store.load(slotName) as SaveSlot | null;
    if (!save) return false;

    const pd = save.player;
    const player = new Player(
      pd.name,
      pd.scenarioURL,
      pd.boardID,
      pd.startX,
      pd.startY,
    );
    player.bag.setEventFactory(() => new GameEvent(player, this.board, this));

    deserializePlayer(pd, player);

    // Load board from unsavedMaps (already visited state)
    const boardID = pd.boardID;
    let board;
    const savedBoardJSON = player.unsavedMaps.get(boardID);
    if (savedBoardJSON) {
      board = new Board();
      deserializeBoard(JSON.parse(savedBoardJSON), board);
    } else {
      // Fall back to fresh load if state not in unsavedMaps
      const result = await loadBoard(boardID);
      board = result.board;
    }

    this.setContext(player, board);
    // Transition before landing — see the comment in newGame().
    this.transitionTo(STATE.PLAYING);

    // Re-place player at their last position
    const px = pd.playerX ?? board.startX;
    const py = pd.playerY ?? board.startY;
    const cell = board.getCellAt(px, py);
    if (cell) {
      cell.setAgent(player);
      this.#fireLandEnter(cell);
    }

    this.fireFull();
    return true;
  }

  setContext(player: Player, board: Board) {
    this.player = player;
    this.board = board;
    animationManager.setContext(board, player, this);
    // why are we doing this?
    if (this.boardView) {
      this.boardView.detach();
      this.boardView.attach(board);
    }
    animationManager.start();
  }

  /**
   * End the game: flash the background (if hasWon), then call onWon / onGameOver.
   * Mirrors Java's Game.gameOver(url, hasWon).
   */
  gameOver(url: string, hasWon: boolean) {
    this.transitionTo(hasWon ? STATE.WON : STATE.GAME_OVER);
    animationManager.stop();
    const fullUrl = this.player.scenarioURL + url;
    if (!hasWon) {
      if (this.onGameOver) this.onGameOver(fullUrl);
      return;
    }
    // Flash the background ~20 times at 100 ms, then invoke onWon.
    let flashes = 0;
    const body = document.body;
    const originalBg = body.style.backgroundColor;
    const timer = setInterval(() => {
      body.style.backgroundColor = randomColor().toString();
      if (++flashes >= 20) {
        clearInterval(timer);
        body.style.backgroundColor = originalBg;
        if (this.onWon) this.onWon(fullUrl);
      }
    }, 100);
  }

  transitionTo(newState: string) {
    this.state = newState;
  }

  #showLoadingOverlay() {
    if (this.boardView && typeof document !== "undefined") {
      const el = document.getElementById("loading-overlay");
      if (el) {
        el.classList.add("visible");
      }
    }
  }

  #hideLoadingOverlay() {
    if (typeof document !== "undefined") {
      const el = document.getElementById("loading-overlay");
      if (el) {
        el.classList.remove("visible");
      }
    }
  }

  /**
   * Handle a player action.
   */
  async handleInput(action: string, dirOrNum: Direction | number | null) {
    if (this.state !== STATE.PLAYING) return;
    if (this.player.is(PARALYZED) || this.player.is(TURNED_TO_STONE)) return;

    const dir = (typeof dirOrNum === "number") ? null : dirOrNum;
    const index = (typeof dirOrNum === "number") ? dirOrNum : -1;

    switch (action) {
      case "move":
        if (dir) await this.movePlayer(dir);
        break;
      case "pickup":
        this.pickup();
        break;
      case "drop":
        this.drop();
        break;
      case "use":
        this.useSelected();
        break;
      case "throw":
        if (dir) this.throwItem(dir);
        break;
      case "fire":
        if (dir) this.fireRanged(dir);
        break;
      case "vertical":
        await this.verticalMove();
        break;
      case "cycle-up":
        this.player.bag.selectUp();
        break;
      case "cycle-down":
        this.player.bag.selectDown();
        break;
      case "select-weapon":
        this.player.bag.selectFirstWeapon();
        break;
      case "select-empty":
        this.player.bag.selectEmptyHanded();
        break;
      case "reorder-up":
        this.player.bag.moveSelectedUp();
        break;
      case "reorder-down":
        this.player.bag.moveSelectedDown();
        break;
      case "pickup-index":
        this.pickupAtIndex(index);
        break;
      case "bag-select":
        this.player.bag.select(index);
        break;
    }
    events.fireHandleInventoryMessaging();
  }

  // ── Movement ────────────────────────────────────────────────────────────────

  async movePlayer(dir: Direction) {
    const { player, board } = this;
    // Block movement while the player is paralyzed or turned to stone.
    if (player.is(PARALYZED) || player.is(TURNED_TO_STONE)) return;
    const fromCell = board.getCurrentCell();

    const event = new GameEvent(player, board, this);

    // Ask current terrain if we may leave
    fromCell.terrain.onExit(event, player, fromCell, dir);
    if (event.isCancelled) return;

    const toCell = board.getAdjacentCell(fromCell.x, fromCell.y, dir);

    if (!toCell) {
      // Player walked off the board edge — try to navigate to adjacent board
      await this.navigateBoard(dir, fromCell);
      return;
    }

    // Melee: walk into a non-player agent
    if (toCell.agent && !toCell.agent.is(PLAYER_FLAG)) {
      // Let target terrain respond before the melee attack (mirrors Java move()).
      // This ensures terrain like ForceField can strip the player's inventory
      // even when they are pushing a boulder into it.
      const enterEvent = new GameEvent(player, board, this);
      toCell.terrain.onEnter(enterEvent, player, toCell, dir);
      if (enterEvent.isCancelled) return;
      const meleeEvent = this.meleeAttack(fromCell, toCell, dir);
      // If the agent was pushed out (cell now empty) and the event wasn't
      // cancelled, move the player into the vacated cell (mirrors Java move()).
      if (
        !meleeEvent.isCancelled &&
        !toCell.agent &&
        toCell.canEnter(fromCell, player, dir)
      ) {
        fromCell.moveAgentTo(toCell, player);
        this.notifyAdjacent(toCell);
      }
      this.runAgentTurns(event);
      return;
    }

    // Let target terrain respond (open door, bounce message, etc.)
    const enterEvent = new GameEvent(player, board, this);
    toCell.terrain.onEnter(enterEvent, player, toCell, dir);
    if (enterEvent.isCancelled) return;

    // Physical traversability check
    if (!toCell.canEnter(fromCell, player, dir)) {
      return;
    }

    // Notify terrain that is no longer adjacent
    this.notifyNotAdjacent(fromCell);

    // Move
    fromCell.moveAgentTo(toCell, player);

    // Notify stepped-on items
    const stepEvent = new GameEvent(player, board, this);
    toCell.onSteppedOn(stepEvent, fromCell, player);

    // Notify adjacent terrain
    this.notifyAdjacent(toCell);

    this.runAgentTurns(new GameEvent(player, board, this));
  }

  notifyAdjacent(cell: Cell) {
    const event = new GameEvent(this.player, this.board, this);
    if (cell.terrain) cell.terrain.onAdjacentTo(event, cell);
    for (const dir of ADJ_DIRECTIONS) {
      const adj = this.board.getAdjacentCell(cell.x, cell.y, dir);
      if (adj?.terrain) {
        adj.terrain.onAdjacentTo(event, adj);
      }
    }
  }

  notifyNotAdjacent(cell: Cell) {
    const event = new GameEvent(this.player, this.board, this);
    if (cell.terrain) cell.terrain.onNotAdjacentTo(event, cell);
    for (const dir of ADJ_DIRECTIONS) {
      const adj = this.board.getAdjacentCell(cell.x, cell.y, dir);
      if (adj?.terrain) {
        adj.terrain.onNotAdjacentTo(event, adj);
      }
    }
  }

  async #loadOrRestoreBoard(boardID: string) {
    const savedJSON = this.player.unsavedMaps.get(boardID);
    if (savedJSON) {
      const board = new Board();
      deserializeBoard(JSON.parse(savedJSON), board);
      return board;
    }
    this.#showLoadingOverlay();
    try {
      const { board } = await loadBoard(boardID);
      return board;
    } finally {
      this.#hideLoadingOverlay();
    }
  }

  #switchBoard(newBoardID: string, newBoard: Board, entryX: number, entryY: number) {
    this.player.boardID = newBoardID;
    this.setContext(this.player, newBoard);
    const entryCell = newBoard.getCellAt(entryX, entryY);
    if (entryCell) {
      if (entryCell.agent) {
        const fallback = newBoard.findRandomCell();
        fallback.setAgent(this.player);
        this.#fireLandEnter(fallback);
      } else {
        entryCell.setAgent(this.player);
        this.#fireLandEnter(entryCell);
      }
    }
    this.fireFull();
  }

  /**
   * Fire the landing cell's onEnter, mirroring Java Game.land(): terrain that
   * reacts on entry (e.g. a welcome TriggerOnce) must fire even when the
   * player is placed directly — new game, loaded save, board switch — rather
   * than walking in. There is no originating direction, so Direction.NONE
   * stands in for Java's null.
   */
  #fireLandEnter(cell: Cell) {
    const event = new GameEvent(this.player, this.board, this);
    cell.terrain.onEnter(event, this.player, cell, NONE);
  }

  async navigateBoard(dir: Direction, fromCell: Cell) {
    const adjacentPath = this.board.getAdjacentBoard(dir);
    if (!adjacentPath) return;

    const { player, board: oldBoard } = this;
    player.unsavedMaps.set(
      oldBoard.boardID,
      JSON.stringify(serializeBoard(oldBoard)),
    );
    fromCell.removeAgent(player);

    const newBoard = await this.#loadOrRestoreBoard(adjacentPath);

    // Determine entry position
    let entryX, entryY;
    if (dir === UP || dir === DOWN) {
      // Stairs: land at the same (x,y) on the destination board — Java convention.
      // The map designer places matching stairs at the same coordinates on each floor.
      entryX = fromCell.x;
      entryY = fromCell.y;
    } else if (dir === NORTH) {
      entryX = fromCell.x;
      entryY = ROWS - 1;
    } else if (dir === SOUTH) {
      entryX = fromCell.x;
      entryY = 0;
    } else if (dir === EAST) {
      entryX = 0;
      entryY = fromCell.y;
    } else if (dir === WEST) {
      entryX = COLUMNS - 1;
      entryY = fromCell.y;
    } else {
      entryX = newBoard.startX;
      entryY = newBoard.startY;
    }
    this.#switchBoard(adjacentPath, newBoard, entryX, entryY);
  }

  /**
   * Teleport the player to a specific board and position.
   * Called by Game.teleport() after the fade animation completes.
   * Mirrors Java Game.teleport(): saves current board, loads target, places player.
   */
  async teleportTo(boardID: string, x: number, y: number) {
    const { player, board: oldBoard } = this;

    // Resolve relative boardID using the current board's directory, falling
    // back to board.folder when the boardID has no directory component.
    const boardDir = oldBoard.boardID.includes("/")
      ? oldBoard.boardID.split("/").slice(0, -1).join("/")
      : (oldBoard.folder ?? "");
    const fullBoardID = boardDir ? `${boardDir}/${boardID}` : boardID;

    player.unsavedMaps.set(
      oldBoard.boardID,
      JSON.stringify(serializeBoard(oldBoard)),
    );

    const newBoard = await this.#loadOrRestoreBoard(fullBoardID);

    player.startX = x;
    player.startY = y;
    this.#switchBoard(fullBoardID, newBoard, x, y);
  }

  async verticalMove() {
    const cell = this.board.getCurrentCell();
    if (!cell.terrain.is(VERTICAL)) return;

    const { player, board } = this;

    // Determine direction: try UP first (stair exit cancels wrong dir)
    for (const dir of [UP, DOWN]) {
      const adjPath = board.getAdjacentBoard(dir);
      if (!adjPath) continue;

      // Check if terrain allows exit in this direction
      const testEvent = new GameEvent(player, board, this);
      cell.terrain.onExit(testEvent, player, cell, dir);
      if (!testEvent.isCancelled) {
        await this.navigateBoard(dir, cell);
        return;
      }
    }
  }

  pickup() {
    this.pickupAtIndex(0);
  }

  /**
   * Pick up the item at the given display-list index on the current cell.
   * Index 0 = first item in the grouped display list (same as pressing 'p').
   */
  pickupAtIndex(index: number) {
    const { player, board } = this;
    const cell = board.getCurrentCell();
    if (cell.isBagEmpty) return;

    const item = _itemAtDisplayIndex(cell, index);
    if (!item) return;

    const event = new GameEvent(player, board, this);

    // Let terrain react (e.g., deny pickup on lava)
    cell.terrain.onPickup(event, cell, player, item);
    if (event.isCancelled) return;

    // Weakness check
    if (player.enforceWeakness(event, cell, item)) return;

    cell.removeItem(item);
    player.bag.add(item);

    this.runAgentTurns(event);
  }

  drop() {
    const { player, board } = this;
    const cell = board.getCurrentCell();

    const item = player.bag.getSelected();
    if (!item || item.name === "Empty-handed") return;

    const event = new GameEvent(player, board, this);
    item.onDrop(event, cell);
    if (event.isCancelled) return;

    cell.terrain.onDrop(event, cell, item);
    if (event.isCancelled) {
      // Item vanishes into lava etc. — still remove from bag
      player.bag.remove(item);
      return;
    }

    player.bag.remove(item);
    cell.addItem(item);

    this.runAgentTurns(event);
  }

  useSelected() {
    const { player, board } = this;
    const cell = board.getCurrentCell();

    const item = player.bag.getSelected();
    if (!item || item.name === "Empty-handed") return;

    const event = new GameEvent(player, board, this);
    item.onUse(event);
    if (event.isCancelled) return;

    this.runAgentTurns(event);
  }

  throwItem(dir: Direction) {
    const { player, board } = this;
    const cell = board.getCurrentCell();

    const item = player.bag.getSelected();
    if (!item || item.name === "Empty-handed") return;

    const event = new GameEvent(player, board, this);
    item.onThrow(event, cell);
    if (event.isCancelled) return;

    player.bag.remove(item);
    const projectile = new InFlightItem(item, dir, player);
    cell.addEffect(projectile);
  }

  fireRanged(dir: Direction) {
    const { player, board } = this;
    if (player.is(PARALYZED) || player.is(TURNED_TO_STONE)) return;
    const cell = board.getCurrentCell();

    const weapon = player.bag.getSelected();
    if (!weapon || !weapon.is(RANGED_WEAPON)) {
      events.fireMessage("You need a ranged weapon equipped.", cell);
      return;
    }

    const event = new GameEvent(player, board, this);
    const ammoItem = weapon.onFire(event);
    if (event.isCancelled) return;
    if (!ammoItem) {
      events.fireMessage("You have no ammunition.", cell);
      return;
    }

    const projectile = new InFlightItem(ammoItem, dir, player);
    cell.addEffect(projectile);
  }

  meleeAttack(attackerCell: Cell, targetCell: Cell, dir: Direction) {
    const { player, board } = this;
    const event = new GameEvent(player, board, this);
    const target = targetCell.agent;

    if (target) {
      // Player hits target
      player.onHit(event, attackerCell, targetCell, target);
      // Target reacts to being hit — pass attackerCell (Java convention: agentLoc = attacker's cell)
      target.onHitBy(event, attackerCell, player, dir);
    }

    // Remove any agents killed by the attack
    if (event.kills) {
      for (const { cell, agent } of event.kills) {
        agent.onDie(event, cell);
        cell.removeAgent(agent);
        cell.addEffect(new Hit(agent));
      }
    }

    return event;
  }

  /**
   * Move an agent according to a Targeting specification.
   * Called by agent onFrame handlers.
   */
  createEvent() {
    return new GameEvent(this.player, this.board, this);
  }

  agentMove(cell: Cell, agent: Agent, targeting: Targeting) {
    const dir = findPathToTarget(this.board, cell, agent, targeting);
    if (dir != null) {
      const event = this.createEvent();
      this.agentMoveInDirection(event, cell, agent, dir);
    }
  }

  /**
   * Execute an agent move in a specific direction (for RollingBoulder, Pusher, Tumbleweed).
   */
  agentMoveInDirection(event: GameEvent, cell: Cell, agent: Agent, dir: Direction) {
    const next = this.board.getAdjacentCell(cell.x, cell.y, dir);

    cell.terrain.onAgentExit(event, agent, cell, dir);
    if (event.isCancelled) return;

    if (!next) {
      event.cancel();
      return;
    }

    next.terrain.onAgentEnter(event, agent, next, dir);
    if (event.isCancelled) return;

    const nextAgent = next.agent;
    if (nextAgent != null) {
      if (nextAgent.is(PLAYER_FLAG)) {
        agent.onHit(event, cell, next, nextAgent);
      }
      nextAgent.onHitBy(event, cell, agent, dir);
      if (event.isCancelled) return;
    }

    const moving = cell.agent;
    if (moving) {
      cell.moveAgentTo(next, moving);
      if (!next.isBagEmpty && next.agent) {
        next.onSteppedOn(event, next, next.agent);
      }
    }
  }

  /**
   * Fire a projectile from an agent toward the player if within targeting range.
   */
  agentShoot(cell: Cell, agent: Agent, ammo: Item, targeting: Targeting) {
    const playerCell = this.board.getCurrentCell();

    const d = getDistance(cell, playerCell);
    if (d > targeting.range) return;

    const dir = getDirectionToCell(cell, playerCell);
    if (!dir) return;

    const event = this.createEvent();
    this.shoot(event, cell, agent, ammo, dir);
  }

  /**
   * Fire a projectile from a cell in a specific direction (used by terrain
   * like Shooter, and internally by agentShoot).
   */
  shoot(event: GameEvent, cell: Cell, originator: Piece, ammo: Item, dir: Direction) {
    const projectile = new InFlightItem(ammo, dir, originator);
    cell.terrain.onFlyOver(event, cell, projectile);
    if (!event.isCancelled) {
      cell.addEffect(projectile);
    }
  }

  runAgentTurns(event: GameEvent) {
    const board = this.board;

    // Collect all non-player agents
    this.#agentCells.length = 0;
    board.visit((cell: Cell, n: number) => {
      if (cell.agent && !cell.agent.is(PLAYER_FLAG)) {
        this.#agentCells.push(cell);
      }
      return true;
    });

    for (const cell of this.#agentCells) {
      const agent = cell.agent;
      if (!agent) continue;
      const agentEvent = new GameEvent(this.player, board, this);
      agent.onTurn(agentEvent, board, cell);
    }
  }

  /**
   * Called by Teleporter (and similar terrain) when the player steps on it.
   * Mirrors Java Player.teleport(): cancels the enter event, removes the player
   * from the current cell, plays a Fade animation on the adjacent cell in the
   * movement direction, then calls Game.teleportTo() when the fade completes.
   */
  teleport(event: GameEvent, dir: Direction, boardID: string, x: number, y: number) {
    event.cancel();
    const currentCell = event.board.getCurrentCell();
    currentCell.removeAgent(event.player);
    const adjCell =
      event.board.getAdjacentCell(currentCell.x, currentCell.y, dir) ??
      currentCell;
    const fade = new Fade(event.player.symbol, () => game.teleportTo(boardID, x, y));
    adjCell.addEffect(fade);
    event.board.notifyCellChange(currentCell);
    event.board.notifyCellChange(adjCell);
  }

  async save(slotName: string) {
    if (this.player && this.board) {
      await store.save(this.player, this.board, slotName);
    }
  }

  fireFull() {
    events.firePlayerChanged(this.player);
    events.fireInventoryChanged(this.player.bag);
    events.fireFlagsChanged(this.player);
    if (this.boardView) {
      this.boardView.rerender?.();
    }
  }
}

export const game = new Game();

/**
 * Return the item at visual display index `index` in the grouped item list for
 * the given cell. Groups items by name (matching popup display order).
 * Index 0 = first item shown in the popup (pressing '1' or 'p').
 */
function _itemAtDisplayIndex(cell: Cell, index: number): Item | null {
  const seen = new Set();
  let count = 0;
  for (const item of cell.items) {
    if (!seen.has(item.name)) {
      if (count === index) return item;
      seen.add(item.name);
      count++;
    }
  }
  return null;
}