import {
  GRID, ZONE, STRUCT, BUILDINGS, NEEDS_ROAD,
  ROAD_COST, BRIDGE_COST, BULLDOZE_COST, TAX_DEFAULT,
  ZONE_POWER, ZONE_POP, ZONE_JOBS, LV_UPGRADE, SIM, START_MONEY, MONTHS
} from '../config.js';
import { Grid } from './grid.js';
import { EventBus } from '../events.js';
import { clamp, mulberry32 } from '../utils.js';

// Power drawn by service buildings when hooked to the grid (cosmetic + capacity math).
const SERVICE_POWER_DRAW = {
  [STRUCT.POLICE]: 5, [STRUCT.FIRE]: 5, [STRUCT.HOSPITAL]: 14,
  [STRUCT.SCHOOL]: 9, [STRUCT.PARK]: 0, [STRUCT.PLAZA]: 1, [STRUCT.STADIUM]: 22
};

export class City extends EventBus {
  constructor(seed = (Math.random() * 2 ** 31) | 0, name = 'New City') {
    super();
    this.seed = seed;
    this.grid = new Grid(seed);
    this.grid.generateTerrain();

    this.name = name;
    this.money = START_MONEY;
    this.taxRate = TAX_DEFAULT;
    this.monthIndex = 0;
    this.milestoneIdx = 0;

    this.stats = {
      pop: 0, jobs: 0, unemployment: 0, happiness: 70,
      resDemand: 0.5, comDemand: 0.35, indDemand: 0.4,
      powerUse: 0, powerCap: 0, brownouts: 0,
      income: 0, expenses: 0, avgLandValue: 0
    };
    this.history = [];   // {m, pop, funds, happy}
    this.lastBudget = { taxRes: 0, taxCom: 0, taxInd: 0, upkeep: 0, roads: 0 };

    // coverage / pollution maps
    const n = this.grid.n;
    this.mapPolice = new Float32Array(n);
    this.mapFire = new Float32Array(n);
    this.mapHealth = new Float32Array(n);
    this.mapEdu = new Float32Array(n);
    this.mapLeisure = new Float32Array(n);
    this.pollution = new Float32Array(n);

    this.tilesDirty = true;          // renderer should re-sync
    this._roadGraphDirty = true;
    this.roadGraph = { nodes: [], byTile: new Int32Array(n).fill(-1) };
    this.rng = mulberry32(seed ^ 0x5f3759df);
    this.disastersEnabled = true;
    this._debtWarnedAt = -99;
  }

  // ================= placement API =================

  tileFree(i) {
    return this.grid.struct[i] === 0 && this.grid.zone[i] === 0 && !this.grid.burning[i];
  }

  /** Validate footprint for a structure anchor at x,z. Returns {ok, reason, cost}. */
  canPlaceStruct(sid, x, z) {
    const meta = BUILDINGS[sid];
    const g = this.grid;
    let cost = 0;
    if (!meta) return { ok: false, reason: 'Unknown building' };
    for (let dz = 0; dz < meta.h; dz++) {
      for (let dx = 0; dx < meta.w; dx++) {
        const tx = x + dx, tz = z + dz;
        if (!g.inB(tx, tz)) return { ok: false, reason: 'Outside city limits' };
        const i = g.idx(tx, tz);
        // structures may claim empty (undeveloped) zoned plots — the zone
        // designation is cleared underneath; rubble/burning/roads block.
        if (g.struct[i] !== 0 || g.rubble[i] || g.burning[i] || g.level[i] > 0)
          return { ok: false, reason: 'Tile occupied — bulldoze first' };
        if (g.terrain[i] === 1) {
          if (sid === STRUCT.ROAD) cost += BRIDGE_COST;
          else return { ok: false, reason: 'Cannot build on water' };
        } else {
          cost += meta.cost !== undefined && sid === STRUCT.ROAD ? ROAD_COST : 0;
        }
      }
    }
    if (sid === STRUCT.ROAD) {
      // single-tile cost model
      const i = g.idx(x, z);
      cost = g.terrain[i] === 1 ? BRIDGE_COST : ROAD_COST;
    } else {
      cost = meta.cost;
    }
    return { ok: true, reason: '', cost };
  }

