import {
  Application,
  Container,
  Graphics,
  Sprite,
  Text,
  TextStyle,
  Texture,
} from "pixi.js";
import UPNG from "upng-js";

const stockMaps: Record<string, string> = import.meta.glob("./maps/map*.png", {
  eager: true,
  import: "default",
  query: "?url",
}) as Record<string, string>;

const TILE_SIZE = 32;
const MAP_OFFSET_X = 16;
const MAP_OFFSET_Y = 16;

const PATH_COLOR = 0x00ffff;
const PATH_LINE_W = 2;
const PATH_HEAD_SIZE = 8;

const SMOOTH_COLOR = 0xff8800;
const THETA_COLOR = 0x2255dd;
const FUNNEL_COLOR = 0x00ff88;
const THETA_FUNNEL_COLOR = 0xaa44ff;
const START_COLOR = 0xffff00;
const GOAL_COLOR = 0xff8800;
const MARKER_R = 8;
const UNIT_RADIUS = TILE_SIZE * 0.3;

const enum Tile {
  Ground = 0,
  Wall = 1,
}

interface World {
  cols: number;
  rows: number;
  tiles: Uint8Array;
}

// Bidirectional tile↔color mapping for PNG I/O.
// Export: tileToRgb → RGBA buffer → UPNG quantises to indexed PNG.
// Import: UPNG → RGBA buffer → rgbToTile (nearest RGB distance).
const TILE_COLORS: { tile: Tile; r: number; g: number; b: number }[] = [
  { tile: Tile.Ground, r: 0, g: 0, b: 0 },
  { tile: Tile.Wall, r: 255, g: 255, b: 255 },
];

function tileToRgb(tile: Tile): { r: number; g: number; b: number } {
  for (const entry of TILE_COLORS) {
    if (entry.tile === tile) return entry;
  }
  return TILE_COLORS[0];
}

function rgbToTile(r: number, g: number, b: number): Tile {
  let best = TILE_COLORS[0];
  let bestDist = Infinity;
  for (const entry of TILE_COLORS) {
    const d = (r - entry.r) ** 2 + (g - entry.g) ** 2 + (b - entry.b) ** 2;
    if (d < bestDist) {
      bestDist = d;
      best = entry;
    }
  }
  return best.tile;
}

type Coord = { col: number; row: number };
type Path = Coord[];

function makeWorld(cols: number, rows: number): World {
  return { cols, rows, tiles: new Uint8Array(cols * rows) };
}

function getTile(world: World, col: number, row: number): Tile {
  return world.tiles[row * world.cols + col] as Tile;
}

function tileCenter(col: number, row: number): { x: number; y: number } {
  return {
    x: MAP_OFFSET_X + col * TILE_SIZE + TILE_SIZE / 2,
    y: MAP_OFFSET_Y + row * TILE_SIZE + TILE_SIZE / 2,
  };
}

function coordToIndex(world: World, col: number, row: number): number {
  return row * world.cols + col;
}

function inBounds(world: World, col: number, row: number): boolean {
  return col >= 0 && col < world.cols && row >= 0 && row < world.rows;
}

interface HeapNode {
  index: number;
  score: number;
}

class MinHeap {
  private data: HeapNode[] = [];

  push(node: HeapNode): void {
    this.data.push(node);
    this.bubbleUp(this.data.length - 1);
  }

  pop(): HeapNode | undefined {
    if (this.data.length === 0) return undefined;
    const top = this.data[0];
    const last = this.data.pop()!;
    if (this.data.length > 0) {
      this.data[0] = last;
      this.sinkDown(0);
    }
    return top;
  }

  get size(): number {
    return this.data.length;
  }

  private bubbleUp(i: number): void {
    while (i > 0) {
      const parent = (i - 1) >> 1;
      if (this.data[parent].score <= this.data[i].score) break;
      [this.data[parent], this.data[i]] = [this.data[i], this.data[parent]];
      i = parent;
    }
  }

  private sinkDown(i: number): void {
    const n = this.data.length;
    while (true) {
      let smallest = i;
      const l = 2 * i + 1;
      const r = 2 * i + 2;
      if (l < n && this.data[l].score < this.data[smallest].score) smallest = l;
      if (r < n && this.data[r].score < this.data[smallest].score) smallest = r;
      if (smallest === i) break;
      [this.data[smallest], this.data[i]] = [this.data[i], this.data[smallest]];
      i = smallest;
    }
  }
}

const NEIGHBORS = [
  { dc: 0, dr: -1 },
  { dc: 0, dr: 1 },
  { dc: -1, dr: 0 },
  { dc: 1, dr: 0 },
];

