import {
  Application,
  Container,
  Graphics,
  Rectangle,
  Sprite,
  Text,
  TextStyle,
  Texture,
} from "pixi.js";
import { Tilemap } from "@pixi/tilemap";

type RGB = [number, number, number];

const TILE_SIZE = 36;
const GRID = 20;
const STRIP_TILE = 28;
const NUM_LEVELS = 18;
const NOISE_DENSITY = 0.1;
const COLOR_OFFSET = 0.15;
const MARKER_RADIUS = 6;

const GROUND_LOW: RGB = [30, 22, 12];
const GROUND_HIGH: RGB = [150, 140, 120];
const WATER_SHALLOW: RGB = [80, 150, 210];
const WATER_DEEP: RGB = [10, 25, 90];

const FLOW_RATE = 0.15;
const SOURCE_RATE = 0.05;
const SOURCE_ROW = 10;
const SOURCE_COL = 5;
const WATER_VISIBLE_THRESHOLD = 0.01;
const MAX_TERRAIN = 7;
const WATER_VISIBILITY_DEPTH = 3;
const SENSOR_THRESHOLD = 0.05;

function lerpChannel(a: number, b: number, t: number): number {
  return Math.round(a + (b - a) * t);
}

function lerpColor(a: RGB, b: RGB, t: number): RGB {
  return [
    lerpChannel(a[0], b[0], t),
    lerpChannel(a[1], b[1], t),
    lerpChannel(a[2], b[2], t),
  ];
}

function levelIndex(value: number, max: number): number {
  return Math.min(
    NUM_LEVELS - 1,
    Math.max(0, Math.round((value / max) * (NUM_LEVELS - 1))),
  );
}

/**
 * Packs named groups of same-sized textures into a single atlas.
 * Returns the atlas texture and a record of texture arrays keyed by group name,
 * where each texture is a sub-rectangle of the atlas.
 */
function buildAtlas(
  app: Application,
  groups: Record<string, Texture[]>,
  tileSize: number,
): { atlas: Texture; textures: Record<string, Texture[]> } {
  const entries = Object.entries(groups);
  const totalTiles = entries.reduce((sum, [, texs]) => sum + texs.length, 0);
  const cols = Math.ceil(Math.sqrt(totalTiles));
  const rows = Math.ceil(totalTiles / cols);

  const container = new Container();
  const layout: { key: string; index: number; col: number; row: number }[] = [];
  let tileIndex = 0;

  for (const [key, texs] of entries) {
    for (let i = 0; i < texs.length; i++) {
      const col = tileIndex % cols;
      const row = Math.floor(tileIndex / cols);
      const sprite = new Sprite(texs[i]);
      sprite.x = col * tileSize;
      sprite.y = row * tileSize;
      container.addChild(sprite);
      layout.push({ key, index: i, col, row });
      tileIndex++;
    }
  }

  const atlas = app.renderer.generateTexture({
    target: container,
    resolution: 1,
    frame: new Rectangle(0, 0, cols * tileSize, rows * tileSize),
  });

  const result: Record<string, Texture[]> = {};
  for (const { key, index, col, row } of layout) {
    if (!result[key]) result[key] = [];
    result[key][index] = new Texture({
      source: atlas.source,
      frame: new Rectangle(col * tileSize, row * tileSize, tileSize, tileSize),
    });
  }

  return { atlas, textures: result };
}

function makeNoiseTileTexture(bg: RGB, fg: RGB, size: number): Texture {
  const canvas = document.createElement("canvas");
  canvas.width = size;
  canvas.height = size;
  const ctx = canvas.getContext("2d")!;
  const imageData = ctx.createImageData(size, size);
  const d = imageData.data;
  for (let i = 0; i < size * size; i++) {
    const c = Math.random() < NOISE_DENSITY ? fg : bg;
    d[i * 4] = c[0];
    d[i * 4 + 1] = c[1];
    d[i * 4 + 2] = c[2];
    d[i * 4 + 3] = 255;
  }
  ctx.putImageData(imageData, 0, 0);
  return Texture.from(canvas);
}