  placeStruct(sid, x, z) {
    const check = this.canPlaceStruct(sid, x, z);
    if (!check.ok) return { ok: false, reason: check.reason };
    if (this.money < check.cost) return { ok: false, reason: 'Not enough funds' };
    const meta = BUILDINGS[sid];
    const g = this.grid;
    const a = g.idx(x, z);
    this.money -= check.cost;
    for (let dz = 0; dz < meta.h; dz++) {
      for (let dx = 0; dx < meta.w; dx++) {
        const i = g.idx(x + dx, z + dz);
        if (sid !== STRUCT.ROAD && g.zone[i] !== 0) g.zone[i] = ZONE.NONE;
        g.struct[i] = sid;
        g.level[i] = 0;
        g.variant[i] = 0;
        g.age[i] = 0;
        g.badMonths[i] = 0;
        g.burning[i] = 0;
        g.rubble[i] = 0;
        g.scenery[i] = 0;
        g.anchor[i] = a;
      }
    }
    this.markTilesChanged();
    this.emit('money');
    return { ok: true };
  }

  /** Paint zones over a list of tiles (free action). Returns count placed. */
  placeZone(zid, tiles) {
    const g = this.grid;
    let placed = 0;
    for (const [x, z] of tiles) {
      if (!g.inB(x, z)) continue;
      const i = g.idx(x, z);
      if (g.terrain[i] !== 0) continue;
      if (!this.tileFree(i)) continue;
      if (g.zone[i] === zid) continue;
      g.zone[i] = zid;
      placed++;
    }
    if (placed > 0) { this.markTilesChanged(); }
    return placed;
  }

  /** Remove a zone designation (only on empty, undeveloped tiles). */
  clearZone(tiles) {
    const g = this.grid;
    let cleared = 0;
    for (const [x, z] of tiles) {
      if (!g.inB(x, z)) continue;
      const i = g.idx(x, z);
      if (g.zone[i] !== 0 && g.level[i] === 0 && !g.rubble[i]) {
        g.zone[i] = ZONE.NONE; cleared++;
      }
    }
    if (cleared) this.markTilesChanged();
    return cleared;
  }

  demolish(x, z) {
    const g = this.grid;
    if (!g.inB(x, z)) return { ok: false, reason: 'Outside city limits' };
    const i = g.idx(x, z);
    if (this.money < BULLDOZE_COST) return { ok: false, reason: 'Not enough funds' };

    // multi-tile structure → clear whole footprint via its anchor
    const ai = g.anchor[i];
    if (ai !== i || g.struct[i] !== 0) {
      const sid = g.struct[ai];
      if (sid !== 0) {
        const meta = BUILDINGS[sid];
        const ax = ai % g.size, az = (ai - ax) / g.size;
        for (let dz = 0; dz < meta.h; dz++) {
          for (let dx = 0; dx < meta.w; dx++) {
            const j = g.idx(ax + dx, az + dz);
            g.struct[j] = 0; g.level[j] = 0; g.variant[j] = 0;
            g.age[j] = 0; g.badMonths[j] = 0; g.burning[j] = 0; g.anchor[j] = j;
          }
        }
      }
    }
    // zone development / rubble / burning
    if (g.level[i] > 0 || g.rubble[i] || g.burning[i]) {
      g.level[i] = 0; g.variant[i] = 0; g.age[i] = 0;
      g.badMonths[i] = 0; g.burning[i] = 0; g.rubble[i] = 0;
      g.struct[i] = 0; g.anchor[i] = i;
    } else if (g.zone[i] !== 0) {
      g.zone[i] = ZONE.NONE;
    }
    this.money -= BULLDOZE_COST;
    this.emit('money');
    this.markTilesChanged();
    return { ok: true };
  }

  markTilesChanged() {
    this.tilesDirty = true;
    this._roadGraphDirty = true;
    this.emit('tilesChanged');
  }

  // ================= road graph (for cars) =================

  rebuildRoadGraph() {
    const g = this.grid, S = g.size;
    const nodes = [];
    const byTile = this.roadGraph.byTile.fill(-1);
    for (let i = 0; i < g.n; i++) {
      if (g.struct[i] === STRUCT.ROAD) { byTile[i] = nodes.length; nodes.push({ i, nbrs: [] }); }
    }
    for (let ni = 0; ni < nodes.length; ni++) {
      const { i } = nodes[ni];
      const x = i % S, z = (i - x) / S;
      const dirs = [[1, 0], [-1, 0], [0, 1], [0, -1]];
      for (const [dx, dz] of dirs) {
        const nx = x + dx, nz = z + dz;
        if (!g.inB(nx, nz)) continue;
        const j = g.idx(nx, nz);
        const nj = byTile[j];
        if (nj >= 0) nodes[ni].nbrs.push(nj);
      }
    }
    this.roadGraph.nodes = nodes;
    this._roadGraphDirty = false;
  }