function findPath(world: World, start: Coord, goal: Coord): Path | null {
  if (getTile(world, start.col, start.row) === Tile.Wall) return null;
  if (getTile(world, goal.col, goal.row) === Tile.Wall) return null;

  const startIdx = coordToIndex(world, start.col, start.row);
  const goalIdx = coordToIndex(world, goal.col, goal.row);

  const gScore = new Float32Array(world.cols * world.rows).fill(Infinity);
  const cameFrom = new Int32Array(world.cols * world.rows).fill(-1);
  gScore[startIdx] = 0;

  const open = new MinHeap();
  const heuristic = (col: number, row: number) =>
    Math.abs(col - goal.col) + Math.abs(row - goal.row);

  open.push({ index: startIdx, score: heuristic(start.col, start.row) });
  const inOpen = new Uint8Array(world.cols * world.rows);
  inOpen[startIdx] = 1;

  while (open.size > 0) {
    const { index: current } = open.pop()!;
    inOpen[current] = 0;

    if (current === goalIdx) {
      const path: Path = [];
      let idx = goalIdx;
      while (idx !== -1) {
        path.push({ col: idx % world.cols, row: Math.floor(idx / world.cols) });
        idx = cameFrom[idx];
      }
      return path.reverse();
    }

    const col = current % world.cols;
    const row = Math.floor(current / world.cols);

    for (const { dc, dr } of NEIGHBORS) {
      const nc = col + dc;
      const nr = row + dr;
      if (!inBounds(world, nc, nr)) continue;
      if (getTile(world, nc, nr) === Tile.Wall) continue;

      const neighborIdx = coordToIndex(world, nc, nr);
      const tentativeG = gScore[current] + 1;

      if (tentativeG < gScore[neighborIdx]) {
        cameFrom[neighborIdx] = current;
        gScore[neighborIdx] = tentativeG;
        if (!inOpen[neighborIdx]) {
          open.push({
            index: neighborIdx,
            score: tentativeG + heuristic(nc, nr),
          });
          inOpen[neighborIdx] = 1;
        }
      }
    }
  }

  return null;
}

function findPathTheta(world: World, start: Coord, goal: Coord): Path | null {
  if (getTile(world, start.col, start.row) === Tile.Wall) return null;
  if (getTile(world, goal.col, goal.row) === Tile.Wall) return null;

  const startIdx = coordToIndex(world, start.col, start.row);
  const goalIdx = coordToIndex(world, goal.col, goal.row);

  const gScore = new Float32Array(world.cols * world.rows).fill(Infinity);
  const cameFrom = new Int32Array(world.cols * world.rows).fill(-1);
  gScore[startIdx] = 0;

  const open = new MinHeap();
  const heuristic = (col: number, row: number) => {
    const dc = col - goal.col;
    const dr = row - goal.row;
    return Math.sqrt(dc * dc + dr * dr);
  };

  open.push({ index: startIdx, score: heuristic(start.col, start.row) });
  // Use closed set + lazy deletion: allow duplicate heap entries, skip already-closed nodes.
  const closed = new Uint8Array(world.cols * world.rows);

  while (open.size > 0) {
    const { index: current } = open.pop()!;
    if (closed[current]) continue;
    closed[current] = 1;

    if (current === goalIdx) {
      const path: Path = [];
      let idx = goalIdx;
      while (idx !== -1) {
        path.push({ col: idx % world.cols, row: Math.floor(idx / world.cols) });
        idx = cameFrom[idx];
      }
      return path.reverse();
    }

    const col = current % world.cols;
    const row = Math.floor(current / world.cols);
    const parentIdx = cameFrom[current];

    for (const { dc, dr } of NEIGHBORS) {
      const nc = col + dc;
      const nr = row + dr;
      if (!inBounds(world, nc, nr)) continue;
      if (getTile(world, nc, nr) === Tile.Wall) continue;

      const neighborIdx = coordToIndex(world, nc, nr);
      if (closed[neighborIdx]) continue;

      // Theta*: if LOS exists from parent to neighbor, shortcut through parent.
      let tentativeG: number;
      let newParent: number;
      if (parentIdx !== -1) {
        const pCol = parentIdx % world.cols;
        const pRow = Math.floor(parentIdx / world.cols);
        if (
          lineOfSight(world, { col: pCol, row: pRow }, { col: nc, row: nr })
        ) {
          const dpc = nc - pCol;
          const dpr = nr - pRow;
          tentativeG = gScore[parentIdx] + Math.sqrt(dpc * dpc + dpr * dpr);
          newParent = parentIdx;
        } else {
          tentativeG = gScore[current] + Math.sqrt(dc * dc + dr * dr);
          newParent = current;
        }
      } else {
        tentativeG = gScore[current] + Math.sqrt(dc * dc + dr * dr);
        newParent = current;
      }

      if (tentativeG < gScore[neighborIdx]) {
        cameFrom[neighborIdx] = newParent;
        gScore[neighborIdx] = tentativeG;
        open.push({
          index: neighborIdx,
          score: tentativeG + heuristic(nc, nr),
        });
      }
    }
  }

  return null;
}

