// Edit store — wraps base data with user overrides, persisted to localStorage.
// Lets the rest of the app keep reading window.BUILDINGS / window.GRAPH_NODES
// while edit mode mutates them through setEdits().

const EDIT_STORAGE_KEY = "ucd-campus-map-edits-v2";
const LEGACY_KEYS = ["ucd-campus-map-edits-v1"];

// Snapshot of base data as imported from data.jsx
const BASE_BUILDINGS  = window.BUILDINGS.map(b => ({ ...b }));
const BASE_NODES      = JSON.parse(JSON.stringify(window.GRAPH_NODES));
const BASE_EDGES      = window.GRAPH_EDGES.map(e => [...e]);
const BASE_SHUTTLE    = window.SHUTTLE_STOPS.map(s => ({ ...s }));

window.BASE_BUILDINGS = BASE_BUILDINGS;
window.BASE_NODES     = BASE_NODES;
window.BASE_EDGES     = BASE_EDGES;
window.BASE_SHUTTLE   = BASE_SHUTTLE;

// Edge types ----
const EDGE_TYPES = {
  walk: { label: "Walking path",  color: "#0E7C66", dash: "none",   width: 3 },
  car:  { label: "Car / drive",   color: "#5A6470", dash: "none",   width: 4 },
  both: { label: "Mixed",         color: "#022851", dash: "none",   width: 3 },
};
window.EDGE_TYPES = EDGE_TYPES;

// Edits shape (v2):
// {
//   buildings: { [code]: { x, y, w, h, label } },
//   nodes:     { [id]: { x, y, label } },
//   addedNodes:{ [id]: { x, y, label } },
//   addedEdges:[ [a,b], ... ],
//   removedEdges:[ [a,b], ... ],
//   edgeMeta:  { [edgeKey]: { type: "walk"|"car"|"both" } },   // type override per edge
//   shuttle:   { [id]: { x, y } },
//   entrances: { [id]: { id, building, x, y, accessible, label } },
//   crosswalks:{ [id]: { id, x, y, angle, label } },
// }
function readEdits() {
  try {
    let raw = localStorage.getItem(EDIT_STORAGE_KEY);
    // Migrate from legacy keys (e.g. v1) so prior building/waypoint edits don't disappear
    if (!raw) {
      for (const k of LEGACY_KEYS) {
        const legacy = localStorage.getItem(k);
        if (legacy) {
          try {
            const merged = { ...defaultEdits(), ...JSON.parse(legacy) };
            localStorage.setItem(EDIT_STORAGE_KEY, JSON.stringify(merged));
            raw = JSON.stringify(merged);
            break;
          } catch {}
        }
      }
    }
    if (!raw) return defaultEdits();
    const parsed = JSON.parse(raw);
    return pruneExpiredZones({ ...defaultEdits(), ...parsed });
  } catch {
    return defaultEdits();
  }
}
function defaultEdits() {
  return {
    // buildings entries override BASE_BUILDINGS by code: { x?, y?, w?, h?, points?, label?, name?, cat?, _deleted? }
    // _deleted: true tombstones a base building so it stays gone across reloads.
    // addedBuildings holds full records for user-created hotspots.
    addedBuildings:{},
    buildings:{}, nodes:{}, addedNodes:{},
    addedEdges:[], removedEdges:[], edgeMeta:{},
    // shuttle entries override BASE_SHUTTLE: { x?, y?, name?, _deleted? }
    // _deleted: true tombstones a base stop (kept in storage so it stays gone
    // across reloads even though BASE_SHUTTLE still has it).
    shuttle:{},
    // user-added shuttle stops (full records keyed by id)
    addedShuttle:{},
    entrances:{}, crosswalks:{},
    pois:{},
    // Construction zones — temporary closures; route around them.
    // Shape: { id, name, points:[[x,y],...], startDate:"YYYY-MM-DD"|null,
    //          endDate:"YYYY-MM-DD"|null, affects:[buildingCodes], notes }
    constructionZones:{},
    // Floor-plan edits — keyed by building code that has interior data
    // (currently just "T"). Each entry is a map of levelId → { boundary,
    // nodes, edges } where boundary is a polygon [[x,y], ...], nodes is a
    // map of nodeId → { id, kind:"walk"|"room"|"elevator", x, y,
    // elevatorId?, roomN?, label? }, and edges is an array of [nodeIdA,
    // nodeIdB] pairs. Overrides REPLACE the seed for each field that's
    // present; missing fields fall back to seedFloors in floorplan.jsx.
    floorplan: {},
  };
}