  ensureRoadGraph() {
    if (this._roadGraphDirty) this.rebuildRoadGraph();
    return this.roadGraph;
  }

  roadNear(x, z, r = 2) {
    const g = this.grid;
    for (let dz = -r; dz <= r; dz++) {
      for (let dx = -r; dx <= r; dx++) {
        const nx = x + dx, nz = z + dz;
        if (!g.inB(nx, nz)) continue;
        if (g.struct[g.idx(nx, nz)] === STRUCT.ROAD) return true;
      }
    }
    return false;
  }

  // ================= power network =================

  recomputePower() {
    const g = this.grid;
    let cap = 0;
    const plants = [];

    for (let i = 0; i < g.n; i++) {
      const s = g.struct[i];
      if ((s === STRUCT.COAL || s === STRUCT.SOLAR || s === STRUCT.WIND) && i === g.anchor[i]) {
        let p = BUILDINGS[s].power;
        if (s === STRUCT.WIND) {
          // coastal bonus: turbines near water spin harder
          const x = i % g.size, z = (i - x) / g.size;
          let waterN = 0;
          for (const [dx, dz] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
            const nx = x + dx, nz = z + dz;
            if (g.inB(nx, nz) && g.terrain[g.idx(nx, nz)] === 1) waterN++;
          }
          if (waterN >= 3) p = Math.round(p * 1.5);
        }
        cap += p;
        plants.push(i);
      }
    }

    // consumers: developed zoned tiles; services draw from the meter too
    let draw = 0;
    const consumers = []; // [distToNearestPlant, tileIndex]
    for (let i = 0; i < g.n; i++) {
      const s = g.struct[i];
      if (s !== 0) {
        if (SERVICE_POWER_DRAW[s]) draw += SERVICE_POWER_DRAW[s];
        continue;
      }
      if (!g.isDeveloped(i)) continue;
      draw += ZONE_POWER[g.zone[i]][g.level[i] - 1];
      const x = i % g.size, z = (i - x) / g.size;
      let best = 255;
      for (const p of plants) {
        const px = p % g.size, pz = (p - px) / g.size;
        const d = Math.max(Math.abs(px - x), Math.abs(pz - z));
        if (d < best) best = d;
      }
      consumers.push([best, i]);
    }

    // city-wide grid: when demand outstrips capacity the districts farthest
    // from any plant brown out first
    g.powered.fill(0);
    consumers.sort((a, b) => b[0] - a[0]);
    let short = Math.max(0, draw - cap);
    let nOut = 0;
    for (const [d, i] of consumers) {
      void d;
      if (short <= 0) break;
      short -= ZONE_POWER[g.zone[i]][g.level[i] - 1];
      nOut++;
    }
    consumers.forEach(([, i], k) => { g.powered[i] = k < nOut ? 0 : 1; });

    this.stats.powerUse = draw;
    this.stats.powerCap = cap;
    this.stats.brownouts = nOut;
  }

  // ================= services / pollution / land value =================

  stampDisc(map, cx, cz, radius, str) {
    const g = this.grid, S = g.size;
    const r2 = radius * radius;
    const x0 = Math.max(0, Math.floor(cx - radius)), x1 = Math.min(S - 1, Math.ceil(cx + radius));
    const z0 = Math.max(0, Math.floor(cz - radius)), z1 = Math.min(S - 1, Math.ceil(cz + radius));
    for (let z = z0; z <= z1; z++) {
      for (let x = x0; x <= x1; x++) {
        const dx = x - cx, dz = z - cz;
        const d2 = dx * dx + dz * dz;
        if (d2 > r2) continue;
        map[z * S + x] += str * (1 - Math.sqrt(d2) / radius);
      }
    }
  }

