import Phaser from 'phaser';
import { UI, chunkyButton, font, panel } from './style';
import { drunkify, typedLength, wobbleAt, type RngLike } from './typo';

// Bottom-third dialogue for the door's sobriety test and the floor's cut-off
// chats. Deliberately ignorant of who is talking — the caller supplies a speaker
// tag, a line, and up to four things you can say back.

const PANEL_X = 320;
const PANEL_Y = 300;
const PANEL_W = 600;
const PANEL_H = 96;
const PAD = 10;
const BODY_SIZE = 10;
const CHOICE_H = 16;
const CHOICE_GAP = 8;
const CHOICE_MAX_W = 140;

export interface DialogueChoice {
  id: string;
  label: string;
}

export interface DialogueOptions {
  speaker: string;
  text: string;
  choices?: readonly DialogueChoice[];
  /** 0..1 — drives drunkify() plus per-character wobble. */
  drunkenness?: number;
  /** Required when drunkenness > 0; a deterministic stand-in is used if absent. */
  rng?: RngLike;
  charMs?: number;
  depth?: number;
  sfx?: { typeTick(): void };
  onChoice?: (id: string) => void;
  onComplete?: () => void;
}

interface Glyph {
  ch: string;
  /** Index into the mangled source string — what the reveal count is measured in. */
  index: number;
  x: number;
  y: number;
}

/** LCG stand-in so a caller who forgot the rng gets slurring, not a crashed night. */
function fallbackRng(): RngLike {
  let s = 0x2f6e2b1;
  return {
    next: (): number => {
      s = (s * 1664525 + 1013904223) >>> 0;
      return s / 0x100000000;
    },
  };
}

/**
 * Greedy word wrap that keeps every glyph's source index, which Phaser's own
 * wordWrap throws away. The drunk path needs the mapping to reveal characters in
 * source order while drawing them in wrapped positions.
 */
function layoutGlyphs(text: string, maxCols: number, charW: number, lineH: number, x0: number, y0: number): Glyph[] {
  const out: Glyph[] = [];
  let line = 0;
  let col = 0;
  let i = 0;

  const push = (ch: string, index: number): void => {
    out.push({ ch, index, x: x0 + col * charW, y: y0 + line * lineH });
    col++;
  };

  while (i < text.length) {
    const ch = text.charAt(i);
    if (ch === '\n') {
      line++;
      col = 0;
      i++;
      continue;
    }
    if (ch === ' ') {
      if (col > 0) push(ch, i);
      i++;
      continue;
    }

    let end = i;
    while (end < text.length && text.charAt(end) !== ' ' && text.charAt(end) !== '\n') end++;

    if (col > 0 && col + (end - i) > maxCols) {
      // The space that pushed us over hangs off the end of the line — drop it.
      if (out.at(-1)?.ch === ' ') out.pop();
      line++;
      col = 0;
    }
    for (let k = i; k < end; k++) {
      if (col >= maxCols) {
        line++;
        col = 0;
      }
      push(text.charAt(k), k);
    }
    i = end;
  }
  return out;
}

export class DialogueBox {
  private readonly root: Phaser.GameObjects.Container;
  private readonly body: Phaser.GameObjects.Text | null = null;
  private readonly glyphs: Glyph[] = [];
  private readonly glyphTexts: Phaser.GameObjects.Text[] = [];
  private readonly buttons: Phaser.GameObjects.Container[] = [];

  private readonly full: string;
  private readonly charMs: number;
  private readonly drunkenness: number;
  private readonly sfx: { typeTick(): void } | undefined;
  private readonly onChoice: ((id: string) => void) | undefined;
  private readonly onComplete: (() => void) | undefined;

  private elapsed = 0;
  private phase = 0;
  private visible = 0;
  private finished = false;
  private completed = false;
  private locked = false;
  private destroyed = false;

