// The shared editor: palette sidebar, place/erase/paint gestures, pan/zoom
// gestures, drag-and-drop, save/load JSON, localStorage autosave, undo/redo,
// play-from-here. Knows nothing about any specific game — see LeveledAdapter.
import type { Cell, EntityDef, LevelDoc, LeveledAdapter } from './types';
import { cloneDoc, parseDoc } from './doc';

const HINT_HTML =
  'L-click place · R-click delete · drag-paint<br/>or drag a tool onto the grid<br/>' +
  'middle/space-drag or arrows: pan<br/>scroll: zoom · Ctrl+Z / Ctrl+Y: undo / redo';

const UNDO_DEPTH = 80;

export class Leveled {
  doc: LevelDoc;
  selected: string;

  private adapter: LeveledAdapter;
  private defs = new Map<string, EntityDef>();
  private root: HTMLElement;
  private itemEls = new Map<string, HTMLElement>();
  private fileInput: HTMLInputElement;
  private toastEl: HTMLElement;
  private toastTimer = 0;
  private roomSelect: HTMLSelectElement | null = null;

  private active = false;
  private panning = false;
  private panPointerId = -1;
  private panLastX = 0;
  private panLastY = 0;
  private spaceHeld = false;
  private strokeOpen = false;
  private strokeSnapshot: string | null = null;
  private undoStack: string[] = [];
  private redoStack: string[] = [];

  constructor(adapter: LeveledAdapter, parent: HTMLElement, doc: LevelDoc) {
    this.adapter = adapter;
    this.doc = doc;
    for (const d of adapter.entities) this.defs.set(d.type, d);
    this.selected = adapter.entities[0]?.type ?? '';

    const root = document.createElement('aside');
    root.className = 'palette hidden';
    root.innerHTML = `
      <h1>${adapter.title}</h1>
      <h2>LEVEL EDITOR</h2>
      <div class="room-row hidden"><label>ROOM</label><select class="room-select"></select></div>
      <div class="tools"></div>
      <p class="hint">${HINT_HTML}</p>
      <div class="actions">
        <button class="act primary" data-act="play">▶ &nbsp;Play from here</button>
        <div class="actrow">
          <button class="act" data-act="undo">↶ Undo</button>
          <button class="act" data-act="redo">↷ Redo</button>
        </div>
        <button class="act" data-act="save">⬇ &nbsp;Save Level</button>
        <button class="act" data-act="load">⬆ &nbsp;Load Level</button>
        <button class="act danger" data-act="reset">↺ &nbsp;Reset to default</button>
      </div>
      <div class="toast"></div>
    `;
    parent.appendChild(root);
    this.root = root;
    this.toastEl = root.querySelector('.toast') as HTMLElement;

    const toolsBox = root.querySelector('.tools') as HTMLElement;
    for (const d of adapter.entities) {
      const el = document.createElement('button');
      el.className = 'tool';
      el.draggable = true;
      el.innerHTML =
        `<span class="swatch${d.round ? ' round' : ''}"></span>` +
        `<span class="tlabel">${d.label}<small>${d.hint}</small></span><em class="count"></em>`;
      (el.querySelector('.swatch') as HTMLElement).style.background = d.icon;
      el.addEventListener('click', () => this.select(d.type));
      el.addEventListener('dragstart', (e) => {
        this.select(d.type);
        e.dataTransfer?.setData('text/plain', d.type);
        if (e.dataTransfer) e.dataTransfer.effectAllowed = 'copy';
      });
      toolsBox.appendChild(el);
      this.itemEls.set(d.type, el);
    }
    if (this.selected) this.select(this.selected);

    // room selector (multi-level adapters only)
    if (adapter.variants) {
      const row = root.querySelector('.room-row') as HTMLElement;
      row.classList.remove('hidden');
      this.roomSelect = row.querySelector('.room-select') as HTMLSelectElement;
      for (const v of adapter.variants.list()) {
        const opt = document.createElement('option');
        opt.value = v.id;
        opt.textContent = v.label;
        this.roomSelect.appendChild(opt);
      }
      this.roomSelect.value = adapter.variants.current();
      this.roomSelect.addEventListener('change', () => {
        const v = this.adapter.variants;
        if (!v || !this.roomSelect) return;
        this.persist(); // keep the outgoing room's edits
        this.setDoc(v.select(this.roomSelect.value), { fit: true, resetHistory: true });
        this.showToast(`editing "${this.roomSelect.selectedOptions[0]?.textContent ?? this.roomSelect.value}"`);
      });
    }

    this.fileInput = document.createElement('input');
    this.fileInput.type = 'file';
    this.fileInput.accept = '.json,application/json';
    this.fileInput.hidden = true;
    root.appendChild(this.fileInput);
    this.fileInput.addEventListener('change', () => void this.handleFile());

    root.querySelector('[data-act="play"]')!.addEventListener('click', () => {
      this.adapter.enterPlayMode(cloneDoc(this.doc));
    });
    root.querySelector('[data-act="save"]')!.addEventListener('click', () => this.saveToFile());
    root.querySelector('[data-act="load"]')!.addEventListener('click', () => this.fileInput.click());
    root.querySelector('[data-act="undo"]')!.addEventListener('click', () => this.undo());
    root.querySelector('[data-act="redo"]')!.addEventListener('click', () => this.redo());
    root.querySelector('[data-act="reset"]')!.addEventListener('click', () => {
      if (confirm('Reset to the default level? Current edit will be replaced.')) {
        this.setDoc(this.adapter.defaultLevel(), { fit: true });
        this.showToast('reset to default level');
      }
    });

    this.bindCanvas();
    this.bindKeys();
  }