  computeServiceMaps() {
    const g = this.grid, S = g.size;
    for (const m of [this.mapPolice, this.mapFire, this.mapHealth, this.mapEdu, this.mapLeisure, this.pollution]) m.fill(0);

    for (let i = 0; i < g.n; i++) {
      const s = g.struct[i];
      if (s === 0 || i !== g.anchor[i]) continue;
      const meta = BUILDINGS[s];
      if (!meta || !meta.radius && !meta.pollutionRadius) continue;
      const x = i % S, z = (i - x) / S;
      const active = !NEEDS_ROAD.has(s) || this.roadNear(x, z, 2);
      if (meta.radius && active) {
        const str = meta.radius >= 10 ? 1.0 : 1.15;
        switch (s) {
          case STRUCT.POLICE: this.stampDisc(this.mapPolice, x, z, meta.radius, str); break;
          case STRUCT.FIRE: this.stampDisc(this.mapFire, x, z, meta.radius, str); break;
          case STRUCT.HOSPITAL: this.stampDisc(this.mapHealth, x, z, meta.radius, str); break;
          case STRUCT.SCHOOL: this.stampDisc(this.mapEdu, x, z, meta.radius, str); break;
          case STRUCT.PARK: case STRUCT.PLAZA: case STRUCT.STADIUM:
            this.stampDisc(this.mapLeisure, x, z, meta.radius, str); break;
        }
      }
      if (meta.pollutionRadius) {
        this.stampDisc(this.pollution, x, z, meta.pollutionRadius, meta.pollution / 8);
      }
    }

    // industry pollution
    for (let i = 0; i < g.n; i++) {
      if (g.zone[i] === ZONE.IND && g.level[i] > 0 && !g.rubble[i]) {
        const S_ = S, x = i % S_, z = (i - x) / S_;
        this.stampDisc(this.pollution, x, z, 5, g.level[i] * 1.6);
      }
    }

    // land value per tile
    const W = { // weights per zone type: [edu, health, police, fire, leisure]
      1: [0.60, 0.60, 0.50, 0.30, 1.00],
      2: [0.30, 0.20, 0.70, 0.30, 0.80],
      3: [0.05, 0.05, 0.05, 0.05, 0.00]
    };
    let lvSum = 0, lvCount = 0;
    for (let i = 0; i < g.n; i++) {
      const zv = g.zone[i];
      if (zv === 0) { g.landValue[i] = 0; continue; }
      const x = i % S, z = (i - x) / S;
      const w = W[zv];
      let lv = 18
        + this.mapEdu[i] * 22 * w[0]
        + this.mapHealth[i] * 20 * w[1]
        + this.mapPolice[i] * 18 * w[2]
        + this.mapFire[i] * 12 * w[3]
        + this.mapLeisure[i] * 26 * w[4]
        - this.pollution[i] * (zv === ZONE.IND ? 0.18 : 0.55);
      // waterfront bonus
      for (const [dx, dz] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
        const nx = x + dx, nz = z + dz;
        if (g.inB(nx, nz) && g.terrain[g.idx(nx, nz)] === 1) { lv += 12; break; }
      }
      lv = clamp(lv, 0, 255);
      g.landValue[i] = lv;
      if (g.isDeveloped(i)) { lvSum += lv; lvCount++; }
    }
    this.stats.avgLandValue = lvCount ? lvSum / lvCount : 0;
  }

  // ================= aggregates & demand =================

  updateAggregates() {
    const g = this.grid;
    let pop = 0, jobsCom = 0, jobsInd = 0, resTiles = 0;
    let unpoweredBad = 0;
    for (let i = 0; i < g.n; i++) {
      if (g.rubble[i] || g.burning[i]) continue;
      const zv = g.zone[i], lvl = g.level[i];
      if (zv === 0 || lvl === 0) continue;
      const abandoned = g.badMonths[i] >= SIM.abandonAfterBadPowerMonths && !g.powered[i];
      const occ = abandoned ? 0.15 : 1;
      if (zv === ZONE.RES) pop += ZONE_POP[1][lvl - 1] * occ;
      else if (zv === ZONE.COM) jobsCom += ZONE_JOBS[2][lvl - 1] * occ;
      else if (zv === ZONE.IND) jobsInd += ZONE_JOBS[3][lvl - 1] * occ;
      if (!g.powered[i]) unpoweredBad++;
    }
    const jobs = Math.round(jobsCom + jobsInd);
    pop = Math.round(pop);
    const workers = pop * 0.52;
    const unemployment = workers > 0 ? clamp(1 - jobs / workers, 0, 1) : 0;

    this.stats.pop = pop;
    this.stats.jobs = jobs;
    this.stats.unemployment = unemployment;
    return { pop, jobs, workers };
  }