// Drop construction zones whose endDate is in the past so the routing graph
// auto-recovers without a manual purge. Called on every read.
function pruneExpiredZones(edits) {
  const today = new Date().toISOString().slice(0, 10); // YYYY-MM-DD lexicographic compare
  const zones = edits.constructionZones || {};
  const keep = {};
  let removed = 0;
  for (const [id, z] of Object.entries(zones)) {
    if (z.endDate && z.endDate < today) { removed++; continue; }
    keep[id] = z;
  }
  if (removed > 0) {
    edits.constructionZones = keep;
    try { localStorage.setItem(EDIT_STORAGE_KEY, JSON.stringify(edits)); } catch {}
  }
  return edits;
}
function writeEdits(e) {
  try { localStorage.setItem(EDIT_STORAGE_KEY, JSON.stringify(e)); } catch {}
}

function applyEdits(edits) {
  // Buildings: base list with overrides + tombstones, then user-added hotspots
  const baseBuildings = BASE_BUILDINGS
    .map(b => {
      const o = edits.buildings[b.code];
      return o ? { ...b, ...o } : b;
    })
    .filter(b => !b._deleted);
  const addedBuildings = Object.values(edits.addedBuildings || {});
  window.BUILDINGS = [...baseBuildings, ...addedBuildings];
  // Nodes: base + overrides + added
  const merged = {};
  for (const [k, v] of Object.entries(BASE_NODES)) {
    const o = edits.nodes[k];
    merged[k] = o ? { ...v, ...o } : v;
  }
  for (const [k, v] of Object.entries(edits.addedNodes || {})) merged[k] = v;
  window.GRAPH_NODES = merged;
  // Edges (with type metadata attached as third array slot for downstream code, but stored separately)
  const removed = new Set(edits.removedEdges.map(e => edgeKey(e)));
  const baseEdges = BASE_EDGES.filter(e => !removed.has(edgeKey(e)));
  window.GRAPH_EDGES = [...baseEdges, ...edits.addedEdges];
  window.EDGE_META = { ...edits.edgeMeta };
  // Shuttle stops: base list with overrides + tombstones, then user-added
  const baseStops = BASE_SHUTTLE
    .map(s => {
      const o = edits.shuttle[s.id];
      return o ? { ...s, ...o } : s;
    })
    .filter(s => !s._deleted);
  const addedStops = Object.values(edits.addedShuttle || {});
  window.SHUTTLE_STOPS = [...baseStops, ...addedStops];
  // Entrances + crosswalks + POIs (purely user-added)
  window.ENTRANCES  = Object.values(edits.entrances || {});
  window.CROSSWALKS = Object.values(edits.crosswalks || {});
  window.POIS       = Object.values(edits.pois || {});
  // Construction zones — only the ones not yet expired. pruneExpiredZones in
  // readEdits already drops stale ones on disk, but a defensive filter here
  // protects against future codepaths writing without going through readEdits.
  const today = new Date().toISOString().slice(0, 10);
  window.CONSTRUCTION_ZONES = Object.values(edits.constructionZones || {})
    .filter(z => !z.endDate || z.endDate >= today);
}

function edgeKey(e) {
  const [a, b] = e;
  return a < b ? `${a}__${b}` : `${b}__${a}`;
}
window.edgeKey = edgeKey;

// Hook so React components re-render when edits change
const editListeners = new Set();
function notifyEdits() { editListeners.forEach(cb => cb()); }

// ---- Backup + dirty-state tracking ----
// Auto-snapshots every mutation into a separate localStorage key so a stray
// localStorage.clear() or stale-cache reset can be recovered. Counts mutations
// since the last Apply so the unsaved-edits banner can warn the user before
// they navigate away with un-published work.
const BACKUP_KEY  = "ucd-campus-map-edits-v2-backups";
const DIRTY_KEY   = "ucd-campus-map-edits-v2-dirty"; // count of mutations since last Apply
const MAX_BACKUPS = 10;

function readBackups() {
  try { return JSON.parse(localStorage.getItem(BACKUP_KEY) || "[]"); } catch { return []; }
}
function writeBackups(list) {
  try { localStorage.setItem(BACKUP_KEY, JSON.stringify(list.slice(0, MAX_BACKUPS))); } catch {}
}
function snapshot(state) {
  const list = readBackups();
  // Skip writing a duplicate of the most recent snapshot to avoid filling the
  // ring buffer with no-op renders. Compare by JSON string.
  const ser = JSON.stringify(state);
  if (list.length && list[0].edits === ser) return;
  list.unshift({ at: new Date().toISOString(), edits: ser });
  writeBackups(list);
}
function readDirty() {
  try { return parseInt(localStorage.getItem(DIRTY_KEY) || "0", 10) || 0; } catch { return 0; }
}
function writeDirty(n) {
  try { localStorage.setItem(DIRTY_KEY, String(n)); } catch {}
}

