/**
 * Canvas renderer: procgen tile art (consistent, hash-seeded), camera,
 * sprites, particles, and the day/night color grade.
 *
 * Tiles are painted procedurally (gpt-image-2 drifts between calls, so
 * repeating ground art must be canvas-drawn); generated art is reserved for
 * the farmer, crops, trees, tools and the title plate.
 */
import { COLS, ROWS, TILE, tileHash, cropDef, tillable, houseRect, type Tile } from './world.ts';
import { targetTile, type GameState } from './game.ts';
import { particles } from './particles.ts';
import { collectEntityDraws, drawAir, drawNightGlow, dayFactor } from './ambient.ts';
import type { Assets, Sprite } from './assets.ts';

const SCALE = 2; // static layer supersample so zoom stays crisp
const TS = TILE * SCALE;
const MAP_W = COLS * TILE;
const MAP_H = ROWS * TILE;

/** forest-edge tiles painted beyond the fence so the camera can keep the
 *  player centred near world edges without showing void */
const APRON = 2;
const A_PX = APRON * TILE;
const EXP_W = MAP_W + A_PX * 2;
const EXP_H = MAP_H + A_PX * 2;

const HOUSE = houseRect();

export interface Camera {
  x: number; // world px at left edge
  y: number;
  zoom: number;
}

const PAL = {
  grass: '#6fae4e',
  grassDark: '#5f9c42',
  grassLight: '#7fbb5c',
  dirt: '#b08350',
  dirtDark: '#9a6f42',
  soil: '#7d5a38',
  soilDark: '#6a4a2d',
  soilWet: '#684527',
  soilWetDark: '#553820',
  path: '#d9bd8a',
  pathDark: '#c4a874',
  sand: '#e2cb96',
  sandDark: '#cdb074',
  water: '#4f93c4',
  waterDeep: '#3a7bb0',
  fence: '#9a6b3f',
  fenceDark: '#7d5430',
  cobble: '#b3a88f',
};

let staticLayer: HTMLCanvasElement | null = null;

// ---------------------------------------------------------------------------
// static tile layer
// ---------------------------------------------------------------------------

type Grid = Tile[][];

const isFamily = (g: Grid, x: number, y: number, fam: (t: Tile) => boolean): boolean => {
  if (x < 0 || y < 0 || x >= COLS || y >= ROWS) return true; // map edge continues
  return fam(g[y][x]);
};

/** patch with corners rounded where the patch ends (organic blob edges) */
function patch(
  ctx: CanvasRenderingContext2D, g: Grid, x: number, y: number,
  fam: (t: Tile) => boolean, color: string, inset: number, r: number,
): void {
  const n = isFamily(g, x, y - 1, fam);
  const s = isFamily(g, x, y + 1, fam);
  const w = isFamily(g, x - 1, y, fam);
  const e = isFamily(g, x + 1, y, fam);
  const px = x * TS, py = y * TS;
  const x0 = px + (w ? 0 : inset);
  const y0 = py + (n ? 0 : inset);
  const x1 = px + TS - (e ? 0 : inset);
  const y1 = py + TS - (s ? 0 : inset);
  ctx.fillStyle = color;
  ctx.beginPath();
  ctx.roundRect(x0, y0, x1 - x0, y1 - y0, [
    n || w ? 0 : r, n || e ? 0 : r, s || e ? 0 : r, s || w ? 0 : r,
  ]);
  ctx.fill();
}

function paintGrass(ctx: CanvasRenderingContext2D, x: number, y: number): void {
  const h = tileHash(x, y);
  ctx.fillStyle = PAL.grass;
  ctx.fillRect(x * TS, y * TS, TS, TS);
  // grass blades
  for (let i = 0; i < 7; i++) {
    const hx = tileHash(x * 31 + i, y * 17 + i * 3);
    const hy = tileHash(x * 13 + i * 5, y * 41 + i);
    const bx = x * TS + hx * TS;
    const by = y * TS + hy * TS;
    ctx.strokeStyle = hx > 0.5 ? 'rgba(50,100,35,.5)' : 'rgba(150,210,110,.5)';
    ctx.lineWidth = 2.5;
    ctx.beginPath();
    ctx.moveTo(bx, by);
    ctx.lineTo(bx + (hy - 0.5) * 5, by - 7 - hx * 5);
    ctx.stroke();
  }
  // occasional tiny flower
  if (h > 0.91) {
    const fx = x * TS + TS * (0.25 + tileHash(x + 7, y) * 0.5);
    const fy = y * TS + TS * (0.25 + tileHash(x, y + 7) * 0.5);
    ctx.fillStyle = h > 0.96 ? '#f5f2e3' : '#f2cf5b';
    for (let p = 0; p < 4; p++) {
      const a = (p / 4) * Math.PI * 2;
      ctx.beginPath();
      ctx.arc(fx + Math.cos(a) * 4, fy + Math.sin(a) * 4, 3.2, 0, Math.PI * 2);
      ctx.fill();
    }
    ctx.fillStyle = '#e8913f';
    ctx.beginPath();
    ctx.arc(fx, fy, 2.6, 0, Math.PI * 2);
    ctx.fill();
  }
}