  updateDemand({ pop, jobs, workers }) {
    const g = this.grid;
    let housing = 0, comCap = 0, indCap = 0;
    for (let i = 0; i < g.n; i++) {
      if (g.rubble[i]) continue;
      if (g.zone[i] === ZONE.RES && g.level[i] > 0) housing += ZONE_POP[1][g.level[i] - 1];
      else if (g.zone[i] === ZONE.COM && g.level[i] > 0) comCap += ZONE_JOBS[2][g.level[i] - 1];
      else if (g.zone[i] === ZONE.IND && g.level[i] > 0) indCap += ZONE_JOBS[3][g.level[i] - 1];
    }
    const taxMod = this.taxRate <= 9 ? 1 : Math.max(0.25, 1 - (this.taxRate - 9) * 0.07);
    const happyMod = 0.6 + (this.stats.happiness / 100) * 0.7;

    const targetPop = jobs * 1.5 + 80;
    const resD = clamp((targetPop - pop) / Math.max(70, targetPop + 70), -1, 1) * taxMod * happyMod;
    const comTarget = pop * 0.32 + 12;
    const comD = clamp((comTarget - comCap) / Math.max(60, comTarget + comCap * 0.8 + 45), -1, 1) * taxMod;
    const indTarget = pop * 0.42 + 24;
    const indD = clamp((indTarget - indCap) / Math.max(70, indTarget + indCap * 0.8 + 50), -1, 1) * taxMod;

    this.stats.resDemand = resD;
    this.stats.comDemand = comD;
    this.stats.indDemand = indD;
    void housing; // housing informs resD implicitly through pop
  }

  computeHappiness(unemploymentRatio) {
    const g = this.grid;
    let covSum = 0, leisureSum = 0, pollSum = 0, cnt = 0;
    for (let i = 0; i < g.n; i++) {
      if (g.zone[i] === ZONE.RES && g.level[i] > 0 && !g.rubble[i]) {
        covSum += Math.min(1, (this.mapPolice[i] * .5 + this.mapFire[i] * .3 + this.mapHealth[i] * .6 + this.mapEdu[i] * .6));
        leisureSum += Math.min(1, this.mapLeisure[i] * 0.8);
        pollSum += this.pollution[i];
        cnt++;
      }
    }
    const coverScore = cnt ? (covSum / cnt) * 26 : 0;
    const parkScore = cnt ? (leisureSum / cnt) * 14 : 0;
    const pollPen = cnt ? Math.min(14, (pollSum / cnt) * 0.28) : 0;
    const taxPen = Math.max(0, (this.taxRate - 7)) * 3.2;
    const powerPen = this.stats.powerUse > 0 ? Math.min(24, (this.stats.brownouts / Math.max(1, this.stats.pop / 6)) * 8) : 0;
    const unempPen = unemploymentRatio * 16;
    this.stats.happiness = Math.round(clamp(56 + coverScore + parkScore - pollPen - taxPen - powerPen - unempPen, 3, 100));
  }

  // ================= monthly tick =================

  tick() {
    const g = this.grid;
    this.monthIndex++;

    this.recomputePower();
    this.computeServiceMaps();
    const agg = this.updateAggregates();
    this.computeHappiness(this.stats.unemployment);
    this.updateDemand(agg);

    this.growZones();
    this.upgradeAndAbandon();
    this.fires();
    this.maybeDisaster();

    // final aggregates after growth
    this.recomputePower();
    const agg2 = this.updateAggregates();
    this.computeHappiness(this.stats.unemployment);

    this.economy();
    this.checkMilestones();

    // brownout / unpowered notifications
    if (agg2.pop > 0 && this.stats.brownouts > 0 && this.monthIndex % 3 === 0) {
      this.notify(`⚡ Brownouts across town! Power use ${this.stats.powerUse} / ${this.stats.powerCap} units. Build another plant.`, 'warn');
    }
    const anyUnpoweredDev = (() => {
      let c = 0;
      for (let i = 0; i < g.n; i++) if (g.isDeveloped(i) && !g.powered[i]) c++;
      return c > agg2.pop / 40 && c > 4;
    })();
    if (anyUnpoweredDev && this.monthIndex % 4 === 0) {
      this.notify('🔌 New buildings have no electricity! Connect them to a power plant.', 'warn');
    }

    this.history.push({
      m: this.monthIndex, pop: this.stats.pop,
      funds: Math.round(this.money), happy: this.stats.happiness
    });
    if (this.history.length > 240) this.history.shift();

    this.markTilesChanged();
    this.emit('stats');
    this.emit('date');

    if (this.monthIndex % SIM.autosaveEveryMonths === 0) this.emit('autosave');
  }

  demandFor(zv) {
    return zv === ZONE.RES ? this.stats.resDemand : zv === ZONE.COM ? this.stats.comDemand : this.stats.indDemand;
  }

