/**
 * chunkstore.js — the world's working set. WP1 slice 2 (see ROADMAP.md).
 *
 * Sits between the World and the pure generator: chunks are generated lazily
 * on first touch, terrain edits overlay them (kept as a replayable per-chunk
 * log, so an evicted chunk regenerates *edited*), and each chunk carries an
 * entity record — the serialized state of everything standing in it while
 * the chunk is dormant. Arrays are evictable (pure regeneration + replay);
 * edit logs and entity records are not (they're the only real state, and
 * they're small).
 *
 * Nothing here decides WHAT exists — that's worldgen.js (generation) and
 * world.js (simulation). This file only remembers and forgets ground.
 */
'use strict';

const { CHUNK } = require('./worldgen');

const MAX_LOADED = 768; // ~ arrays for a 1.7km × 1.7km working set (~16MB)

class ChunkStore {
  /** @param {ReturnType<import('./worldgen').makeWorldGen>} gen */
  constructor(gen) {
    this.gen = gen;
    this.loaded = new Map(); // "cx,cz" -> { cx, cz, height, material, version }
    this.edits = new Map(); // "cx,cz" -> [op, ...] — replayable terrain edits
    this.entities = new Map(); // "cx,cz" -> { populated: bool, list: [serialized entities] }
    this._touch = new Map(); // "cx,cz" -> monotonically increasing touch stamp (LRU)
    this._stamp = 0;
  }

  static key(cx, cz) { return cx + ',' + cz; }
  static chunkOf(x, z) { return { cx: Math.floor(x / CHUNK), cz: Math.floor(z / CHUNK) }; }

  /** The chunk record for (cx,cz), generating (and replaying edits) on miss. */
  chunk(cx, cz) {
    const k = ChunkStore.key(cx, cz);
    let c = this.loaded.get(k);
    if (!c) {
      c = this.gen.generateChunk(cx, cz);
      c.version = 1;
      const ops = this.edits.get(k);
      if (ops) {
        for (const op of ops) this._applyToArrays(c, op);
        c.version += ops.length;
      }
      this.loaded.set(k, c);
      this._evictIfNeeded();
    }
    this._touch.set(k, ++this._stamp);
    return c;
  }

  heightAt(x, z) {
    // Round to the cell FIRST, then locate its chunk — that way a position
    // like x=63.7 resolves into chunk 1 cell 0 directly, no boundary case.
    const rx = Math.round(x), rz = Math.round(z);
    const cx = Math.floor(rx / CHUNK), cz = Math.floor(rz / CHUNK);
    const c = this.chunk(cx, cz);
    return c.height[(rz - cz * CHUNK) * CHUNK + (rx - cx * CHUNK)];
  }

  materialIdAt(x, z) {
    const rx = Math.round(x), rz = Math.round(z);
    const cx = Math.floor(rx / CHUNK), cz = Math.floor(rz / CHUNK);
    const c = this.chunk(cx, cz);
    return c.material[(rz - cz * CHUNK) * CHUNK + (rx - cx * CHUNK)];
  }

  /**
   * Apply a terrain edit (same op shape the bounded world used: x, z,
   * radius, mode raise|lower|paint, strength, material) across every chunk
   * it touches. Logged per chunk so regeneration replays it. Returns the
   * chunk keys that changed (for version bumps / client resync).
   */
  applyEdit(op) {
    const r = Math.max(1, Math.min(40, op.radius || 4));
    const cx0 = Math.floor((op.x - r) / CHUNK), cx1 = Math.floor((op.x + r) / CHUNK);
    const cz0 = Math.floor((op.z - r) / CHUNK), cz1 = Math.floor((op.z + r) / CHUNK);
    const touched = [];
    for (let cz = cz0; cz <= cz1; cz++) {
      for (let cx = cx0; cx <= cx1; cx++) {
        const k = ChunkStore.key(cx, cz);
        if (!this.edits.has(k)) this.edits.set(k, []);
        this.edits.get(k).push(op);
        const c = this.loaded.get(k);
        if (c) { this._applyToArrays(c, op); c.version++; }
        touched.push(k);
      }
    }
    return touched;
  }

  /** Mutate one loaded chunk's arrays with one op (falloff brush — the same
   *  math the bounded world's _applyTerrainEdit used). */
  _applyToArrays(c, op) {
    const r = Math.max(1, Math.min(40, op.radius || 4));
    const cxCell = Math.round(op.x), czCell = Math.round(op.z);
    const r2 = r * r;
    const sea = this.gen.SEA_LEVEL;
    const x0 = c.cx * CHUNK, z0 = c.cz * CHUNK;
    for (let zz = Math.max(z0, czCell - r); zz <= Math.min(z0 + CHUNK - 1, czCell + r); zz++) {
      for (let xx = Math.max(x0, cxCell - r); xx <= Math.min(x0 + CHUNK - 1, cxCell + r); xx++) {
        const dx = xx - cxCell, dz = zz - czCell, d2 = dx * dx + dz * dz;
        if (d2 > r2) continue;
        const i = (zz - z0) * CHUNK + (xx - x0);
        const fall = 1 - Math.sqrt(d2) / r;
        if (op.mode === 'paint') {
          if (op.material != null) c.material[i] = op.material & 0xff;
        } else if (op.mode === 'raise' || op.mode === 'lower') {
          c.height[i] += (op.mode === 'lower' ? -1 : 1) * (op.strength || 2) * fall;
          if (c.height[i] <= sea) c.material[i] = 0; // sank below sea → water
          else if (c.material[i] === 0) c.material[i] = 2; // raised out of water → grass
        }
      }
    }
  }

  /** Entity record for a chunk (dormant entity state + populated flag). */
  entityRecord(cx, cz) {
    const k = ChunkStore.key(cx, cz);
    let rec = this.entities.get(k);
    if (!rec) { rec = { populated: false, list: null }; this.entities.set(k, rec); }
    return rec;
  }

  /** Drop array data for the least-recently-touched chunks. Edit logs and
   *  entity records stay — they are state; arrays are just cache. */
  _evictIfNeeded() {
    if (this.loaded.size <= MAX_LOADED) return;
    const order = [...this._touch.entries()].sort((a, b) => a[1] - b[1]);
    let toDrop = this.loaded.size - Math.floor(MAX_LOADED * 0.9);
    for (const [k] of order) {
      if (toDrop <= 0) break;
      if (this.loaded.delete(k)) { this._touch.delete(k); toDrop--; }
    }
  }

  /** Serializable per-chunk state: edits + entity records. Nothing else. */
  save() {
    return {
      edits: [...this.edits.entries()],
      entities: [...this.entities.entries()].filter(([, rec]) => rec.populated),
    };
  }

  load(data) {
    this.edits = new Map(data && Array.isArray(data.edits) ? data.edits : []);
    this.entities = new Map(data && Array.isArray(data.entities) ? data.entities : []);
    this.loaded.clear();
    this._touch.clear();
  }
}

module.exports = { ChunkStore, MAX_LOADED };