function lineOfSight(world: World, a: Coord, b: Coord): boolean {
  const x0 = a.col + 0.5;
  const y0 = a.row + 0.5;
  const x1 = b.col + 0.5;
  const y1 = b.row + 0.5;
  const dx = x1 - x0;
  const dy = y1 - y0;
  const stepX = dx > 0 ? 1 : -1;
  const stepY = dy > 0 ? 1 : -1;
  const tDeltaX = dx !== 0 ? Math.abs(1 / dx) : Infinity;
  const tDeltaY = dy !== 0 ? Math.abs(1 / dy) : Infinity;
  let tMaxX =
    dx !== 0
      ? Math.abs((Math.floor(x0) + (dx > 0 ? 1 : 0) - x0) / dx)
      : Infinity;
  let tMaxY =
    dy !== 0
      ? Math.abs((Math.floor(y0) + (dy > 0 ? 1 : 0) - y0) / dy)
      : Infinity;
  let col = Math.floor(x0);
  let row = Math.floor(y0);
  const endCol = Math.floor(x1);
  const endRow = Math.floor(y1);

  while (true) {
    if (getTile(world, col, row) === Tile.Wall) return false;
    if (col === endCol && row === endRow) break;
    if (tMaxX < tMaxY) {
      tMaxX += tDeltaX;
      col += stepX;
    } else if (tMaxY < tMaxX) {
      tMaxY += tDeltaY;
      row += stepY;
    } else {
      // Ray hits a grid corner exactly — step both axes and check the diagonal tile.
      tMaxX += tDeltaX;
      tMaxY += tDeltaY;
      if (
        getTile(world, col + stepX, row) === Tile.Wall ||
        getTile(world, col, row + stepY) === Tile.Wall
      )
        return false;
      col += stepX;
      row += stepY;
    }
  }
  return true;
}

function smoothPath(world: World, path: Path): Path {
  if (path.length <= 2) return path;
  const result: Path = [path[0]];
  let current = 0;
  while (current < path.length - 1) {
    let farthest = current + 1;
    for (let i = path.length - 1; i > current + 1; i--) {
      if (lineOfSight(world, path[current], path[i])) {
        farthest = i;
        break;
      }
    }
    result.push(path[farthest]);
    current = farthest;
  }
  return result;
}

function pathLength(path: Path): number {
  let len = 0;
  for (let i = 1; i < path.length; i++) {
    const dc = path[i].col - path[i - 1].col;
    const dr = path[i].row - path[i - 1].row;
    len += Math.sqrt(dc * dc + dr * dr);
  }
  return len;
}

function pathToTileSequence(path: Path): Coord[] {
  if (path.length === 0) return [];
  const tiles: Coord[] = [];
  const seen = new Set<number>();

  const add = (col: number, row: number) => {
    const key = row * 10000 + col;
    if (!seen.has(key)) {
      seen.add(key);
      tiles.push({ col, row });
    }
  };

  for (let s = 0; s < path.length - 1; s++) {
    const a = path[s];
    const b = path[s + 1];
    const x0 = a.col + 0.5;
    const y0 = a.row + 0.5;
    const x1 = b.col + 0.5;
    const y1 = b.row + 0.5;
    const dx = x1 - x0;
    const dy = y1 - y0;
    const stepX = dx > 0 ? 1 : -1;
    const stepY = dy > 0 ? 1 : -1;
    const tDeltaX = dx !== 0 ? Math.abs(1 / dx) : Infinity;
    const tDeltaY = dy !== 0 ? Math.abs(1 / dy) : Infinity;
    let tMaxX =
      dx !== 0
        ? Math.abs((Math.floor(x0) + (dx > 0 ? 1 : 0) - x0) / dx)
        : Infinity;
    let tMaxY =
      dy !== 0
        ? Math.abs((Math.floor(y0) + (dy > 0 ? 1 : 0) - y0) / dy)
        : Infinity;
    let col = Math.floor(x0);
    let row = Math.floor(y0);
    const endCol = Math.floor(x1);
    const endRow = Math.floor(y1);

    while (true) {
      add(col, row);
      if (col === endCol && row === endRow) break;
      if (tMaxX < tMaxY) {
        tMaxX += tDeltaX;
        col += stepX;
      } else if (tMaxY < tMaxX) {
        tMaxY += tDeltaY;
        row += stepY;
      } else {
        tMaxX += tDeltaX;
        tMaxY += tDeltaY;
        col += stepX;
        row += stepY;
      }
    }
  }

  return tiles;
}