  growZones() {
    const g = this.grid;
    const powerShortage = this.stats.brownouts > 0 || (this.stats.powerCap === 0 && this.stats.pop > 0);

    // collect developable plots once (cheap O(n) walk)
    const cands = [];
    for (let i = 0; i < g.n; i++) {
      if (g.zone[i] === 0 || g.level[i] !== 0 || g.rubble[i] || g.burning[i]) continue;
      if (g.terrain[i] !== 0) continue;
      cands.push(i);
    }
    if (!cands.length) return;

    const S = g.size;

    for (const i of cands) {
      if (powerShortage && this.rng() < 0.85) continue; // construction stalls in blackouts
      const dem = this.demandFor(g.zone[i]);
      if (dem <= 0.03) continue;
      const x = i % S, z = (i - x) / S;
      if (!this.roadNear(x, z, 2)) continue;
      // gradual neighbourhood filling — scales with demand and area
      if (this.rng() < Math.min(0.75, dem * 0.5)) {
        g.level[i] = 1;
        g.variant[i] = (this.rng() * 3) | 0;
        g.age[i] = 0;
        g.badMonths[i] = 0;
      }
    }
  }

  upgradeAndAbandon() {
    const g = this.grid;
    const samples = SIM.upgradeSamplesPerMonth;
    for (let a = 0; a < samples; a++) {
      const i = (this.rng() * g.n) | 0;
      const zv = g.zone[i], lvl = g.level[i];
      if (zv === 0 || lvl === 0) continue;
      g.age[i]++;
      if (g.burning[i] || g.rubble[i]) continue;

      if (g.powered[i]) {
        if (g.badMonths[i] > 0) g.badMonths[i]--;
        // upgrade path — matured streets gain up to +12 effective land value
        const effLV = g.landValue[i] + Math.min(15, g.age[i] * 0.5);
        if (lvl < 3) {
          const dem = this.demandFor(zv);
          if (dem > 0.08 && g.age[i] > 2 && effLV >= LV_UPGRADE[lvl + 1] && this.rng() < 0.45) {
            g.level[i] = lvl + 1;
            g.variant[i] = (this.rng() * 3) | 0;
            g.age[i] = 0;
          }
        }
      } else {
        g.badMonths[i]++;
        if (g.badMonths[i] > 200) g.badMonths[i] = 200;
      }
    }
  }

  fires() {
    const g = this.grid, S = g.size;
    let firedStarted = false, spreadHappened = false;
    const newFires = [];
    for (let a = 0; a < 1400; a++) {
      const i = (this.rng() * g.n) | 0;
      if (!g.isDeveloped(i)) continue;
      const flamm = (0.35 + this.pollution[i] / 45) * (this.mapFire[i] > 0.3 ? 0.12 : 1) * (g.powered[i] ? 1 : 1.8);
      if (this.rng() < SIM.fireBaseChance * flamm * 2) { newFires.push(i); }
    }
    for (const i of newFires) {
      if (g.burning[i]) continue;
      g.burning[i] = 3;
      firedStarted = true;
    }

    // progress existing fires
    for (let i = 0; i < g.n; i++) {
      if (!g.burning[i]) continue;
      const covered = this.mapFire[i] > 0.45;
      g.burning[i] = covered ? Math.max(0, g.burning[i] - 2.5 | 0) : g.burning[i] - 1;
      if (!covered && this.rng() < SIM.fireSpreadChance) {
        const x = i % S, z = (i - x) / S;
        const nx = x + ((this.rng() * 3) | 0) - 1, nz = z + ((this.rng() * 3) | 0) - 1;
        if (g.inB(nx, nz)) {
          const j = g.idx(nx, nz);
          if (g.isDeveloped(j) && !g.burning[j]) { g.burning[j] = 2; spreadHappened = true; }
        }
      }
      if (g.burning[i] <= 0) {
        g.burning[i] = 0;
        g.rubble[i] = 1;
        g.level[i] = 0;
        g.variant[i] = 0;
        g.age[i] = 0;
      }
    }
    if (firedStarted) this.notify('🔥 Fire broke out in the city!', 'danger');
    else if (spreadHappened) this.notify('🔥 The fire is spreading!', 'danger');
  }

  maybeDisaster() {
    if (!this.disastersEnabled) return;
    if (this.monthIndex < SIM.disasterMinMonth || this.rng() >= SIM.disasterChance) return;
    this.spawnTornado();
  }