function speckle(ctx: CanvasRenderingContext2D, x: number, y: number, color: string, n: number, seed: number): void {
  ctx.fillStyle = color;
  for (let i = 0; i < n; i++) {
    const hx = tileHash(x * 53 + i * 7 + seed, y * 29 + i);
    const hy = tileHash(x * 11 + i, y * 67 + i * 9 + seed);
    ctx.beginPath();
    ctx.arc(x * TS + 6 + hx * (TS - 12), y * TS + 6 + hy * (TS - 12), 1.8 + hx * 2.4, 0, Math.PI * 2);
    ctx.fill();
  }
}

const dirtFam = (t: Tile) => t.kind === 'dirt' || t.kind === 'soil';
const pathFam = (t: Tile) => t.kind === 'path' || t.kind === 'house';
const waterFam = (t: Tile) => t.kind === 'water';
const sandFam = (t: Tile) => t.kind === 'sand' || t.kind === 'water';

/** soft organic light/dark blobs across the whole map — kills the tile-grid look */
function paintMottle(ctx: CanvasRenderingContext2D): void {
  const W = COLS * TS, H = ROWS * TS;
  for (let i = 0; i < 90; i++) {
    const hx = tileHash(i * 3 + 1, i * 7 + 2);
    const hy = tileHash(i * 11 + 5, i * 13 + 3);
    const hr = tileHash(i * 17 + 9, i * 5 + 4);
    ctx.fillStyle = i % 2 ? 'rgba(70,125,45,0.16)' : 'rgba(150,205,105,0.13)';
    ctx.beginPath();
    ctx.ellipse(hx * W, hy * H, TS * (0.5 + hr * 1.6), TS * (0.4 + hr * 1.1), hr * Math.PI, 0, Math.PI * 2);
    ctx.fill();
  }
}