  // ---------- visibility ----------

  show(): void {
    this.active = true;
    this.root.classList.remove('hidden');
    if (this.roomSelect && this.adapter.variants) this.roomSelect.value = this.adapter.variants.current();
    this.updateCounts();
  }

  hide(): void {
    this.active = false;
    this.panning = false;
    this.strokeOpen = false;
    this.spaceHeld = false;
    this.root.classList.add('hidden');
  }

  get isActive(): boolean {
    return this.active;
  }

  // ---------- doc management ----------

  /** Replace the working doc (load/reset/variant switch/external hook). */
  setDoc(doc: LevelDoc, opts: { fit?: boolean; resetHistory?: boolean } = {}): void {
    if (opts.resetHistory !== false) {
      this.undoStack.length = 0;
      this.redoStack.length = 0;
    }
    this.doc = doc;
    this.adapter.onChange(this.doc);
    this.persist();
    this.updateCounts();
    if (opts.fit) this.adapter.view.fit();
    if (this.roomSelect && this.adapter.variants) this.roomSelect.value = this.adapter.variants.current();
  }

  /** Parse arbitrary JSON (v2 or legacy) and adopt it. Throws on bad data. */
  loadRaw(raw: unknown): void {
    let doc = parseDoc(raw, this.adapter);
    if (this.adapter.adoptLoaded) doc = this.adapter.adoptLoaded(doc);
    this.setDoc(doc, { fit: true });
  }

  persist(): void {
    try {
      localStorage.setItem(this.adapter.storageKey(this.doc), JSON.stringify(this.doc));
    } catch {
      /* storage full / blocked — non-fatal */
    }
  }

  private select(type: string): void {
    this.selected = type;
    for (const [id, el] of this.itemEls) el.classList.toggle('selected', id === type);
  }

  private updateCounts(): void {
    for (const [type, el] of this.itemEls) {
      const n = this.doc.entities.reduce((acc, e) => acc + (e.type === type ? 1 : 0), 0);
      (el.querySelector('.count') as HTMLElement).textContent = String(n);
    }
  }

  // ---------- undo / redo ----------

  private beginStroke(): void {
    this.strokeOpen = true;
    this.strokeSnapshot = null;
  }

  private endStroke(): void {
    this.strokeOpen = false;
    this.strokeSnapshot = null;
  }

  /** Run a doc mutation; snapshot once per gesture for undo. */
  private mutate(fn: () => boolean): void {
    const before = JSON.stringify(this.doc);
    if (!fn()) return;
    if (!this.strokeOpen || this.strokeSnapshot === null) {
      this.undoStack.push(before);
      if (this.undoStack.length > UNDO_DEPTH) this.undoStack.shift();
      this.redoStack.length = 0;
      if (this.strokeOpen) this.strokeSnapshot = before;
    }
    this.commit();
  }

  private commit(): void {
    this.adapter.onChange(this.doc);
    this.persist();
    this.updateCounts();
  }

  undo(): void {
    const prev = this.undoStack.pop();
    if (prev === undefined) {
      this.showToast('nothing to undo');
      return;
    }
    this.redoStack.push(JSON.stringify(this.doc));
    this.doc = JSON.parse(prev) as LevelDoc;
    this.commit();
  }

  redo(): void {
    const next = this.redoStack.pop();
    if (next === undefined) {
      this.showToast('nothing to redo');
      return;
    }
    this.undoStack.push(JSON.stringify(this.doc));
    this.doc = JSON.parse(next) as LevelDoc;
    this.commit();
  }

  // ---------- placement ----------

  place(type: string, cell: Cell | null): void {
    if (!cell || !this.active) return;
    const def = this.defs.get(type);
    if (!def) return;
    const [x, y] = def.snapCell ? def.snapCell(cell[0], cell[1], this.doc.size) : cell;
    const at = (t: string): boolean => this.doc.entities.some((e) => e.type === t && e.x === x && e.y === y);
    if (at(type)) return;
    if (def.blockedBy?.some(at)) return;

    this.mutate(() => {
      if (def.displaces?.length) {
        this.doc.entities = this.doc.entities.filter(
          (e) => !(e.x === x && e.y === y && def.displaces!.includes(e.type)),
        );
      }
      if (!def.multiple) {
        const existing = this.doc.entities.find((e) => e.type === type);
        if (existing) {
          existing.x = x;
          existing.y = y;
          return true;
        }
      }
      const props = def.defaultProps?.(x, y);
      const ent: { type: string; x: number; y: number; props?: Record<string, unknown> } = { type, x, y };
      if (props) ent.props = props;
      this.doc.entities.push(ent);
      return true;
    });
  }