  constructor(scene: Phaser.Scene, opts: DialogueOptions) {
    this.charMs = opts.charMs ?? 28;
    this.drunkenness = Math.max(0, Math.min(1, opts.drunkenness ?? 0));
    this.sfx = opts.sfx;
    this.onChoice = opts.onChoice;
    this.onComplete = opts.onComplete;

    // Mangle once at construction; per-frame drunkify would shimmer the line.
    this.full =
      this.drunkenness > 0
        ? drunkify(opts.text, { drunkenness: this.drunkenness, rng: opts.rng ?? fallbackRng() })
        : opts.text;

    this.root = panel(scene, PANEL_X, PANEL_Y, PANEL_W, PANEL_H);
    this.root.setDepth(opts.depth ?? 900);

    const tag = scene.add.text(0, 0, opts.speaker, font(9, UI.ink)).setOrigin(0, 0.5);
    const tagBg = scene.add
      .rectangle(-PANEL_W / 2 + PAD - 4, -PANEL_H / 2, tag.width + 10, 13, UI.kebabAmber)
      .setOrigin(0, 0.5);
    tag.setPosition(-PANEL_W / 2 + PAD + 1, -PANEL_H / 2);
    this.root.add([tagBg, tag]);

    const bodyX = -PANEL_W / 2 + PAD;
    const bodyY = -PANEL_H / 2 + 14;
    const wrapPx = PANEL_W - PAD * 2;

    if (this.drunkenness > 0) {
      const probe = scene.add.text(0, 0, 'M', font(BODY_SIZE, UI.paper));
      const charW = probe.width > 0 ? probe.width : BODY_SIZE * 0.6;
      const lineH = Math.max(probe.height, BODY_SIZE + 2);
      probe.destroy();

      const cols = Math.max(1, Math.floor(wrapPx / charW));
      this.glyphs = layoutGlyphs(this.full, cols, charW, lineH, bodyX, bodyY);
      for (const g of this.glyphs) {
        const t = scene.add.text(g.x, g.y, g.ch, font(BODY_SIZE, UI.paper)).setOrigin(0, 0).setVisible(false);
        this.glyphTexts.push(t);
        this.root.add(t);
      }
    } else {
      this.body = scene.add
        .text(bodyX, bodyY, '', { ...font(BODY_SIZE, UI.paper), wordWrap: { width: wrapPx } })
        .setOrigin(0, 0);
      this.root.add(this.body);
    }

    this.buildChoices(scene, opts.choices ?? []);
  }

  private buildChoices(scene: Phaser.Scene, choices: readonly DialogueChoice[]): void {
    if (choices.length === 0) return;
    const n = Math.min(choices.length, 4);
    const avail = PANEL_W - PAD * 2;
    const w = Math.min(CHOICE_MAX_W, (avail - CHOICE_GAP * (n - 1)) / n);
    const rowW = w * n + CHOICE_GAP * (n - 1);
    const y = PANEL_H / 2 - CHOICE_H / 2 - 6;

    for (let i = 0; i < n; i++) {
      const choice = choices[i];
      if (choice === undefined) continue;
      const x = -rowW / 2 + w / 2 + i * (w + CHOICE_GAP);
      const btn = chunkyButton(scene, x, y, choice.label, {
        w,
        h: CHOICE_H,
        fill: UI.grime,
        textColour: UI.neonGreen,
        fontSize: 8,
        onClick: () => this.pick(choice.id),
      });
      btn.setVisible(false);
      this.buttons.push(btn);
      this.root.add(btn);
    }
  }

  private pick(id: string): void {
    if (this.destroyed || this.locked) return;
    this.locked = true;
    for (const btn of this.buttons) {
      btn.setAlpha(0.55);
      for (const child of btn.list) child.disableInteractive();
    }
    this.onChoice?.(id);
  }

  update(deltaMs: number): void {
    if (this.destroyed) return;
    // Both accumulators below are permanent state, so one non-finite delta would
    // stall the reveal for the rest of the night with the buttons never shown.
    // typo.ts clamps its own inputs too, but that only stops the NaN spreading —
    // dropping the bad frame here is what keeps `elapsed` usable.
    if (!Number.isFinite(deltaMs)) return;
    if (this.drunkenness > 0) this.phase += deltaMs * 0.006;

    if (!this.finished) {
      this.elapsed += deltaMs;
      this.reveal(typedLength(this.full, this.elapsed, this.charMs, this.drunkenness));
    }

    if (this.drunkenness > 0) {
      this.glyphs.forEach((g, k) => {
        const t = this.glyphTexts[k];
        if (t?.visible !== true) return;
        t.y = g.y + wobbleAt(g.index, this.drunkenness, this.phase);
      });
    }
  }

  skip(): void {
    if (this.destroyed) return;
    this.elapsed = this.full.length * this.charMs * 3;
    this.reveal(this.full.length);
  }

  private reveal(next: number): void {
    if (next > this.visible) {
      // One tick per burst, and never for whitespace — otherwise it machine-guns.
      if (/\S/.test(this.full.slice(this.visible, next))) this.sfx?.typeTick();
      this.visible = next;
      this.paint();
    }
    if (this.visible >= this.full.length && !this.finished) {
      this.finished = true;
      for (const btn of this.buttons) btn.setVisible(true);
      if (!this.completed) {
        this.completed = true;
        this.onComplete?.();
      }
    }
  }

  private paint(): void {
    if (this.body !== null) {
      this.body.setText(this.full.slice(0, this.visible));
      return;
    }
    this.glyphs.forEach((g, k) => {
      this.glyphTexts[k]?.setVisible(g.index < this.visible);
    });
  }

  get done(): boolean {
    return this.finished;
  }

  // Guards every public entry point: Phaser's Text.preDestroy hands the canvas
  // back to CanvasPool without nulling it, so a stray update()/skip() would
  // setText() onto a canvas the pool may have already re-issued to another widget.
  destroy(): void {
    if (this.destroyed) return;
    this.destroyed = true;
    this.root.destroy();
    this.glyphs.length = 0;
    this.glyphTexts.length = 0;
    this.buttons.length = 0;
  }
}