function paintOverlay(ctx: CanvasRenderingContext2D, g: Grid, x: number, y: number): void {
  const t = g[y][x];
  switch (t.kind) {
    case 'grass':
      break;
    case 'dirt':
      patch(ctx, g, x, y, dirtFam, PAL.dirt, TS * 0.07, TS * 0.3);
      speckle(ctx, x, y, 'rgba(122,86,48,.55)', 6, 1);
      speckle(ctx, x, y, 'rgba(220,180,130,.4)', 3, 9);
      break;
    case 'soil': {
      patch(ctx, g, x, y, dirtFam, t.watered ? PAL.soilWet : PAL.soil, TS * 0.07, TS * 0.3);
      // furrows — slightly wavy so they read as worked earth, not UI bars
      ctx.strokeStyle = t.watered ? PAL.soilWetDark : PAL.soilDark;
      ctx.lineWidth = TS * 0.055;
      ctx.lineCap = 'round';
      for (let f = 0; f < 3; f++) {
        const fy = y * TS + TS * (0.3 + f * 0.22);
        const wob = (tileHash(x * 7 + f, y * 3 + f) - 0.5) * TS * 0.05;
        ctx.beginPath();
        ctx.moveTo(x * TS + TS * 0.18, fy + wob);
        ctx.quadraticCurveTo(x * TS + TS * 0.5, fy - wob, x * TS + TS * 0.82, fy + wob);
        ctx.stroke();
      }
      speckle(ctx, x, y, t.watered ? 'rgba(40,26,16,.4)' : 'rgba(220,180,130,.35)', 3, 21);
      if (t.watered) {
        // tiny glints of moisture — keeps "wet" reading wet but still brown
        ctx.fillStyle = 'rgba(190,215,245,.3)';
        for (let i = 0; i < 4; i++) {
          const hx = tileHash(x * 19 + i * 3, y * 23 + i);
          const hy = tileHash(x * 5 + i * 11, y * 13 + i * 7);
          ctx.beginPath();
          ctx.ellipse(
            x * TS + TS * (0.2 + hx * 0.6), y * TS + TS * (0.2 + hy * 0.6),
            TS * 0.05, TS * 0.025, 0, 0, Math.PI * 2,
          );
          ctx.fill();
        }
      }
      break;
    }
    case 'path':
      patch(ctx, g, x, y, pathFam, PAL.path, TS * 0.1, TS * 0.34);
      speckle(ctx, x, y, PAL.pathDark, 5, 4);
      speckle(ctx, x, y, '#eadcb6', 3, 13);
      break;
    case 'sand':
      patch(ctx, g, x, y, sandFam, PAL.sand, TS * 0.06, TS * 0.32);
      speckle(ctx, x, y, PAL.sandDark, 5, 6);
      speckle(ctx, x, y, '#f3e6c2', 4, 17);
      // tiny shell-ish dots
      if (tileHash(x + 3, y + 5) > 0.6) {
        ctx.fillStyle = '#fdf6e0';
        ctx.beginPath();
        ctx.arc(x * TS + TS * (0.3 + tileHash(x, y + 9) * 0.4), y * TS + TS * (0.3 + tileHash(x + 9, y) * 0.4), 3, 0, Math.PI * 2);
        ctx.fill();
      }
      break;
    case 'house':
      // cobblestone footing under the farmhouse sprite
      patch(ctx, g, x, y, pathFam, PAL.cobble, TS * 0.05, TS * 0.25);
      speckle(ctx, x, y, 'rgba(90,85,70,.4)', 5, 8);
      break;
    case 'water': {
      // sandy bank then water inset — gives a soft shoreline
      patch(ctx, g, x, y, waterFam, PAL.sand, TS * 0.02, TS * 0.3);
      patch(ctx, g, x, y, waterFam, PAL.water, TS * 0.14, TS * 0.4);
      patch(ctx, g, x, y, (tt) => tt.kind === 'water' && deepWater(g, x, y), PAL.waterDeep, TS * 0.34, TS * 0.4);
      break;
    }
    case 'fence': {
      // posts + rails connecting neighbouring fence tiles
      const cx = x * TS + TS / 2;
      const cy = y * TS + TS / 2;
      const conn = (dx: number, dy: number) =>
        x + dx >= 0 && y + dy >= 0 && x + dx < COLS && y + dy < ROWS && g[y + dy][x + dx].kind === 'fence';
      ctx.strokeStyle = PAL.fenceDark;
      ctx.lineWidth = TS * 0.13;
      ctx.lineCap = 'round';
      for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]] as const) {
        if (!conn(dx, dy)) continue;
        for (const off of [-TS * 0.12, TS * 0.1]) {
          ctx.beginPath();
          ctx.moveTo(cx, cy + off);
          ctx.lineTo(cx + (dx * TS) / 2, cy + (dy * TS) / 2 + off);
          ctx.stroke();
        }
      }
      // post
      ctx.fillStyle = PAL.fence;
      ctx.strokeStyle = PAL.fenceDark;
      ctx.lineWidth = 3;
      const pw = TS * 0.2;
      ctx.beginPath();
      ctx.roundRect(cx - pw / 2, cy - TS * 0.3, pw, TS * 0.55, 4);
      ctx.fill();
      ctx.stroke();
      ctx.fillStyle = 'rgba(255,235,200,.35)';
      ctx.fillRect(cx - pw / 2 + 2, cy - TS * 0.28, pw * 0.3, TS * 0.45);
      break;
    }
  }
}

function deepWater(g: Grid, x: number, y: number): boolean {
  for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]] as const) {
    const nx = x + dx, ny = y + dy;
    if (nx < 0 || ny < 0 || nx >= COLS || ny >= ROWS) continue;
    if (g[ny][nx].kind !== 'water') return false;
  }
  return true;
}