// Coalesces rapid mutations (drag handlers, keystroke-by-keystroke text input)
// into a single logical "edit". Without this, a vertex drag bumps the dirty
// counter and snapshots the state once per mousemove frame — dozens of
// "edits" for what the user perceives as one drag.
//
// Strategy: every editStore.set() schedules a commit timer. If another set
// fires within COMMIT_WINDOW_MS, the timer resets. When the window finally
// elapses with no new mutations, we record ONE dirty increment and ONE
// snapshot of the most recent state. Live UI updates (applyEdits +
// notifyEdits) still fire synchronously inside set(), so the map keeps
// repainting in real time during the drag.
//
// 400ms is long enough that a fluent drag/keystroke counts as one edit,
// short enough that the dirty count + banner feel responsive after release.
const COMMIT_WINDOW_MS = 400;
let commitTimer = null;
let commitPendingState = null;
function scheduleCommit(state) {
  commitPendingState = state;
  if (commitTimer) clearTimeout(commitTimer);
  commitTimer = setTimeout(() => {
    if (commitPendingState) {
      snapshot(commitPendingState);
      writeDirty(readDirty() + 1);
      commitPendingState = null;
      notifyEdits(); // re-render the banner with the new count
    }
    commitTimer = null;
  }, COMMIT_WINDOW_MS);
}

function uid(prefix) {
  return prefix + "_" + Math.random().toString(36).slice(2, 7);
}