  /** A tornado tears an erratic path through town, flattening buildings
   *  into rubble. Roads survive. */
  spawnTornado() {
    const g = this.grid, S = g.size;
    let sx = -1, sz = -1, best = -1;
    for (let a = 0; a < 400; a++) {
      const x = 4 + ((this.rng() * (S - 8)) | 0), z = 4 + ((this.rng() * (S - 8)) | 0);
      let dev = 0;
      for (let dz = -3; dz <= 3; dz += 2) for (let dx = -3; dx <= 3; dx += 2) {
        if (!g.inB(x + dx, z + dz)) continue;
        const j = g.idx(x + dx, z + dz);
        if (g.isDeveloped(j) || g.struct[j]) dev++;
      }
      if (dev > best) { best = dev; sx = x; sz = z; }
    }
    if (best <= 0) return;

    let x = sx, z = sz, angle = this.rng() * Math.PI * 2, destroyed = 0;
    const steps = 9 + ((this.rng() * 6) | 0);
    const wreck = (tx, tz) => {
      if (!g.inB(tx, tz)) return;
      const i = g.idx(tx, tz);
      if (g.isDeveloped(i)) {
        g.burning[i] = 0; g.rubble[i] = 1;
        g.level[i] = 0; g.variant[i] = 0; g.age[i] = 0;
        return true;
      }
      if (g.struct[i] && g.struct[i] !== STRUCT.ROAD) { g.struct[i] = 0; return true; }
    };
    for (let s = 0; s < steps; s++) {
      // the funnel has width: strike this cell plus its four neighbours
      for (const [dx, dz] of [[0, 0], [1, 0], [-1, 0], [0, 1], [0, -1]]) {
        if (wreck(x + dx, z + dz)) destroyed++;
      }
      angle += (this.rng() - 0.5) * 1.2;
      x += Math.round(Math.cos(angle)); z += Math.round(Math.sin(angle));
      if (!g.inB(x, z)) break;
    }
    if (!destroyed) return;
    this.recomputeAnchors();
    this.markTilesChanged();
    this.notify('\u{1F32A} A tornado tore through the city!', 'danger');
    this.emit('disaster', { type: 'tornado', destroyed });
  }


  economy() {
    const t = this.taxRate / 100;
    const g = this.grid;
    let taxRes = 0, taxCom = 0, taxInd = 0, upkeep = 0, roadTiles = 0;

    for (let i = 0; i < g.n; i++) {
      const s = g.struct[i];
      if (s !== 0) {
        if (i === g.anchor[i] && s !== STRUCT.ROAD) upkeep += BUILDINGS[s].upkeep;
        if (s === STRUCT.ROAD) roadTiles++;
        continue;
      }
      const zv = g.zone[i], lvl = g.level[i];
      if (zv === 0 || lvl === 0 || g.rubble[i] || g.burning[i]) continue;
      const occ = (g.badMonths[i] >= SIM.abandonAfterBadPowerMonths && !g.powered[i]) ? 0.15 : 1;
      if (zv === ZONE.RES) taxRes += ZONE_POP[1][lvl - 1] * occ;
      else if (zv === ZONE.COM) taxCom += ZONE_JOBS[2][lvl - 1] * occ;
      else taxInd += ZONE_JOBS[3][lvl - 1] * occ;
    }
    upkeep += roadTiles * SIM.roadExpensePerTile;

    taxRes *= t * SIM.taxRes;
    taxCom *= t * SIM.taxCom;
    taxInd *= t * SIM.taxInd;

    const income = taxRes + taxCom + taxInd;
    const expenses = upkeep;
    this.money = Math.round(this.money + income - expenses);
    this.lastBudget = { taxRes, taxCom, taxInd, upkeep, roads: roadTiles * SIM.roadExpensePerTile };
    this.stats.income = income;
    this.stats.expenses = expenses;

    if (this.money < SIM.maxDebtWarning && this.monthIndex - this._debtWarnedAt > 6) {
      this._debtWarnedAt = this.monthIndex;
      this.notify('💸 Treasury is deep in debt! Raise taxes or cut services.', 'danger');
    }
  }

  checkMilestones() {
    const ms = SIM.milestonePops;
    let idx = 0;
    for (let i = 0; i < ms.length; i++) if (this.stats.pop >= ms[i][0]) idx = i;
    if (idx > this.milestoneIdx) {
      this.milestoneIdx = idx;
      this.notify(`🏙️ Congratulations! ${this.name} is now a ${ms[idx][1]}!`, 'success');
      this.emit('milestone', ms[idx][1]);
    } else {
      this.milestoneIdx = idx;
    }
  }

  notify(msg, kind = 'info') { this.emit('notify', { msg, kind }); }

  dateLabel() {
    const year = 2026 + Math.floor(this.monthIndex / 12);
    return `${MONTHS[this.monthIndex % 12]} ${year}`;
  }

  // ================= info for query tool =================