/** dense painterly tree-line filling the apron outside the fence */
function paintApronForest(ctx: CanvasRenderingContext2D, tree: Sprite): void {
  // darker forest-floor grass
  ctx.fillStyle = 'rgba(22, 52, 26, 0.30)';
  ctx.fillRect(-APRON * TS, -APRON * TS, EXP_W * SCALE, APRON * TS); // top
  ctx.fillRect(-APRON * TS, ROWS * TS, EXP_W * SCALE, APRON * TS); // bottom
  ctx.fillRect(-APRON * TS, 0, APRON * TS, ROWS * TS); // left
  ctx.fillRect(COLS * TS, 0, APRON * TS, ROWS * TS); // right

  // gather jittered tree spots, sort by y so canopies overlap front-to-back
  const spots: Array<{ px: number; py: number; h: number }> = [];
  for (let y = -APRON; y < ROWS + APRON; y++) {
    for (let x = -APRON; x < COLS + APRON; x++) {
      const inMap = x >= 0 && y >= 0 && x < COLS && y < ROWS;
      if (inMap) continue;
      // outer ring is denser + taller so the tree-line reads as deep woods
      const outer = x < -1 || y < -1 || x >= COLS + 1 || y >= ROWS + 1;
      const h = tileHash(x + 101, y + 57);
      if (h < (outer ? 0.12 : 0.3)) continue;
      spots.push({
        px: x * TS + TS * (0.1 + tileHash(x, y + 31) * 0.8),
        py: y * TS + TS * (0.55 + tileHash(x + 13, y) * 0.55),
        h: TS * (1.9 + h * (outer ? 1.2 : 0.9)),
      });
    }
  }
  spots.sort((a, b) => a.py - b.py);
  for (const s of spots) {
    const w = (tree.canvas.width / tree.canvas.height) * s.h;
    ctx.fillStyle = 'rgba(20, 45, 22, 0.30)';
    ctx.beginPath();
    ctx.ellipse(s.px, s.py - 2, w * 0.42, w * 0.13, 0, 0, Math.PI * 2);
    ctx.fill();
    ctx.drawImage(tree.canvas, s.px - w / 2, s.py - s.h, w, s.h);
  }
  // deepen the very outer rim so the forest fades dark at the edge of the world
  const rim = TS * 1.1;
  for (const [gx0, gy0, gx1, gy1] of [
    [0, -APRON * TS, 0, -APRON * TS + rim], // top
    [0, ROWS * TS + APRON * TS, 0, ROWS * TS + APRON * TS - rim], // bottom
    [-APRON * TS, 0, -APRON * TS + rim, 0], // left
    [COLS * TS + APRON * TS, 0, COLS * TS + APRON * TS - rim, 0], // right
  ] as const) {
    const grad = ctx.createLinearGradient(gx0, gy0, gx1, gy1);
    grad.addColorStop(0, 'rgba(10, 25, 12, 0.55)');
    grad.addColorStop(1, 'rgba(10, 25, 12, 0)');
    ctx.fillStyle = grad;
    ctx.fillRect(-APRON * TS, -APRON * TS, EXP_W * SCALE, EXP_H * SCALE);
  }
}

function renderStaticLayer(g: GameState, assets: Assets): HTMLCanvasElement {
  if (!staticLayer) {
    staticLayer = document.createElement('canvas');
    staticLayer.width = EXP_W * SCALE;
    staticLayer.height = EXP_H * SCALE;
  }
  const ctx = staticLayer.getContext('2d')!;
  ctx.setTransform(1, 0, 0, 1, APRON * TS, APRON * TS); // map tile (0,0) at apron offset
  ctx.clearRect(-APRON * TS, -APRON * TS, EXP_W * SCALE, EXP_H * SCALE);
  for (let y = -APRON; y < ROWS + APRON; y++) {
    for (let x = -APRON; x < COLS + APRON; x++) paintGrass(ctx, x, y);
  }
  paintMottle(ctx);
  for (let y = 0; y < ROWS; y++) for (let x = 0; x < COLS; x++) paintOverlay(ctx, g.tiles, x, y);
  paintApronForest(ctx, assets.tree);
  ctx.setTransform(1, 0, 0, 1, 0, 0);
  return staticLayer;
}

// ---------------------------------------------------------------------------
// camera
// ---------------------------------------------------------------------------