  erase(cell: Cell | null): void {
    if (!cell || !this.active) return;
    const [x, y] = cell;
    // candidates under the cursor, topmost layer first; permanents are move-only
    const hits = this.doc.entities
      .map((e) => ({ e, def: this.defs.get(e.type) }))
      .filter(({ e, def }) => {
        if (!def || def.permanent) return false;
        return def.hitTest ? def.hitTest(e, x, y) : e.x === x && e.y === y;
      })
      .sort((a, b) => (b.def!.layer ?? 0) - (a.def!.layer ?? 0));
    const top = hits[0];
    if (!top) return;
    this.mutate(() => {
      this.doc.entities = this.doc.entities.filter((e) => e !== top.e);
      return true;
    });
  }

  // ---------- gestures ----------

  private bindCanvas(): void {
    const el = this.adapter.view.element;

    el.addEventListener('contextmenu', (e) => {
      if (this.active) e.preventDefault();
    });

    el.addEventListener('pointerdown', (e) => {
      if (!this.active) return;
      // middle-drag or hold-Space + left-drag pans the camera
      if (e.button === 1 || (e.button === 0 && this.spaceHeld)) {
        e.preventDefault();
        this.panning = true;
        this.panPointerId = e.pointerId;
        this.panLastX = e.clientX;
        this.panLastY = e.clientY;
        el.setPointerCapture(e.pointerId);
        return;
      }
      if (e.button === 0) {
        this.beginStroke();
        this.place(this.selected, this.cellAt(e));
      } else if (e.button === 2) {
        this.beginStroke();
        this.erase(this.cellAt(e));
      }
    });

    el.addEventListener('pointermove', (e) => {
      if (!this.active) return;
      if (this.panning && e.pointerId === this.panPointerId) {
        this.adapter.view.pan(e.clientX - this.panLastX, e.clientY - this.panLastY);
        this.panLastX = e.clientX;
        this.panLastY = e.clientY;
        return;
      }
      // drag-paint: hold LMB to paint paintable tiles, hold RMB to erase
      if (e.buttons & 1 && this.defs.get(this.selected)?.paint) {
        this.place(this.selected, this.cellAt(e));
      } else if (e.buttons & 2) {
        this.erase(this.cellAt(e));
      }
    });

    window.addEventListener('pointerup', () => {
      this.panning = false;
      this.endStroke();
    });

    el.addEventListener(
      'wheel',
      (e) => {
        if (!this.active) return;
        e.preventDefault();
        this.adapter.view.zoom(e.deltaY);
      },
      { passive: false },
    );

    // HTML5 drag-and-drop from the palette
    el.addEventListener('dragover', (e) => {
      if (!this.active) return;
      e.preventDefault();
      if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
    });
    el.addEventListener('drop', (e) => {
      if (!this.active) return;
      e.preventDefault();
      const t = e.dataTransfer?.getData('text/plain') ?? '';
      if (!this.defs.has(t)) return;
      this.place(t, this.cellAt(e));
    });
  }

  private cellAt(e: { clientX: number; clientY: number }): Cell | null {
    return this.adapter.view.pointerToCell(e.clientX, e.clientY);
  }

  private bindKeys(): void {
    window.addEventListener('keydown', (e) => {
      if (!this.active) return;
      const t = e.target as HTMLElement | null;
      if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT')) return;
      if (e.code === 'Space') {
        this.spaceHeld = true;
        e.preventDefault();
      } else if ((e.ctrlKey || e.metaKey) && e.code === 'KeyZ') {
        e.preventDefault();
        if (e.shiftKey) this.redo();
        else this.undo();
      } else if ((e.ctrlKey || e.metaKey) && e.code === 'KeyY') {
        e.preventDefault();
        this.redo();
      }
    });
    window.addEventListener('keyup', (e) => {
      if (e.code === 'Space') this.spaceHeld = false;
    });
    window.addEventListener('blur', () => {
      this.spaceHeld = false;
      this.panning = false;
    });
  }

  // ---------- file I/O ----------

  saveToFile(): void {
    const name = this.adapter.fileName(this.doc);
    const blob = new Blob([JSON.stringify(this.doc, null, 2)], { type: 'application/json' });
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url;
    a.download = name;
    document.body.appendChild(a);
    a.click();
    a.remove();
    setTimeout(() => URL.revokeObjectURL(url), 5000);
    this.showToast(`level saved to ${name}`);
  }

  private async handleFile(): Promise<void> {
    const file = this.fileInput.files?.[0];
    this.fileInput.value = '';
    if (!file) return;
    try {
      this.loadRaw(await file.text());
      this.showToast(`loaded "${file.name}"`);
    } catch (err) {
      this.showToast(err instanceof Error ? err.message : 'could not load level', true);
    }
  }

  private showToast(msg: string, isError = false): void {
    this.toastEl.textContent = msg;
    this.toastEl.classList.toggle('error', isError);
    this.toastEl.classList.add('show');
    window.clearTimeout(this.toastTimer);
    this.toastTimer = window.setTimeout(() => this.toastEl.classList.remove('show'), 2600);
  }
}