function drawPath(g: Graphics, path: Path, color: number): void {
  if (path.length < 2) return;

  const first = tileCenter(path[0].col, path[0].row);
  g.moveTo(first.x, first.y);
  for (let i = 1; i < path.length; i++) {
    const { x, y } = tileCenter(path[i].col, path[i].row);
    g.lineTo(x, y);
  }
  g.stroke({ color, width: PATH_LINE_W });

  const last = tileCenter(path[path.length - 1].col, path[path.length - 1].row);
  const prev = tileCenter(path[path.length - 2].col, path[path.length - 2].row);
  const dx = last.x - prev.x;
  const dy = last.y - prev.y;
  const dist = Math.sqrt(dx * dx + dy * dy);
  const ux = dx / dist;
  const uy = dy / dist;
  const px = -uy * (PATH_HEAD_SIZE * 0.5);
  const py = ux * (PATH_HEAD_SIZE * 0.5);
  const base = {
    x: last.x - ux * PATH_HEAD_SIZE,
    y: last.y - uy * PATH_HEAD_SIZE,
  };
  g.moveTo(last.x, last.y);
  g.lineTo(base.x + px, base.y + py);
  g.lineTo(base.x - px, base.y - py);
  g.fill(color);
}

type Vec2 = { x: number; y: number };

function drawPixelPath(g: Graphics, pts: Vec2[], color: number): void {
  if (pts.length < 2) return;
  g.moveTo(pts[0].x, pts[0].y);
  for (let i = 1; i < pts.length; i++) g.lineTo(pts[i].x, pts[i].y);
  g.stroke({ color, width: PATH_LINE_W });
  const last = pts[pts.length - 1];
  const prev = pts[pts.length - 2];
  const dx = last.x - prev.x;
  const dy = last.y - prev.y;
  const dist = Math.sqrt(dx * dx + dy * dy);
  const ux = dx / dist;
  const uy = dy / dist;
  const px = -uy * (PATH_HEAD_SIZE * 0.5);
  const py = ux * (PATH_HEAD_SIZE * 0.5);
  const bx = last.x - ux * PATH_HEAD_SIZE;
  const by = last.y - uy * PATH_HEAD_SIZE;
  g.moveTo(last.x, last.y);
  g.lineTo(bx + px, by + py);
  g.lineTo(bx - px, by - py);
  g.fill(color);
}

interface Portal {
  left: Vec2;
  right: Vec2;
}

function extractPortals(path: Path, r: number): Portal[] {
  // Each adjacent tile pair shares a grid edge — the portal the unit crosses.
  // "left" and "right" are defined looking from path[i] toward path[i+1].
  const portals: Portal[] = [];
  const ox = MAP_OFFSET_X;
  const oy = MAP_OFFSET_Y;
  const ts = TILE_SIZE;

  for (let i = 0; i < path.length - 1; i++) {
    const dc = path[i + 1].col - path[i].col;
    const dr = path[i + 1].row - path[i].row;
    // Diagonal step (corner-touching) has no meaningful portal — skip.
    if (Math.abs(dc) + Math.abs(dr) !== 1) continue;
    const c = path[i].col;
    const row = path[i].row;

    let left: Vec2, right: Vec2;
    if (dc === 1) {
      // moving right: portal is left edge of next tile (vertical)
      const x = ox + (c + 1) * ts;
      left = { x, y: oy + row * ts + r };
      right = { x, y: oy + (row + 1) * ts - r };
    } else if (dc === -1) {
      // moving left: portal is right edge of next tile (vertical)
      const x = ox + c * ts;
      left = { x, y: oy + (row + 1) * ts - r };
      right = { x, y: oy + row * ts + r };
    } else if (dr === 1) {
      // moving down: portal is top edge of next tile (horizontal)
      const y = oy + (row + 1) * ts;
      left = { x: ox + (c + 1) * ts - r, y };
      right = { x: ox + c * ts + r, y };
    } else {
      // moving up: portal is bottom edge of next tile (horizontal)
      const y = oy + row * ts;
      left = { x: ox + c * ts + r, y };
      right = { x: ox + (c + 1) * ts - r, y };
    }
    portals.push({ left, right });
  }
  return portals;
}

function cross2(o: Vec2, a: Vec2, b: Vec2): number {
  return (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x);
}

function funnelPath(start: Vec2, goal: Vec2, portals: Portal[]): Vec2[] {
  // Simple Stupid Funnel Algorithm (Mikko Mononen)
  const result: Vec2[] = [start];
  if (portals.length === 0) {
    result.push(goal);
    return result;
  }

  let apexIdx = -1; // index into portals where current apex was set (-1 = start)
  let apex = start;
  let leftV = portals[0].left;
  let rightV = portals[0].right;
  let leftIdx = 0;
  let rightIdx = 0;

  for (let i = 1; i <= portals.length; i++) {
    const newLeft = i < portals.length ? portals[i].left : goal;
    const newRight = i < portals.length ? portals[i].right : goal;

    // Tighten right
    if (cross2(apex, rightV, newRight) <= 0) {
      if (apex === rightV || cross2(apex, leftV, newRight) > 0) {
        rightV = newRight;
        rightIdx = i;
      } else {
        // Right crossed left — emit left as waypoint, restart from there
        result.push(leftV);
        apex = leftV;
        apexIdx = leftIdx;
        leftV = apex;
        rightV = apex;
        leftIdx = apexIdx;
        rightIdx = apexIdx;
        // Restart scan from apexIdx + 1
        i = apexIdx;
        continue;
      }
    }

    // Tighten left
    if (cross2(apex, leftV, newLeft) >= 0) {
      if (apex === leftV || cross2(apex, rightV, newLeft) < 0) {
        leftV = newLeft;
        leftIdx = i;
      } else {
        result.push(rightV);
        apex = rightV;
        apexIdx = rightIdx;
        leftV = apex;
        rightV = apex;
        leftIdx = apexIdx;
        rightIdx = apexIdx;
        i = apexIdx;
        continue;
      }
    }
  }

  result.push(goal);
  return result;
}