export function computeCamera(g: GameState, cw: number, ch: number): Camera {
  // cover the expanded (apron-included) world, with a cosiness floor so wide
  // desktop windows zoom in instead of flattening the whole map on screen:
  // show at most ~10 tile rows, but never fewer than ~7 columns.
  const zoom = Math.max(
    cw / EXP_W,
    ch / EXP_H,
    Math.min(ch / (TILE * 10), cw / (TILE * 7)),
  );
  // centre on the player, clamped against the apron edges — fixes the desktop
  // bug where the camera pinned to the map and the farmer walked off-screen
  const x = Math.min(Math.max(g.player.x * TILE - cw / zoom / 2, -A_PX), MAP_W + A_PX - cw / zoom);
  const y = Math.min(Math.max(g.player.y * TILE - ch / zoom / 2, -A_PX), MAP_H + A_PX - ch / zoom);
  return { x, y, zoom };
}

// ---------------------------------------------------------------------------
// frame
// ---------------------------------------------------------------------------

function drawSprite(
  ctx: CanvasRenderingContext2D, s: Sprite, cx: number, by: number, h: number,
  flip = false, rot = 0, sqx = 1, sqy = 1,
): void {
  const w = (s.canvas.width / s.canvas.height) * h;
  ctx.save();
  ctx.translate(cx, by);
  if (rot) ctx.rotate(rot);
  ctx.scale(flip ? -sqx : sqx, sqy);
  ctx.drawImage(s.canvas, -w / 2, -h, w, h);
  ctx.restore();
}

function shadow(ctx: CanvasRenderingContext2D, cx: number, by: number, rw: number): void {
  ctx.fillStyle = 'rgba(30,50,25,.25)';
  ctx.beginPath();
  ctx.ellipse(cx, by - 2, rw, rw * 0.32, 0, 0, Math.PI * 2);
  ctx.fill();
}

/** chimney mouth in world px (smoke spawns here — chimney sits left of the roof peak) */
function smokeOrigin(): { x: number; y: number } {
  return {
    x: (HOUSE.x + HOUSE.w * 0.32) * TILE,
    y: (HOUSE.y + HOUSE.h) * TILE - TILE * (HOUSE.h + 1.6) * 0.92,
  };
}

interface Light { x: number; y: number; r: number; strength: number }
let lightCache: Light[] | null = null;

/** lantern posts + farmhouse windows — lit after dusk */
function lightSources(g: GameState): Light[] {
  if (!lightCache) {
    lightCache = [];
    for (let y = 0; y < ROWS; y++) {
      for (let x = 0; x < COLS; x++) {
        if (g.tiles[y][x].deco === 'lantern') {
          lightCache.push({ x: (x + 0.5) * TILE, y: (y + 0.25) * TILE, r: TILE * 2.2, strength: 0.5 });
        }
      }
    }
    lightCache.push(
      { x: (HOUSE.x + HOUSE.w * 0.3) * TILE, y: (HOUSE.y + HOUSE.h * 0.55) * TILE, r: TILE * 1.6, strength: 0.42 },
      { x: (HOUSE.x + HOUSE.w * 0.7) * TILE, y: (HOUSE.y + HOUSE.h * 0.55) * TILE, r: TILE * 1.6, strength: 0.42 },
    );
  }
  return lightCache;
}

/** lerped day/night multiply color */
function gradeColor(t: number): [number, number, number] {
  const keys: Array<[number, number, number, number]> = [
    [0.0, 255, 250, 240],
    [0.5, 255, 255, 250],
    [0.6, 255, 238, 205],
    [0.7, 255, 168, 105],
    [0.78, 215, 122, 132],
    [0.86, 112, 106, 175],
    [1.0, 60, 73, 138],
  ];
  for (let i = 0; i < keys.length - 1; i++) {
    const [t0, r0, g0, b0] = keys[i];
    const [t1, r1, g1, b1] = keys[i + 1];
    if (t >= t0 && t <= t1) {
      const k = (t - t0) / (t1 - t0);
      return [r0 + (r1 - r0) * k, g0 + (g1 - g0) * k, b0 + (b1 - b0) * k];
    }
  }
  return [255, 255, 255];
}

let walkClock = 0;