function makeGroundNoise(size: number): Texture[] {
  return Array.from({ length: NUM_LEVELS }, (_, i) => {
    const t = i / (NUM_LEVELS - 1);
    const bg = lerpColor(GROUND_LOW, GROUND_HIGH, t);
    const fg = lerpColor(
      GROUND_LOW,
      GROUND_HIGH,
      Math.min(1, t + COLOR_OFFSET),
    );
    return makeNoiseTileTexture(bg, fg, size);
  });
}

function makeWaterNoise(size: number): Texture[] {
  return Array.from({ length: NUM_LEVELS }, (_, i) => {
    const t = i / (NUM_LEVELS - 1);
    const bg = lerpColor(WATER_SHALLOW, WATER_DEEP, t);
    const fg = lerpColor(
      WATER_SHALLOW,
      WATER_DEEP,
      Math.max(0, t - COLOR_OFFSET),
    );
    return makeNoiseTileTexture(bg, fg, size);
  });
}

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;
}

const TEXT_STYLE = new TextStyle({
  fill: 0xffffff,
  fontSize: 11,
  fontFamily: "monospace",
  lineHeight: 16,
});

function drawStrips(app: Application) {
  const LEFT = 20;
  const LABEL_W = 52;
  const ROW_H = STRIP_TILE + 4;

  const rows = [
    { label: "ground", tiles: makeGroundNoise(STRIP_TILE) },
    { label: "water", tiles: makeWaterNoise(STRIP_TILE) },
  ];

  rows.forEach(({ label, tiles }, row) => {
    const y = 8 + row * ROW_H;
    const lbl = new Text({ text: label, style: TEXT_STYLE });
    lbl.x = LEFT;
    lbl.y = Math.round(y + (STRIP_TILE - 12) / 2);
    app.stage.addChild(lbl);
    tiles.forEach((tex, i) => {
      const s = new Sprite(tex);
      s.x = LEFT + LABEL_W + i * STRIP_TILE;
      s.y = y;
      app.stage.addChild(s);
    });
  });

  const topBottom = 8 + rows.length * ROW_H + 10;

  return { LEFT, topBottom };
}

function generateTerrain(): number[][] {
  const lc = { r: GRID / 2, c: GRID * 0.25 };
  const rc = { r: GRID / 2, c: GRID * 0.75 };
  const sigma = 3.5;
  const grid = Array.from({ length: GRID }, (_, r) =>
    Array.from({ length: GRID }, (_, c) => {
      const dL = Math.sqrt((r - lc.r) ** 2 + (c - lc.c) ** 2);
      const dR = Math.sqrt((r - rc.r) ** 2 + (c - rc.c) ** 2);
      return (
        1 +
        6 * Math.tanh(Math.min(dL, dR) / sigma) +
        (Math.random() - 0.5) * 0.15
      );
    }),
  );

  const channelRow = 10;
  const channelDepth = 1.2;
  for (let c = Math.floor(GRID * 0.25); c <= Math.ceil(GRID * 0.75); c++) {
    grid[channelRow][c] = Math.max(0, grid[channelRow][c] - channelDepth);
  }

  return grid;
}

function makeWaterGrid(): number[][] {
  return Array.from({ length: GRID }, () => Array(GRID).fill(0));
}

function simStep(terrain: number[][], water: number[][]) {
  water[SOURCE_ROW][SOURCE_COL] += SOURCE_RATE;

  const delta = makeWaterGrid();

  for (let r = 0; r < GRID; r++) {
    for (let c = 0; c < GRID; c++) {
      if (water[r][c] <= 0) continue;
      const surface = terrain[r][c] + water[r][c];

      const neighbors: [number, number][] = [];
      if (r > 0) neighbors.push([r - 1, c]);
      if (r < GRID - 1) neighbors.push([r + 1, c]);
      if (c > 0) neighbors.push([r, c - 1]);
      if (c < GRID - 1) neighbors.push([r, c + 1]);

      let totalDiff = 0;
      const diffs: number[] = [];
      for (const [nr, nc] of neighbors) {
        const nSurface = terrain[nr][nc] + water[nr][nc];
        const diff = Math.max(0, surface - nSurface);
        diffs.push(diff);
        totalDiff += diff;
      }

      if (totalDiff <= 0) continue;

      const maxFlow = Math.min(water[r][c], totalDiff * FLOW_RATE);
      for (let i = 0; i < neighbors.length; i++) {
        const flow = maxFlow * (diffs[i] / totalDiff);
        const [nr, nc] = neighbors[i];
        delta[r][c] -= flow;
        delta[nr][nc] += flow;
      }
    }
  }

  for (let r = 0; r < GRID; r++) {
    for (let c = 0; c < GRID; c++) {
      water[r][c] = Math.max(0, water[r][c] + delta[r][c]);
    }
  }
}