const editStore = {
  get() { return readEdits(); },
  set(updater) {
    const cur = readEdits();
    const next = typeof updater === "function" ? updater(cur) : updater;
    // No-op guard: if the updater returned the same state (e.g. a forced
    // re-render via set(e => ({...e})) or a useless patch), don't count it
    // as an edit. Comparison is via JSON since edits hold only plain data.
    const sameAsBefore = JSON.stringify(cur) === JSON.stringify(next);
    writeEdits(next);
    applyEdits(next);
    notifyEdits();
    if (!sameAsBefore) scheduleCommit(next);
  },
  subscribe(cb) { editListeners.add(cb); return () => editListeners.delete(cb); },
  reset() { writeEdits(defaultEdits()); applyEdits(defaultEdits()); writeDirty(0); notifyEdits(); },

  // ---- Backup API ----
  // Number of mutations since the last Apply. Read by the unsaved-edits banner.
  dirtyCount() { return readDirty(); },
  // Mark current state as the published baseline (called by the Apply button).
  // Also flushes any pending debounced commit (snapshot + counter bump) so a
  // mid-drag Apply doesn't leave an in-flight increment that would re-dirty
  // the counter ~400ms later.
  markApplied() {
    if (commitTimer) { clearTimeout(commitTimer); commitTimer = null; }
    if (commitPendingState) { snapshot(commitPendingState); commitPendingState = null; }
    writeDirty(0);
    notifyEdits();
  },
  // List of snapshot timestamps for the recovery UI.
  listBackups() { return readBackups().map(s => ({ at: s.at })); },
  // Restore the snapshot at index `i` in listBackups() output.
  restoreBackup(i) {
    const list = readBackups();
    if (!list[i]) return false;
    try {
      const edits = JSON.parse(list[i].edits);
      writeEdits(edits);
      applyEdits(edits);
      // Restoring is itself a mutation that diverges from the published
      // baseline, so bump dirty so the banner reminds the user to Apply.
      writeDirty(readDirty() + 1);
      notifyEdits();
      return true;
    } catch { return false; }
  },

  // ---- Buildings ----
  // Internal: applies a transform fn to whichever bucket holds this code
  // (added vs base override). Keeps every patcher below from having to repeat
  // the dispatch logic.
  _patchBuilding(e, code, fn) {
    if (e.addedBuildings[code]) {
      const next = fn(e.addedBuildings[code]);
      return { ...e, addedBuildings: { ...e.addedBuildings, [code]: next } };
    }
    const cur = e.buildings[code] || {};
    return { ...e, buildings: { ...e.buildings, [code]: fn(cur) } };
  },
  // ---- New: add / delete a hotspot ----
  addBuilding(x, y, opts = {}) {
    // Auto-pick a unique code that isn't already in BUILDINGS or addedBuildings
    let code = opts.code;
    if (!code) {
      const used = new Set(window.BUILDINGS.map(b => b.code));
      let n = 1;
      while (used.has("X" + n)) n++;
      code = "X" + n;
    }
    const w = Math.max(8, opts.w || 40);
    const h = Math.max(8, opts.h || 30);
    const record = {
      code,
      name: opts.name || "New hotspot",
      cat: opts.cat || "external",
      x: Math.round(x - w / 2),
      y: Math.round(y - h / 2),
      w, h,
      label: opts.label || (opts.name || code),
    };
    this.set(e => ({ ...e, addedBuildings: { ...e.addedBuildings, [code]: record } }));
    return code;
  },
  deleteBuilding(code) {
    this.set(e => {
      if (e.addedBuildings[code]) {
        const a = { ...e.addedBuildings }; delete a[code];
        return { ...e, addedBuildings: a };
      }
      // Tombstone for base buildings — preserves any prior overrides
      return { ...e, buildings: { ...e.buildings, [code]: { ...(e.buildings[code]||{}), _deleted: true } } };
    });
  },
  moveBuilding(code, partial) {
    this.set(e => this._patchBuilding(e, code, cur => ({ ...cur, ...partial })));
  },
  setBuildingLabel(code, label) {
    this.moveBuilding(code, { label });
  },
  // Convert a building from rect → polygon (4 corners derived from current x/y/w/h)
  convertToPolygon(code) {
    const b = window.BUILDINGS.find(x => x.code === code);
    if (!b) return;
    const pts = [
      [b.x, b.y], [b.x + b.w, b.y],
      [b.x + b.w, b.y + b.h], [b.x, b.y + b.h],
    ];
    this.moveBuilding(code, { points: pts });
  },
  // Convert polygon → rect (using bbox of current points)
  convertToRect(code) {
    const b = window.BUILDINGS.find(x => x.code === code);
    if (!b || !b.points) return;
    const xs = b.points.map(p => p[0]), ys = b.points.map(p => p[1]);
    const x = Math.round(Math.min(...xs)), y = Math.round(Math.min(...ys));
    const w = Math.round(Math.max(...xs) - x), h = Math.round(Math.max(...ys) - y);
    this.set(e => this._patchBuilding(e, code, cur => {
      const next = { ...cur, x, y, w, h };
      delete next.points;
      return next;
    }));
  },
  setPolygonPoint(code, idx, x, y) {
    this.set(e => this._patchBuilding(e, code, cur => {
      const base = window.BASE_BUILDINGS.find(x => x.code === code) || {};
      const points = (cur.points ? cur.points : (base.points || null) ||
        // synthesise from current rect
        (() => { const b = window.BUILDINGS.find(x => x.code === code);
          return [[b.x,b.y],[b.x+b.w,b.y],[b.x+b.w,b.y+b.h],[b.x,b.y+b.h]]; })()
      ).map((p, i) => i === idx ? [Math.round(x), Math.round(y)] : [...p]);
      return { ...cur, points };
    }));
  },
  insertPolygonPoint(code, afterIdx, x, y) {
    this.set(e => this._patchBuilding(e, code, cur => {
      const points = (cur.points || []).slice();
      points.splice(afterIdx + 1, 0, [Math.round(x), Math.round(y)]);
      return { ...cur, points };
    }));
  },
  deletePolygonPoint(code, idx) {
    this.set(e => {
      const bucket = e.addedBuildings[code] ? "addedBuildings" : "buildings";
      const cur = e[bucket][code] || {};
      if (!cur.points || cur.points.length <= 3) return e;
      const points = cur.points.filter((_, i) => i !== idx);
      return { ...e, [bucket]: { ...e[bucket], [code]: { ...cur, points } } };
    });
  },

  // ---- Waypoints ----
  moveNode(id, x, y) {
    this.set(e => {
      if (BASE_NODES[id]) {
        return { ...e, nodes: { ...e.nodes, [id]: { ...(e.nodes[id]||{}), x, y } } };
      } else {
        return { ...e, addedNodes: { ...e.addedNodes, [id]: { ...(e.addedNodes[id]||{}), x, y } } };
      }
    });
  },
  setNodeLabel(id, label) {
    this.set(e => {
      if (BASE_NODES[id]) {
        return { ...e, nodes: { ...e.nodes, [id]: { ...(e.nodes[id]||{}), label } } };
      } else if (e.addedNodes[id]) {
        return { ...e, addedNodes: { ...e.addedNodes, [id]: { ...e.addedNodes[id], label } } };
      }
      return e;
    });
  },
  addNode(id, x, y, label) {
    this.set(e => ({ ...e, addedNodes: { ...e.addedNodes, [id]: { x, y, label: label || "" } } }));
  },
  deleteAddedNode(id) {
    this.set(e => {
      if (!e.addedNodes[id]) return e;
      const nn = { ...e.addedNodes }; delete nn[id];
      const ae = e.addedEdges.filter(([a,b]) => a !== id && b !== id);
      // also clean edge meta touching this node
      const em = { ...e.edgeMeta };
      for (const k of Object.keys(em)) {
        const [aa, bb] = k.split("__");
        if (aa === id || bb === id) delete em[k];
      }
      return { ...e, addedNodes: nn, addedEdges: ae, edgeMeta: em };
    });
  },

  // ---- Edges ----
  toggleEdge(a, b, type) {
    if (a === b) return;
    this.set(e => {
      const k = edgeKey([a,b]);
      // If it's an added edge, remove it
      if (e.addedEdges.some(ed => edgeKey(ed) === k)) {
        const em = { ...e.edgeMeta }; delete em[k];
        return { ...e, addedEdges: e.addedEdges.filter(ed => edgeKey(ed) !== k), edgeMeta: em };
      }
      // If it's in base edges
      const inBase = BASE_EDGES.some(ed => edgeKey(ed) === k);
      if (inBase) {
        const isRemoved = e.removedEdges.some(ed => edgeKey(ed) === k);
        if (isRemoved) {
          return { ...e, removedEdges: e.removedEdges.filter(ed => edgeKey(ed) !== k) };
        }
        return { ...e, removedEdges: [...e.removedEdges, [a,b]] };
      }
      // Otherwise add new
      const em = type ? { ...e.edgeMeta, [k]: { type } } : e.edgeMeta;
      return { ...e, addedEdges: [...e.addedEdges, [a,b]], edgeMeta: em };
    });
  },
  setEdgeType(a, b, type) {
    this.set(e => {
      const k = edgeKey([a,b]);
      return { ...e, edgeMeta: { ...e.edgeMeta, [k]: { ...(e.edgeMeta[k]||{}), type } } };
    });
  },

  // ---- Shuttle stops ----
  // Adds a brand-new stop. Returns the new id.
  addShuttle(x, y, opts = {}) {
    const id = uid("stop");
    const name = opts.name || "New shuttle stop";
    this.set(e => ({ ...e, addedShuttle: { ...e.addedShuttle,
      [id]: { id, x: Math.round(x), y: Math.round(y), name }
    }}));
    return id;
  },
  // Patches a stop. Routes base-stop edits through `shuttle{}`, added-stop
  // edits through `addedShuttle{}`. Caller doesn't need to know which.
  updateShuttle(id, partial) {
    this.set(e => {
      if (e.addedShuttle[id]) {
        return { ...e, addedShuttle: { ...e.addedShuttle, [id]: { ...e.addedShuttle[id], ...partial } } };
      }
      return { ...e, shuttle: { ...e.shuttle, [id]: { ...(e.shuttle[id]||{}), ...partial } } };
    });
  },
  // Removes a stop. Added stops are dropped outright; base stops get a
  // tombstone so they stay removed on reload.
  deleteShuttle(id) {
    this.set(e => {
      if (e.addedShuttle[id]) {
        const a = { ...e.addedShuttle }; delete a[id];
        return { ...e, addedShuttle: a };
      }
      return { ...e, shuttle: { ...e.shuttle, [id]: { ...(e.shuttle[id]||{}), _deleted: true } } };
    });
  },

  // ---- Entrances ----
  addEntrance(x, y, opts = {}) {
    const id = uid("ent");
    this.set(e => ({ ...e, entrances: { ...e.entrances,
      [id]: { id, x, y, accessible: !!opts.accessible, building: opts.building || "", label: opts.label || "" }
    }}));
    return id;
  },
  updateEntrance(id, partial) {
    this.set(e => {
      if (!e.entrances[id]) return e;
      return { ...e, entrances: { ...e.entrances, [id]: { ...e.entrances[id], ...partial } } };
    });
  },
  deleteEntrance(id) {
    this.set(e => {
      const ee = { ...e.entrances }; delete ee[id];
      return { ...e, entrances: ee };
    });
  },

  // ---- Crosswalks ----
  addCrosswalk(x, y, opts = {}) {
    const id = uid("xw");
    this.set(e => ({ ...e, crosswalks: { ...e.crosswalks,
      [id]: { id, x, y, angle: opts.angle ?? 0, label: opts.label || "" }
    }}));
    return id;
  },
  updateCrosswalk(id, partial) {
    this.set(e => {
      if (!e.crosswalks[id]) return e;
      return { ...e, crosswalks: { ...e.crosswalks, [id]: { ...e.crosswalks[id], ...partial } } };
    });
  },
  deleteCrosswalk(id) {
    this.set(e => {
      const cc = { ...e.crosswalks }; delete cc[id];
      return { ...e, crosswalks: cc };
    });
  },

  // ---- Points of Interest ----
  // POI shape: { id, x, y, name, instructions, events: [{title,when,note}], mapsLink }
  addPOI(x, y, opts = {}) {
    const id = uid("poi");
    const type = (window.POI_TYPES && window.POI_TYPES[opts.type]) ? opts.type : "general";
    // Default the name from the type label so a fresh, untitled POI still
    // reads sensibly (e.g. "Bike locker") instead of the generic placeholder.
    const defaultName = (window.POI_TYPES && window.POI_TYPES[type]?.label) || "Point of interest";
    this.set(e => ({ ...e, pois: { ...(e.pois || {}),
      [id]: {
        id, x, y,
        type,
        name: opts.name || defaultName,
        instructions: opts.instructions || "",
        events: opts.events || [],
        building: opts.building || "",     // building this POI is hosted at / near
        mapsQuery: opts.mapsQuery || "",   // fallback place name / address
        mapsLink: opts.mapsLink || "",     // legacy: literal URL; kept for back-compat reads
      }
    }}));
    return id;
  },
  updatePOI(id, partial) {
    this.set(e => {
      const cur = (e.pois || {})[id];
      if (!cur) return e;
      return { ...e, pois: { ...e.pois, [id]: { ...cur, ...partial } } };
    });
  },
  deletePOI(id) {
    this.set(e => {
      const pp = { ...(e.pois || {}) }; delete pp[id];
      return { ...e, pois: pp };
    });
  },

  // ---- Construction Zones ----
  // Polygon shape: { id, name, points:[[x,y],...], startDate, endDate, affects:[codes], notes }
  addConstructionZone(points, opts = {}) {
    const id = uid("cz");
    this.set(e => ({ ...e, constructionZones: { ...(e.constructionZones || {}),
      [id]: {
        id,
        name: opts.name || "Construction zone",
        points: points.map(p => [+p[0], +p[1]]),
        startDate: opts.startDate || null,
        endDate: opts.endDate || null,
        affects: opts.affects || [],
        notes: opts.notes || "",
      }
    }}));
    return id;
  },
  updateConstructionZone(id, partial) {
    this.set(e => {
      const cur = (e.constructionZones || {})[id];
      if (!cur) return e;
      return { ...e, constructionZones: { ...e.constructionZones, [id]: { ...cur, ...partial } } };
    });
  },
  deleteConstructionZone(id) {
    this.set(e => {
      const cz = { ...(e.constructionZones || {}) }; delete cz[id];
      return { ...e, constructionZones: cz };
    });
  },
  // Polygon vertex manipulation for construction zones — mirrors building polygon helpers.
  // Construction often blocks only a portion of a building, so editors need to trim or
  // reshape the auto-generated polygon after marking a building as under construction.
  setConstructionZonePoint(id, idx, x, y) {
    this.set(e => {
      const z = (e.constructionZones || {})[id];
      if (!z || !z.points) return e;
      const pts = z.points.map((p, i) => i === idx ? [Math.round(x), Math.round(y)] : p);
      return { ...e, constructionZones: { ...e.constructionZones, [id]: { ...z, points: pts } } };
    });
  },
  insertConstructionZonePoint(id, idx, x, y) {
    this.set(e => {
      const z = (e.constructionZones || {})[id];
      if (!z || !z.points) return e;
      const pts = [...z.points.slice(0, idx + 1), [Math.round(x), Math.round(y)], ...z.points.slice(idx + 1)];
      return { ...e, constructionZones: { ...e.constructionZones, [id]: { ...z, points: pts } } };
    });
  },
  deleteConstructionZonePoint(id, idx) {
    this.set(e => {
      const z = (e.constructionZones || {})[id];
      if (!z || !z.points || z.points.length <= 3) return e; // keep min 3 vertices
      const pts = z.points.filter((_, i) => i !== idx);
      return { ...e, constructionZones: { ...e.constructionZones, [id]: { ...z, points: pts } } };
    });
  },
  moveConstructionZonePolygon(id, dx, dy) {
    this.set(e => {
      const z = (e.constructionZones || {})[id];
      if (!z || !z.points) return e;
      const pts = z.points.map(p => [Math.round(p[0] + dx), Math.round(p[1] + dy)]);
      return { ...e, constructionZones: { ...e.constructionZones, [id]: { ...z, points: pts } } };
    });
  },

  exportJSON() {
    return JSON.stringify(readEdits(), null, 2);
  },
  importJSON(text) {
    // Accepts either the raw edits shape OR the "full data" shape (BUILDINGS/etc)
    const parsed = JSON.parse(text);
    if (parsed.buildings || parsed.addedNodes || parsed.entrances || parsed.crosswalks) {
      // Edits shape — merge with defaults & save
      const merged = { ...defaultEdits(), ...parsed };
      writeEdits(merged); applyEdits(merged); notifyEdits();
      return;
    }
    if (parsed.BUILDINGS || parsed.GRAPH_NODES) {
      // Full snapshot — derive a minimal edits shape by diffing against base
      const next = defaultEdits();
      // Buildings: store overrides for any field that differs
      if (Array.isArray(parsed.BUILDINGS)) {
        for (const b of parsed.BUILDINGS) {
          const base = BASE_BUILDINGS.find(x => x.code === b.code);
          if (!base) continue;
          const diff = {};
          for (const k of ["x","y","w","h","label","points"]) {
            if (k in b && JSON.stringify(b[k]) !== JSON.stringify(base[k])) diff[k] = b[k];
          }
          if (Object.keys(diff).length) next.buildings[b.code] = diff;
        }
      }
      // Nodes
      if (parsed.GRAPH_NODES) {
        for (const [id, n] of Object.entries(parsed.GRAPH_NODES)) {
          if (BASE_NODES[id]) {
            const base = BASE_NODES[id];
            if (n.x !== base.x || n.y !== base.y || (n.label && n.label !== base.label)) {
              next.nodes[id] = { ...n };
            }
          } else {
            next.addedNodes[id] = { ...n };
          }
        }
      }
      // Edges
      if (Array.isArray(parsed.GRAPH_EDGES)) {
        const baseSet = new Set(BASE_EDGES.map(e => edgeKey(e)));
        const newSet = new Set(parsed.GRAPH_EDGES.map(e => edgeKey(e)));
        for (const e of parsed.GRAPH_EDGES) {
          if (!baseSet.has(edgeKey(e))) next.addedEdges.push([e[0], e[1]]);
        }
        for (const e of BASE_EDGES) {
          if (!newSet.has(edgeKey(e))) next.removedEdges.push([e[0], e[1]]);
        }
      }
      if (parsed.EDGE_META) next.edgeMeta = { ...parsed.EDGE_META };
      // Shuttle
      if (Array.isArray(parsed.SHUTTLE_STOPS)) {
        for (const s of parsed.SHUTTLE_STOPS) {
          const base = BASE_SHUTTLE.find(x => x.id === s.id);
          if (base && (s.x !== base.x || s.y !== base.y)) next.shuttle[s.id] = { x: s.x, y: s.y };
        }
      }
      // Entrances + crosswalks
      if (Array.isArray(parsed.ENTRANCES)) {
        for (const en of parsed.ENTRANCES) next.entrances[en.id] = { ...en };
      }
      if (Array.isArray(parsed.CROSSWALKS)) {
        for (const xw of parsed.CROSSWALKS) next.crosswalks[xw.id] = { ...xw };
      }
      writeEdits(next); applyEdits(next); notifyEdits();
      return;
    }
    throw new Error("Unrecognised JSON shape — expected an exported edits or full-data snapshot.");
  },
  exportFullData() {
    const out = {
      BUILDINGS: window.BUILDINGS.map(b => ({ ...b })),
      GRAPH_NODES: { ...window.GRAPH_NODES },
      GRAPH_EDGES: window.GRAPH_EDGES.map(e => [...e]),
      EDGE_META: { ...window.EDGE_META },
      SHUTTLE_STOPS: window.SHUTTLE_STOPS.map(s => ({ ...s })),
      ENTRANCES: window.ENTRANCES.map(x => ({ ...x })),
      CROSSWALKS: window.CROSSWALKS.map(x => ({ ...x })),
    };
    return JSON.stringify(out, null, 2);
  },

  // ----- Floor plan editing -------------------------------------------------
  // All floor-plan writes go through these helpers so we centralise the
  // "lazy-init the override branch" boilerplate. The bucket path looks like:
  //   edits.floorplan[buildingCode].floors[levelId] = { boundary, nodes, edges }
  //
  // Why we look up via window.getFloors() rather than the raw override:
  // floor data is the union of seeded data (from MEDICAL_CENTER_DIAGRAM +
  // auto-generated room nodes built in buildSeedFloors) and any persisted
  // override. A user mutating a seeded room (e.g. moving Admissions) needs
  // the operation to see the seeded room as the base, otherwise the move
  // silently no-ops because the raw override only contains nodes the user
  // has already touched. Pulling the full merged view as `base` here means
  // the FIRST mutation on a seeded node materialises it into the override;
  // subsequent edits stay in the override path.
  _patchFloor(e, buildingCode, levelId, fn) {
    const fp = e.floorplan || {};
    const bld = fp[buildingCode] || { floors: {} };
    const floors = bld.floors || {};
    // Full merged view (seed ∪ override) — the operating-on-known-data base.
    const merged = (window.getFloors && window.getFloors(buildingCode)) || {};
    const m = merged[levelId];
    const base = m
      ? {
          boundary: (m.boundary || []).map(p => [...p]),
          nodes: JSON.parse(JSON.stringify(m.nodes || {})),
          edges: (m.edges || []).map(x => [...x]),
        }
      : { boundary: [], nodes: {}, edges: [] };
    const next = fn(base);
    return {
      ...e,
      floorplan: {
        ...fp,
        [buildingCode]: {
          ...bld,
          floors: { ...floors, [levelId]: next },
        },
      },
    };
  },
  // Replace the boundary polygon (e.g. after a vertex drag or polygon edit).
  setFloorBoundary(buildingCode, levelId, points) {
    this.set(e => this._patchFloor(e, buildingCode, levelId, f => ({ ...f, boundary: points })));
  },
  // Add a node. `opts` provides everything except id — id is generated here
  // so callers can't accidentally collide with existing nodes on this floor.
  addFloorNode(buildingCode, levelId, opts) {
    const id = opts.id || uid(`fp_${levelId}_${opts.kind || "n"}`);
    const node = { id, kind: opts.kind || "walk", x: opts.x, y: opts.y };
    if (opts.elevatorId) node.elevatorId = opts.elevatorId;
    if (opts.roomN != null) node.roomN = opts.roomN;
    if (opts.label) node.label = opts.label;
    this.set(e => this._patchFloor(e, buildingCode, levelId, f => ({
      ...f,
      nodes: { ...f.nodes, [id]: node },
    })));
    return id;
  },
  moveFloorNode(buildingCode, levelId, nodeId, x, y) {
    this.set(e => this._patchFloor(e, buildingCode, levelId, f => {
      const n = f.nodes[nodeId];
      if (!n) return f;
      return { ...f, nodes: { ...f.nodes, [nodeId]: { ...n, x, y } } };
    }));
  },
  updateFloorNode(buildingCode, levelId, nodeId, patch) {
    this.set(e => this._patchFloor(e, buildingCode, levelId, f => {
      const n = f.nodes[nodeId];
      if (!n) return f;
      return { ...f, nodes: { ...f.nodes, [nodeId]: { ...n, ...patch } } };
    }));
  },
  deleteFloorNode(buildingCode, levelId, nodeId) {
    this.set(e => this._patchFloor(e, buildingCode, levelId, f => {
      const nodes = { ...f.nodes }; delete nodes[nodeId];
      const edges = f.edges.filter(([a, b]) => a !== nodeId && b !== nodeId);
      return { ...f, nodes, edges };
    }));
  },
  addFloorEdge(buildingCode, levelId, a, b) {
    if (a === b) return;
    this.set(e => this._patchFloor(e, buildingCode, levelId, f => {
      // Treat edges as undirected — guard against [a,b] AND [b,a] duplicates.
      const exists = f.edges.some(([x, y]) =>
        (x === a && y === b) || (x === b && y === a));
      if (exists) return f;
      return { ...f, edges: [...f.edges, [a, b]] };
    }));
  },
  removeFloorEdge(buildingCode, levelId, a, b) {
    this.set(e => this._patchFloor(e, buildingCode, levelId, f => ({
      ...f,
      edges: f.edges.filter(([x, y]) =>
        !((x === a && y === b) || (x === b && y === a))),
    })));
  },
  // Wipe overrides for one floor and revert to the seed.
  resetFloor(buildingCode, levelId) {
    this.set(e => {
      const fp = e.floorplan || {};
      const bld = fp[buildingCode] || { floors: {} };
      const floors = { ...bld.floors }; delete floors[levelId];
      return {
        ...e,
        floorplan: {
          ...fp,
          [buildingCode]: { ...bld, floors },
        },
      };
    });
  },
};

// Initialise from storage
applyEdits(readEdits());

window.editStore = editStore;

// React hook: re-render on edit changes
function useEdits() {
  const [, force] = React.useState(0);
  React.useEffect(() => editStore.subscribe(() => force(x => x+1)), []);
  return editStore.get();
}
window.useEdits = useEdits;