  getTileInfo(x, z) {
    const g = this.grid;
    if (!g.inB(x, z)) return null;
    const i = g.idx(x, z);
    const info = { x, z, terrain: g.terrain[i] === 1 ? 'Water' : 'Grass' };
    const s = g.struct[i];
    if (s !== 0) {
      const meta = BUILDINGS[s];
      info.type = meta.name;
      info.kind = s === STRUCT.ROAD ? 'road' : 'service';
      if (NEEDS_ROAD.has(s)) info.active = this.roadNear(x, z, 2);
      if (meta.power) {
        let bonus = '';
        info.output = meta.power;
        if (s === STRUCT.WIND) {
          const waterN = [[1,0],[-1,0],[0,1],[0,-1]].filter(([dx,dz]) => g.inB(x+dx,z+dz) && g.terrain[g.idx(x+dx,z+dz)]===1).length;
          if (waterN >= 3) { info.output = Math.round(meta.power*1.5); bonus = ' (+50% coastal)'; }
          info.outputLabel = info.output + ' units' + bonus;
        }
      }
    } else if (g.zone[i] !== 0) {
      const names = { 1: 'Residential zone', 2: 'Commercial zone', 3: 'Industrial zone' };
      info.type = names[g.zone[i]];
      info.kind = 'zone';
      info.level = g.level[i];
      info.rubble = !!g.rubble[i];
      info.burning = g.burning[i] > 0;
      info.powered = !!g.powered[i];
      info.landValue = Math.round(g.landValue[i]);
      info.pollution = Math.round(this.pollution[i]);
      info.abandoned = g.badMonths[i] >= SIM.abandonAfterBadPowerMonths && !g.powered[i] && g.level[i]>0;
    } else {
      info.type = g.terrain[i] === 1 ? 'Water' : 'Open land';
      info.kind = 'empty';
    }
    return info;
  }

  // ================= serialization =================

  toJSON() {
    const g = this.grid;
    return {
      version: 1,
      seed: this.seed, name: this.name,
      money: Math.round(this.money), taxRate: this.taxRate,
      monthIndex: this.monthIndex, milestoneIdx: this.milestoneIdx,
      terrain: Array.from(g.terrain), zone: Array.from(g.zone),
      struct: Array.from(g.struct), level: Array.from(g.level),
      variant: Array.from(g.variant), age: Array.from(g.age),
      badMonths: Array.from(g.badMonths), rubble: Array.from(g.rubble),
      scenery: Array.from(g.scenery),
      history: this.history.slice(-120)
    };
  }

  static fromJSON(data) {
    const c = new City(data.seed, data.name);
    const g = c.grid;
    c.money = data.money; c.taxRate = data.taxRate;
    c.monthIndex = data.monthIndex || 0;
    c.milestoneIdx = data.milestoneIdx || 0;
    c.history = data.history || [];
    g.terrain.set(data.terrain); g.zone.set(data.zone);
    g.struct.set(data.struct); g.level.set(data.level);
    g.variant.set(data.variant); g.age.set(data.age);
    g.badMonths.set(data.badMonths); g.rubble.set(data.rubble);
    g.scenery.set(data.scenery);
    c.recomputeAnchors();
    c.recomputePower();
    c.computeServiceMaps();
    c.updateAggregates();
    c.computeHappiness(c.stats.unemployment);
    c.markTilesChanged();
    return c;
  }

  /** Recompute anchor links for multi-tile structures by scanning footprints. */
  recomputeAnchors() {
    const g = this.grid, S = g.size;
    g.anchor.fill(-1);
    for (let i = 0; i < g.n; i++) {
      const s = g.struct[i];
      if (s === 0 || s === STRUCT.ROAD) { g.anchor[i] = i; continue; }
    }
    for (let z = 0; z < S; z++) {
      for (let x = 0; x < S; x++) {
        const i = g.idx(x, z);
        const s = g.struct[i];
        if (s === 0 || s === STRUCT.ROAD) continue;
        if (g.anchor[i] !== -1) continue;
        // found an unclaimed tile — it is the top-left of its footprint
        const meta = BUILDINGS[s];
        const a = i;
        for (let dz = 0; dz < meta.h; dz++) {
          for (let dx = 0; dx < meta.w; dx++) {
            const tx = x + dx, tz = z + dz;
            if (!g.inB(tx, tz)) continue;
            const j = g.idx(tx, tz);
            if (g.struct[j] === s && g.anchor[j] === -1) g.anchor[j] = a;
          }
        }
      }
    }
    for (let i = 0; i < g.n; i++) if (g.anchor[i] === -1) g.anchor[i] = i;
  }
}