function renderTilemap(
  tilemap: Tilemap,
  groundTex: Texture[],
  waterTex: Texture[],
  terrain: number[][],
  water: number[][],
) {
  tilemap.clear();
  for (let row = 0; row < GRID; row++) {
    for (let col = 0; col < GRID; col++) {
      const px = col * TILE_SIZE;
      const py = row * TILE_SIZE;
      const w = water[row][col];

      if (w > WATER_VISIBLE_THRESHOLD) {
        tilemap.tile(waterTex[levelIndex(w, WATER_VISIBILITY_DEPTH)], px, py);
      } else {
        tilemap.tile(
          groundTex[levelIndex(terrain[row][col], MAX_TERRAIN)],
          px,
          py,
        );
      }
    }
  }
}

(async () => {
  const app = await setup();
  const { LEFT, topBottom } = drawStrips(app);

  const { atlas, textures: atlasTextures } = buildAtlas(
    app,
    {
      ground: makeGroundNoise(TILE_SIZE),
      water: makeWaterNoise(TILE_SIZE),
    },
    TILE_SIZE,
  );
  const { ground: groundTex, water: waterTex } = atlasTextures;
  const terrain = generateTerrain();
  const water = makeWaterGrid();

  const mapX = LEFT;
  const mapY = topBottom;

  const tilemap = new Tilemap(atlas.source);
  tilemap.x = mapX;
  tilemap.y = mapY;
  app.stage.addChild(tilemap);

  const sourceMarker = new Graphics();
  sourceMarker
    .circle(0, 0, MARKER_RADIUS)
    .fill(0x00aaff)
    .stroke({ width: 1, color: 0xffffff });
  sourceMarker.x = mapX + SOURCE_COL * TILE_SIZE + TILE_SIZE / 2;
  sourceMarker.y = mapY + SOURCE_ROW * TILE_SIZE + TILE_SIZE / 2;
  app.stage.addChild(sourceMarker);

  const sensorCells: [number, number][] = [
    [0, 0],
    [0, GRID - 1],
    [GRID - 1, 0],
    [GRID - 1, GRID - 1],
  ];
  const sensorMarkers = sensorCells.map(([r, c]) => {
    const marker = new Graphics();
    marker
      .circle(0, 0, MARKER_RADIUS)
      .fill(0xff4444)
      .stroke({ width: 1, color: 0xffffff });
    marker.x = mapX + c * TILE_SIZE + TILE_SIZE / 2;
    marker.y = mapY + r * TILE_SIZE + TILE_SIZE / 2;
    app.stage.addChild(marker);
    return marker;
  });

  renderTilemap(tilemap, groundTex, waterTex, terrain, water);

  const totalTiles = NUM_LEVELS * 2;
  const atlasCols = Math.ceil(Math.sqrt(totalTiles));
  const atlasRows = Math.ceil(totalTiles / atlasCols);
  const atlasW = atlasCols * TILE_SIZE;
  const atlasH = atlasRows * TILE_SIZE;
  const atlasKB = ((atlasW * atlasH * 4) / 1024).toFixed(1);

  const TARGET_FPS = 60;
  const TARGET_MS = 1000 / TARGET_FPS;
  const BAR_WIDTH = GRID * TILE_SIZE;
  const BAR_HEIGHT = 14;
  const BAR_X = mapX;
  const BAR_Y = mapY + GRID * TILE_SIZE + 12;
  const SCALE_Y = BAR_Y + BAR_HEIGHT + 4;
  const MS_PER_PX = BAR_WIDTH / TARGET_MS;
  const EMA_WINDOW = 60;
  const EMA_ALPHA = 2 / (EMA_WINDOW + 1);

  const statsText = new Text({ text: "", style: TEXT_STYLE });
  statsText.x = BAR_X + BAR_WIDTH + 16;
  statsText.y = 8;
  app.stage.addChild(statsText);

  const timingBar = new Graphics();
  timingBar.x = BAR_X;
  timingBar.y = BAR_Y;
  app.stage.addChild(timingBar);

  const scaleBar = new Graphics();
  scaleBar
    .rect(BAR_X, SCALE_Y, BAR_WIDTH, 2)
    .fill(0x888888)
    .rect(BAR_X, SCALE_Y - 2, 1, 6)
    .fill(0x888888)
    .rect(BAR_X + BAR_WIDTH, SCALE_Y - 2, 1, 6)
    .fill(0x888888);
  app.stage.addChild(scaleBar);

  const scaleLabel = new Text({
    text: `${TARGET_MS.toFixed(1)}ms (${TARGET_FPS}fps)`,
    style: TEXT_STYLE,
  });
  scaleLabel.x = BAR_X + BAR_WIDTH + 4;
  scaleLabel.y = SCALE_Y - 5;
  app.stage.addChild(scaleLabel);

  const atlasDisplayY = SCALE_Y + 20;
  const atlasSprite = new Sprite(atlas);
  atlasSprite.x = BAR_X;
  atlasSprite.y = atlasDisplayY;
  app.stage.addChild(atlasSprite);

  const atlasInfoText = new Text({
    text: [
      `Tile size:    ${TILE_SIZE}×${TILE_SIZE}px`,
      `Atlas grid:   ${atlasCols}×${atlasRows} (${totalTiles} tiles)`,
      `Atlas pixels: ${atlasW}×${atlasH}`,
      `Atlas size:   ${atlasKB} KB`,
    ].join("\n"),
    style: TEXT_STYLE,
  });
  atlasInfoText.x = BAR_X + atlasW + 12;
  atlasInfoText.y = atlasDisplayY;
  app.stage.addChild(atlasInfoText);

  let emaSim = 0;
  let emaRender = 0;
  let emaTotal = TARGET_MS;
  let lastFrameEnd = performance.now();

  let frame = 0;
  app.ticker.add(() => {
    const frameStart = performance.now();
    const totalMs = frameStart - lastFrameEnd;
    frame++;

    const simStart = performance.now();
    simStep(terrain, water);
    const simMs = performance.now() - simStart;

    const renderStart = performance.now();
    renderTilemap(tilemap, groundTex, waterTex, terrain, water);
    const renderMs = performance.now() - renderStart;

    // Reset after render so the pre-reset state is visible for one frame
    const triggered = sensorCells.some(
      ([r, c]) => water[r][c] > SENSOR_THRESHOLD,
    );
    sensorCells.forEach(([r, c], i) => {
      sensorMarkers[i].tint =
        water[r][c] > SENSOR_THRESHOLD ? 0x00ff00 : 0xffffff;
    });
    if (triggered) water.forEach((row) => row.fill(0));

    emaSim += EMA_ALPHA * (simMs - emaSim);
    emaRender += EMA_ALPHA * (renderMs - emaRender);
    emaTotal += EMA_ALPHA * (totalMs - emaTotal);

    const fps = 1000 / emaTotal;
    const margin = TARGET_MS - emaSim - emaRender;
    statsText.text = [
      `sim:    ${emaSim.toFixed(2)} ms`,
      `render: ${emaRender.toFixed(2)} ms`,
      `fps:    ${fps.toFixed(1)}`,
      `margin: ${margin.toFixed(2)} ms`,
      `tick:   ${frame}`,
    ].join("\n");

    timingBar.clear();
    timingBar.rect(0, 0, BAR_WIDTH, BAR_HEIGHT).fill(0x222222);
    let x = 0;
    timingBar.rect(x, 0, emaSim * MS_PER_PX, BAR_HEIGHT).fill(0x44aa44);
    x += emaSim * MS_PER_PX;
    timingBar.rect(x, 0, emaRender * MS_PER_PX, BAR_HEIGHT).fill(0x4488cc);

    lastFrameEnd = performance.now();
  });
})();