export function render(
  ctx: CanvasRenderingContext2D, g: GameState, assets: Assets,
  cw: number, ch: number, dt: number, elapsed: number,
): void {
  if (g.tilesDirty) {
    renderStaticLayer(g, assets);
    g.tilesDirty = false;
  }
  if (g.player.moving) walkClock += dt;

  const cam = computeCamera(g, cw, ch);
  ctx.save();
  ctx.clearRect(0, 0, cw, ch);
  ctx.imageSmoothingEnabled = true;
  ctx.imageSmoothingQuality = 'high';
  ctx.scale(cam.zoom, cam.zoom);
  ctx.translate(-cam.x, -cam.y);

  // ground (includes the forest apron beyond the fence)
  ctx.drawImage(staticLayer!, -A_PX, -A_PX, EXP_W, EXP_H);

  // animated water shimmer
  ctx.strokeStyle = 'rgba(225,243,255,.5)';
  ctx.lineWidth = 2;
  ctx.lineCap = 'round';
  for (let y = 0; y < ROWS; y++) {
    for (let x = 0; x < COLS; x++) {
      if (g.tiles[y][x].kind !== 'water') continue;
      for (let i = 0; i < 2; i++) {
        const h = tileHash(x * 7 + i * 13, y * 5 + i);
        const ph = (elapsed * 0.6 + h * 7) % 1;
        const a = Math.sin(ph * Math.PI);
        if (a <= 0.05) continue;
        ctx.globalAlpha = a * 0.6;
        const wx = x * TILE + TILE * (0.2 + h * 0.5);
        const wy = y * TILE + TILE * (0.25 + tileHash(x, y + i * 31) * 0.5);
        ctx.beginPath();
        ctx.moveTo(wx, wy + Math.sin(elapsed * 2 + h * 9) * 1.5);
        ctx.lineTo(wx + TILE * 0.22, wy + Math.sin(elapsed * 2 + h * 9 + 1) * 1.5);
        ctx.stroke();
      }
    }
  }
  ctx.globalAlpha = 1;

  // target tile highlight
  if (g.phase === 'play') {
    const tt = targetTile(g);
    const tile = tt.x >= 0 && tt.y >= 0 && tt.x < COLS && tt.y < ROWS ? g.tiles[tt.y][tt.x] : null;
    if (tile) {
      const ok = canAct(g, tile);
      const pulse = 0.45 + Math.sin(elapsed * 5) * 0.18;
      ctx.strokeStyle = ok ? `rgba(255,248,214,${pulse + 0.25})` : `rgba(255,255,255,${pulse * 0.5})`;
      ctx.lineWidth = ok ? 3 : 2;
      ctx.beginPath();
      ctx.roundRect(tt.x * TILE + 3, tt.y * TILE + 3, TILE - 6, TILE - 6, 8);
      ctx.stroke();
    }
  }

  // depth-sorted drawables: crops, trees, player
  interface Drawable { y: number; draw: () => void }
  const draws: Drawable[] = [];

  for (let y = 0; y < ROWS; y++) {
    for (let x = 0; x < COLS; x++) {
      const t = g.tiles[y][x];
      if (t.crop) {
        const c = t.crop;
        const cx = x * TILE + TILE / 2;
        const by = y * TILE + TILE * 0.9;
        draws.push({
          y: by,
          draw: () => {
            const s = assets.crops[c.type][c.stage];
            let h: number;
            if (s.generated) {
              h = Math.min(Math.max(TILE * 1.45 * s.relH, TILE * 0.3), TILE * 1.4);
            } else {
              h = TILE * (0.45 + c.stage * 0.35);
            }
            const sway = c.stage > 0 ? Math.sin(elapsed * 1.6 + x * 1.7 + y) * 0.035 : 0;
            if (c.stage === 2) {
              // ready! gentle bounce to advertise it
              const b = 1 + Math.sin(elapsed * 3 + x) * 0.02;
              drawSprite(ctx, s, cx, by, h * b, false, sway);
            } else {
              drawSprite(ctx, s, cx, by, h, false, sway);
            }
          },
        });
      }
      if (t.tree) {
        const cx = x * TILE + TILE / 2;
        const by = y * TILE + TILE * 0.95;
        draws.push({
          y: by,
          draw: () => {
            shadow(ctx, cx, by, TILE * 0.55);
            const sway = Math.sin(elapsed * 0.9 + x * 2.3) * 0.012;
            drawSprite(ctx, assets.tree, cx, by, TILE * 2.3, false, sway);
          },
        });
      }
      if (t.deco) {
        const deco = t.deco;
        const cx = x * TILE + TILE / 2;
        const by = y * TILE + TILE * 0.9;
        draws.push({
          y: by,
          draw: () => {
            switch (deco) {
              case 'rock':
                shadow(ctx, cx, by, TILE * 0.42);
                drawSprite(ctx, assets.props[0], cx, by, TILE * 0.78, tileHash(x, y) > 0.5);
                break;
              case 'mushroom':
                drawSprite(ctx, assets.props[1], cx, by, TILE * 0.55, tileHash(x, y) > 0.5);
                break;
              case 'log':
                shadow(ctx, cx, by, TILE * 0.5);
                drawSprite(ctx, assets.props[2], cx, by, TILE * 0.62, tileHash(x, y) > 0.5);
                break;
              case 'lantern':
                shadow(ctx, cx, by, TILE * 0.26);
                drawSprite(ctx, assets.props[3], cx, by, TILE * 1.35);
                break;
              case 'scarecrow': {
                shadow(ctx, cx, by, TILE * 0.34);
                const sway = Math.sin(elapsed * 1.2 + 2) * 0.02;
                drawSprite(ctx, assets.scarecrow, cx, by, TILE * 1.6, false, sway);
                break;
              }
            }
          },
        });
      }
    }
  }

  // farmhouse
  {
    const cx = (HOUSE.x + HOUSE.w / 2) * TILE;
    const by = (HOUSE.y + HOUSE.h) * TILE - 3;
    draws.push({
      y: by,
      draw: () => {
        ctx.fillStyle = 'rgba(30,50,25,.22)';
        ctx.beginPath();
        ctx.ellipse(cx, by - 4, HOUSE.w * TILE * 0.52, TILE * 0.34, 0, 0, Math.PI * 2);
        ctx.fill();
        drawSprite(ctx, assets.house, cx, by, TILE * (HOUSE.h + 1.6));
      },
    });
  }

  // ambient critters (chickens, ducks) — depth-sorted with the player
  collectEntityDraws(draws, ctx, assets, elapsed);

  // player
  {
    const p = g.player;
    const cx = p.x * TILE;
    const by = p.y * TILE + TILE * 0.42;
    draws.push({
      y: by,
      draw: () => {
        shadow(ctx, cx, by, TILE * 0.3);
        const s = p.facing === 'up' ? assets.farmerUp : p.facing === 'down' ? assets.farmerDown : assets.farmerSide;
        const flip = p.facing === 'left';
        let bob = 0, sqx = 1, sqy = 1, rot = 0;
        if (p.moving) {
          bob = Math.abs(Math.sin(walkClock * 9)) * 3.5;
          sqx = 1 + Math.sin(walkClock * 18) * 0.04;
          sqy = 1 - Math.sin(walkClock * 18) * 0.04;
          rot = Math.sin(walkClock * 9) * 0.05;
        }
        if (g.toolAnim > 0) {
          // squash + lean into the swing
          const k = g.toolAnim / 0.28; // 1 -> 0
          const lean = Math.sin((1 - k) * Math.PI) * 0.32;
          rot = (p.facing === 'left' ? -lean : p.facing === 'right' ? lean : lean * 0.6);
          sqy = 1 - Math.sin((1 - k) * Math.PI) * 0.12;
          sqx = 1 + Math.sin((1 - k) * Math.PI) * 0.1;
        }
        drawSprite(ctx, s, cx, by - bob, TILE * 1.34, flip, rot, sqx, sqy);
        // swinging tool in hand
        if (g.toolAnim > 0) {
          const k = 1 - g.toolAnim / 0.28;
          const toolIdx = { hoe: 0, can: 1, seeds: 2, scythe: 3 }[g.tool];
          const ts = assets.tools[toolIdx];
          const dirX = p.facing === 'left' ? -1 : p.facing === 'right' ? 1 : 0;
          const dirY = p.facing === 'up' ? -1 : p.facing === 'down' ? 1 : 0.15;
          const reach = TILE * (0.3 + Math.sin(k * Math.PI) * 0.35);
          drawSprite(
            ctx, ts,
            cx + dirX * reach + (dirX === 0 ? TILE * 0.3 : 0),
            by - TILE * 0.45 + dirY * reach * 0.6,
            TILE * 0.62, dirX < 0, (-1.4 + k * 2.2) * (dirX < 0 ? -1 : 1),
          );
        }
      },
    });
  }

  draws.sort((a, b) => a.y - b.y);
  for (const d of draws) d.draw();

  // particles
  for (const p of particles) {
    ctx.globalAlpha = Math.max(0, p.life / p.maxLife);
    ctx.fillStyle = p.color;
    ctx.beginPath();
    ctx.arc(p.x * TILE, p.y * TILE, p.size, 0, Math.PI * 2);
    ctx.fill();
  }
  ctx.globalAlpha = 1;

  // airborne ambience: butterflies, leaves, birds, fish, chimney smoke
  drawAir(ctx, g, elapsed, smokeOrigin());

  ctx.restore();

  // ---- day/night color grade (screen space) ----
  const [r, gg, b] = gradeColor(g.time);
  if (r < 254 || gg < 254 || b < 254) {
    ctx.globalCompositeOperation = 'multiply';
    ctx.fillStyle = `rgb(${r | 0},${gg | 0},${b | 0})`;
    ctx.fillRect(0, 0, cw, ch);
    ctx.globalCompositeOperation = 'source-over';
  }

  // ---- sunset wash: warm directional glow rolling in from the top ----
  if (g.time > 0.58 && g.time < 0.86) {
    const k = Math.sin(((g.time - 0.58) / 0.28) * Math.PI);
    const sg = ctx.createLinearGradient(0, 0, 0, ch * 0.85);
    sg.addColorStop(0, `rgba(255, 120, 40, ${0.26 * k})`);
    sg.addColorStop(0.5, `rgba(255, 80, 90, ${0.1 * k})`);
    sg.addColorStop(1, 'rgba(255, 80, 90, 0)');
    ctx.globalCompositeOperation = 'screen';
    ctx.fillStyle = sg;
    ctx.fillRect(0, 0, cw, ch);
    ctx.globalCompositeOperation = 'source-over';
  }

  // ---- night light sources (after the grade so they punch through) ----
  const night = 1 - dayFactor(g.time);
  if (night > 0.2) {
    ctx.save();
    ctx.scale(cam.zoom, cam.zoom);
    ctx.translate(-cam.x, -cam.y);
    ctx.globalCompositeOperation = 'lighter';
    for (const L of lightSources(g)) {
      const flicker = 0.86 + Math.sin(elapsed * 7 + L.x * 3.1) * 0.08 + Math.sin(elapsed * 13 + L.y) * 0.06;
      const a = night * L.strength * flicker;
      const grad = ctx.createRadialGradient(L.x, L.y, 2, L.x, L.y, L.r);
      grad.addColorStop(0, `rgba(255, 196, 110, ${a})`);
      grad.addColorStop(0.4, `rgba(255, 160, 70, ${a * 0.35})`);
      grad.addColorStop(1, 'rgba(255, 150, 60, 0)');
      ctx.fillStyle = grad;
      ctx.beginPath();
      ctx.arc(L.x, L.y, L.r, 0, Math.PI * 2);
      ctx.fill();
    }
    ctx.globalCompositeOperation = 'source-over';
    drawNightGlow(ctx, g, elapsed); // fireflies
    ctx.restore();
  }

  // cozy vignette
  const vg = ctx.createRadialGradient(cw / 2, ch / 2, Math.min(cw, ch) * 0.42, cw / 2, ch / 2, Math.max(cw, ch) * 0.75);
  vg.addColorStop(0, 'rgba(40,30,15,0)');
  vg.addColorStop(1, 'rgba(40,30,15,.26)');
  ctx.fillStyle = vg;
  ctx.fillRect(0, 0, cw, ch);
}

/** would the current tool do something to this tile? */
export function canAct(g: GameState, t: Tile): boolean {
  switch (g.tool) {
    case 'hoe': return tillable(t);
    case 'can': return t.kind === 'soil' && !t.watered;
    case 'seeds': return t.kind === 'soil' && !t.crop;
    case 'scythe': return !!t.crop && t.crop.stage === 2;
  }
}

/** pretty clock string: 6:00 AM + time*20h */
export function clockString(time: number): string {
  const mins = Math.floor(time * 20 * 60);
  let h = 6 + Math.floor(mins / 60);
  const m = Math.floor((mins % 60) / 10) * 10;
  const day = h % 24 < 12;
  h = h % 12 === 0 ? 12 : h % 12;
  return `${h}:${m.toString().padStart(2, '0')} ${day ? 'AM' : 'PM'}`;
}

export function cropColor(type: string): string {
  return cropDef(type as never).color;
}