function worldToIndexedPng(world: World): ArrayBuffer {
  const rgba = new Uint8Array(world.cols * world.rows * 4);
  for (let i = 0; i < world.tiles.length; i++) {
    const { r, g, b } = tileToRgb(world.tiles[i] as Tile);
    rgba[i * 4 + 0] = r;
    rgba[i * 4 + 1] = g;
    rgba[i * 4 + 2] = b;
    rgba[i * 4 + 3] = 255;
  }
  return UPNG.encode([rgba.buffer], world.cols, world.rows, 0, []);
}

function worldFromIndexedPng(buffer: ArrayBuffer): World | null {
  try {
    const img = UPNG.decode(buffer);
    const rgba = new Uint8Array(UPNG.toRGBA8(img)[0]);
    const world = makeWorld(img.width, img.height);
    const area = img.width * img.height;
    for (let i = 0; i < area; i++) {
      world.tiles[i] = rgbToTile(rgba[i * 4], rgba[i * 4 + 1], rgba[i * 4 + 2]);
    }
    return world;
  } catch {
    return null;
  }
}

function mountIoButtons(
  getWorld: () => World,
  timeLabel: Text,
  onLoad: (loaded: World) => void,
): void {
  const wrap = document.createElement("div");
  wrap.style.cssText =
    "position:fixed;top:16px;right:16px;display:flex;flex-direction:column;gap:8px;";

  const btnStyle =
    "padding:6px 12px;background:#333;color:#ccc;border:1px solid #666;cursor:pointer;font-family:monospace;font-size:13px;";

  const dlBtn = document.createElement("button");
  dlBtn.textContent = "Download PNG";
  dlBtn.style.cssText = btnStyle;
  dlBtn.addEventListener("click", () => {
    const buf = worldToIndexedPng(getWorld());
    const blob = new Blob([buf], { type: "image/png" });
    const url = URL.createObjectURL(blob);
    const a = document.createElement("a");
    a.href = url;
    a.download = "world.png";
    a.click();
    URL.revokeObjectURL(url);
  });

  const applyWorld = (buf: ArrayBuffer, label: string) => {
    const loaded = worldFromIndexedPng(buf);
    if (!loaded) {
      timeLabel.text = `${label} failed: not a valid indexed PNG`;
      return;
    }
    onLoad(loaded);
  };

  const rtBtn = document.createElement("button");
  rtBtn.textContent = "Round-trip";
  rtBtn.style.cssText = btnStyle;
  rtBtn.addEventListener("click", () =>
    applyWorld(worldToIndexedPng(getWorld()), "Round-trip"),
  );

  const fileInput = document.createElement("input");
  fileInput.type = "file";
  fileInput.accept = ".png,image/png";
  fileInput.style.display = "none";
  fileInput.addEventListener("change", () => {
    const file = fileInput.files?.[0];
    if (!file) return;
    file.arrayBuffer().then((buf) => applyWorld(buf, "Upload"));
    fileInput.value = "";
  });

  const ulBtn = document.createElement("button");
  ulBtn.textContent = "Upload PNG";
  ulBtn.style.cssText = btnStyle;
  ulBtn.addEventListener("click", () => fileInput.click());

  wrap.appendChild(dlBtn);
  wrap.appendChild(rtBtn);
  wrap.appendChild(ulBtn);
  wrap.appendChild(fileInput);

  const mapEntries = Object.entries(stockMaps)
    .map(([path, url]) => {
      const name = path.replace(/^.*\//, "").replace(/\.png$/, "");
      return { name, url };
    })
    .sort((a, b) => a.name.localeCompare(b.name));

  if (mapEntries.length > 0) {
    const sep = document.createElement("hr");
    sep.style.cssText = "border:none;border-top:1px solid #666;margin:4px 0;";
    wrap.appendChild(sep);

    const loadMap = (url: string, label: string) =>
      fetch(url)
        .then((r) => r.arrayBuffer())
        .then((buf) => applyWorld(buf, label));

    for (const { name, url } of mapEntries) {
      const btn = document.createElement("button");
      btn.textContent = name;
      btn.style.cssText = btnStyle;
      btn.addEventListener("click", () => loadMap(url, name));
      wrap.appendChild(btn);
    }

    const hash = location.hash.replace(/^#/, "");
    const match = mapEntries.find((e) => e.name === hash);
    if (match) loadMap(match.url, match.name);
  }

  document.body.appendChild(wrap);
}

async function setup() {
  const app = new Application();
  await app.init({ background: "#111111", resizeTo: window, antialias: true });
  document.getElementById("pixi-container")!.appendChild(app.canvas);
  return app;
}

function makeTileTextures(app: Application): Record<Tile, Texture> {
  const wallGfx = new Graphics()
    .rect(0, 0, TILE_SIZE, TILE_SIZE)
    .fill(0x444444);
  const groundGfx = new Graphics()
    .rect(0, 0, TILE_SIZE, TILE_SIZE)
    .fill(0x222222)
    .rect(0, 0, TILE_SIZE, TILE_SIZE)
    .stroke({ color: 0x1a1a1a, width: 1, alignment: 1 });
  return {
    [Tile.Wall]: app.renderer.generateTexture(wallGfx),
    [Tile.Ground]: app.renderer.generateTexture(groundGfx),
  };
}

function pixelToTile(world: World, px: number, py: number): Coord | null {
  const col = Math.floor((px - MAP_OFFSET_X) / TILE_SIZE);
  const row = Math.floor((py - MAP_OFFSET_Y) / TILE_SIZE);
  if (!inBounds(world, col, row)) return null;
  return { col, row };
}

(async () => {
  const app = await setup();
  const textures = makeTileTextures(app);

  const map0Buf = await fetch(stockMaps["./maps/map0.png"]).then((r) =>
    r.arrayBuffer(),
  );
  let world = worldFromIndexedPng(map0Buf) ?? makeWorld(1, 1);

  // --- Containers ---
  const mapContainer = new Container();
  const overlayContainer = new Container();
  const belowMapContainer = new Container();
  belowMapContainer.x = MAP_OFFSET_X;
  app.stage.addChild(mapContainer);
  app.stage.addChild(overlayContainer);
  app.stage.addChild(belowMapContainer);

  // --- Map container contents ---
  let startCoord: Coord | null = null;
  let goalCoord: Coord | null = null;
  type Mode = "route" | "edit";
  let mode: Mode = "route";
  let tileSprites: Sprite[][] = [];

  function onPointerDown(event: import("pixi.js").FederatedPointerEvent): void {
    const pos = event.getLocalPosition(app.stage);
    const coord = pixelToTile(world, pos.x, pos.y);
    if (!coord) return;
    if (mode === "edit") {
      applyEditAt(
        coord.col,
        coord.row,
        event.button === 0 ? Tile.Wall : Tile.Ground,
      );
      return;
    }
    if (getTile(world, coord.col, coord.row) === Tile.Wall) return;
    if (event.button === 0) {
      startCoord = coord;
    } else if (event.button === 2) {
      goalCoord = coord;
    }
    redrawOverlay();
  }

  function onPointerMove(event: import("pixi.js").FederatedPointerEvent): void {
    if (mode !== "edit" || event.buttons === 0) return;
    const pos = event.getLocalPosition(app.stage);
    const coord = pixelToTile(world, pos.x, pos.y);
    if (!coord) return;
    applyEditAt(
      coord.col,
      coord.row,
      event.buttons & 1 ? Tile.Wall : Tile.Ground,
    );
  }

  function applyEditAt(col: number, row: number, tile: Tile): void {
    if (world.tiles[row * world.cols + col] === tile) return;
    world.tiles[row * world.cols + col] = tile;
    tileSprites[row][col].texture = textures[tile];
    redrawOverlay();
  }

  function buildMap(): void {
    for (const child of mapContainer.removeChildren()) child.destroy();
    tileSprites = Array.from(
      { length: world.rows },
      () => new Array<Sprite>(world.cols),
    );
    for (let row = 0; row < world.rows; row++) {
      for (let col = 0; col < world.cols; col++) {
        const sprite = new Sprite(textures[getTile(world, col, row)]);
        sprite.x = MAP_OFFSET_X + col * TILE_SIZE;
        sprite.y = MAP_OFFSET_Y + row * TILE_SIZE;
        tileSprites[row][col] = sprite;
        mapContainer.addChild(sprite);
      }
    }

    const mapW = world.cols * TILE_SIZE;
    const mapH = world.rows * TILE_SIZE;

    const border = new Graphics()
      .rect(MAP_OFFSET_X, MAP_OFFSET_Y, mapW, mapH)
      .stroke({ width: 1, color: 0x888888 });
    mapContainer.addChild(border);

    const hit = new Graphics()
      .rect(MAP_OFFSET_X, MAP_OFFSET_Y, mapW, mapH)
      .fill({ color: 0x000000, alpha: 0 });
    hit.eventMode = "static";
    hit.cursor = "pointer";
    hit.on("pointerdown", onPointerDown);
    hit.on("pointermove", onPointerMove);
    mapContainer.addChild(hit);

    belowMapContainer.y = Math.round(MAP_OFFSET_Y + mapH + 8);
  }

  buildMap();

  // --- Below-map container: stats + legend ---
  const LEGEND_LOCAL_X = 340;
  const LEGEND_ROW = 20;
  const LEGEND_LINE_LEN = 20;
  const LEGEND_LINE_MID_Y = 7;

  const legendLines = [
    { color: PATH_COLOR, label: "a*" },
    { color: THETA_COLOR, label: "θ*" },
    { color: SMOOTH_COLOR, label: "string-pull" },
    { color: FUNNEL_COLOR, label: "a* + funnel" },
    { color: THETA_FUNNEL_COLOR, label: "θ* + funnel" },
  ];
  const markerItems = [
    { color: START_COLOR, label: "start" },
    { color: GOAL_COLOR, label: "goal" },
  ];

  const timeLabel = new Text({
    text: "",
    style: new TextStyle({
      fill: 0x888888,
      fontSize: 14,
      fontFamily: "monospace",
      lineHeight: 20,
    }),
  });
  belowMapContainer.addChild(timeLabel);

  const legendContainer = new Container();
  legendContainer.x = LEGEND_LOCAL_X;
  belowMapContainer.addChild(legendContainer);

  const legendGfx = new Graphics();
  for (let i = 0; i < legendLines.length; i++) {
    const y = i * LEGEND_ROW + LEGEND_LINE_MID_Y;
    legendGfx
      .moveTo(0, y)
      .lineTo(LEGEND_LINE_LEN, y)
      .stroke({ color: legendLines[i].color, width: PATH_LINE_W });
  }
  for (let i = 0; i < markerItems.length; i++) {
    const cy = (legendLines.length + i) * LEGEND_ROW + LEGEND_LINE_MID_Y;
    legendGfx.circle(LEGEND_LINE_LEN / 2, cy, 5).fill(markerItems[i].color);
  }
  legendContainer.addChild(legendGfx);

  const legendStyle = new TextStyle({
    fill: 0x888888,
    fontSize: 14,
    fontFamily: "monospace",
  });

  // --- Overlay container: highlights + path/marker graphics ---
  const highlightGraphics = new Graphics();
  overlayContainer.addChild(highlightGraphics);

  let currentPaths: ({ path: Path; color: number } | null)[] = [
    null,
    null,
    null,
    null,
    null,
  ];

  const clearHighlight = () => {
    highlightGraphics.clear();
  };

  const showHighlight = (idx: number) => {
    highlightGraphics.clear();
    const entry = currentPaths[idx];
    if (!entry) return;
    const seq = pathToTileSequence(entry.path);
    for (const { col, row } of seq) {
      highlightGraphics
        .rect(
          MAP_OFFSET_X + col * TILE_SIZE,
          MAP_OFFSET_Y + row * TILE_SIZE,
          TILE_SIZE,
          TILE_SIZE,
        )
        .fill({ color: entry.color, alpha: 0.18 });
    }
  };

  for (let i = 0; i < legendLines.length; i++) {
    const lbl = new Text({ text: legendLines[i].label, style: legendStyle });
    lbl.x = Math.round(LEGEND_LINE_LEN + 8);
    lbl.y = Math.round(i * LEGEND_ROW);
    lbl.eventMode = "static";
    lbl.cursor = "pointer";
    const idx = i;
    lbl.on("pointerover", () => showHighlight(idx));
    lbl.on("pointerout", clearHighlight);
    legendContainer.addChild(lbl);
  }
  for (let i = 0; i < markerItems.length; i++) {
    const lbl = new Text({ text: markerItems[i].label, style: legendStyle });
    lbl.x = Math.round(LEGEND_LINE_LEN + 8);
    lbl.y = Math.round((legendLines.length + i) * LEGEND_ROW);
    legendContainer.addChild(lbl);
  }

  // --- Mode buttons (Route / Edit) ---
  const modeContainer = new Container();
  modeContainer.x = LEGEND_LOCAL_X + 160;
  belowMapContainer.addChild(modeContainer);

  const modeEntries: { m: Mode; label: string }[] = [
    { m: "route", label: "Route" },
    { m: "edit", label: "Edit" },
  ];
  const modeActiveStyle = new TextStyle({
    fill: 0xdddddd,
    fontSize: 14,
    fontFamily: "monospace",
  });
  const modeInactiveStyle = new TextStyle({
    fill: 0x555555,
    fontSize: 14,
    fontFamily: "monospace",
  });

  const modeBtns = modeEntries.map(({ m, label }, i) => {
    const btn = new Text({
      text: label,
      style: m === mode ? modeActiveStyle : modeInactiveStyle,
    });
    btn.y = i * LEGEND_ROW;
    btn.eventMode = "static";
    btn.cursor = "pointer";
    modeContainer.addChild(btn);
    return btn;
  });
  const setMode = (m: Mode) => {
    mode = m;
    for (let j = 0; j < modeBtns.length; j++) {
      modeBtns[j].style =
        modeEntries[j].m === mode ? modeActiveStyle : modeInactiveStyle;
    }
  };
  modeBtns.forEach((btn, i) =>
    btn.on("pointerdown", () => setMode(modeEntries[i].m)),
  );

  // Overlay layer for markers and path (redrawn on each click)
  let overlayGraphics: Graphics | null = null;

  function redrawOverlay(): void {
    if (overlayGraphics) {
      overlayContainer.removeChild(overlayGraphics);
      overlayGraphics.destroy();
      overlayGraphics = null;
    }

    const g = new Graphics();
    overlayGraphics = g;

    if (startCoord) {
      const { x, y } = tileCenter(startCoord.col, startCoord.row);
      g.circle(x, y, MARKER_R).fill(START_COLOR);
    }

    if (goalCoord) {
      const { x, y } = tileCenter(goalCoord.col, goalCoord.row);
      g.circle(x, y, MARKER_R).fill(GOAL_COLOR);
    }

    clearHighlight();
    overlayContainer.addChild(g);

    if (startCoord && goalCoord) {
      const t0 = performance.now();
      const path = findPath(world, startCoord, goalCoord);
      const t1 = performance.now();
      const thetaPath = findPathTheta(world, startCoord, goalCoord);
      const t2 = performance.now();

      if (path) {
        const smoothed = smoothPath(world, path);
        const t3 = performance.now();

        const startPx = tileCenter(startCoord.col, startCoord.row);
        const goalPx = tileCenter(goalCoord.col, goalCoord.row);

        const t4 = performance.now();
        const astarPortals = extractPortals(path, UNIT_RADIUS);
        const funnelPts = funnelPath(startPx, goalPx, astarPortals);
        const t5 = performance.now();

        const thetaSeq = thetaPath ? pathToTileSequence(thetaPath) : null;
        const thetaPortals = thetaSeq
          ? extractPortals(thetaSeq, UNIT_RADIUS)
          : null;
        const thetaFunnelPts = thetaPortals
          ? funnelPath(startPx, goalPx, thetaPortals)
          : null;
        const t6 = performance.now();

        currentPaths = [
          { path, color: PATH_COLOR },
          thetaPath ? { path: thetaPath, color: THETA_COLOR } : null,
          { path: smoothed, color: SMOOTH_COLOR },
          { path, color: FUNNEL_COLOR },
          thetaPath ? { path: thetaPath, color: THETA_FUNNEL_COLOR } : null,
        ];

        const pixelPathLen = (pts: Vec2[]) =>
          pts.reduce((sum, pt, i) => {
            if (i === 0) return 0;
            const dx = pt.x - pts[i - 1].x;
            const dy = pt.y - pts[i - 1].y;
            return sum + Math.sqrt(dx * dx + dy * dy) / TILE_SIZE;
          }, 0);

        const gridLen = pathLength(path);
        const smoothLen = pathLength(smoothed);
        const thetaLen = thetaPath ? pathLength(thetaPath) : null;
        const funnelLen = pixelPathLen(funnelPts);
        const thetaFunnelLen = thetaFunnelPts
          ? pixelPathLen(thetaFunnelPts)
          : null;
        const dc = goalCoord.col - startCoord.col;
        const dr = goalCoord.row - startCoord.row;
        const straight = Math.sqrt(dc * dc + dr * dr);

        timeLabel.text = [
          `a*:             ${(t1 - t0).toFixed(3)} ms  ${gridLen.toFixed(2)} tiles`,
          `θ*:             ${(t2 - t1).toFixed(3)} ms  ${thetaLen !== null ? thetaLen.toFixed(2) : ""} tiles`,
          `string-pull:    ${(t3 - t2).toFixed(3)} ms  ${smoothLen.toFixed(2)} tiles`,
          `a* + funnel:    ${(t5 - t4).toFixed(3)} ms  ${funnelLen.toFixed(2)} tiles`,
          `θ* + funnel:    ${(t6 - t5).toFixed(3)} ms  ${thetaFunnelLen !== null ? thetaFunnelLen.toFixed(2) : ""} tiles`,
          `straight:                     ${straight.toFixed(2)} tiles`,
        ].join("\n");

        drawPath(g, path, PATH_COLOR);
        if (thetaPath) drawPath(g, thetaPath, THETA_COLOR);
        drawPath(g, smoothed, SMOOTH_COLOR);
        drawPixelPath(g, funnelPts, FUNNEL_COLOR);
        if (thetaFunnelPts)
          drawPixelPath(g, thetaFunnelPts, THETA_FUNNEL_COLOR);
      } else {
        currentPaths = [null, null, null];
        timeLabel.text = `a*: ${(t1 - t0).toFixed(3)} ms  no path found`;
      }
    }
  }

  // Prevent context menu on right click
  app.canvas.addEventListener("contextmenu", (e) => e.preventDefault());

  mountIoButtons(
    () => world,
    timeLabel,
    (loaded) => {
      world = loaded;
      startCoord = null;
      goalCoord = null;
      buildMap();
      redrawOverlay();
    },
  );
})();