import Phaser from 'phaser';
import {
  GAME_HEIGHT,
  GAME_WIDTH,
  INTERACT_RANGE,
  PLAYER_SPEED,
  SCENE_KEYS,
  T14_STAND_MS,
  IDLE_SADNESS_MS,
  RAIN_SADNESS_MS,
  type MapSceneKey,
} from '../data/constants';
import {
  CLUE_REVEAL,
  FORCE_CELEBRATION_LINE,
  GENERIC_GREET,
  RAIN_NPC_LINES,
  REDIRECT_FOG,
  REDIRECT_WELL,
  TASK_LINES,
  type DialogLine,
} from '../data/dialogues';
import type { NpcId } from '../data/npcs';
import { Director } from '../systems/CorruptionDirector';
import { gameEvents } from '../systems/EventBus';
import { audio } from '../audio/ProceduralAudio';
import type { StageParams } from '../data/corruption';
import { corruptText } from '../data/corruption';

interface MapObj {
  name: string;
  type: string;
  x: number;
  y: number;
  width: number;
  height: number;
  properties?: { name: string; value: unknown }[];
}

function prop(obj: MapObj, key: string): string | undefined {
  const props = obj.properties as unknown;
  if (!props) return undefined;
  if (Array.isArray(props)) {
    const p = props.find((x: { name: string; value: unknown }) => x.name === key);
    return p ? String(p.value) : undefined;
  }
  const rec = props as Record<string, unknown>;
  if (key in rec) return String(rec[key]);
  return undefined;
}

function objType(obj: MapObj): string {
  return obj.type || (obj as { class?: string }).class || '';
}

const MAP_KEY_TO_FILE: Record<MapSceneKey, string> = {
  [SCENE_KEYS.HOUSE]: 'house',
  [SCENE_KEYS.PLAZA]: 'plaza',
  [SCENE_KEYS.BAKERY]: 'bakery',
  [SCENE_KEYS.SHOP]: 'shop',
  [SCENE_KEYS.CLINIC]: 'clinic',
  [SCENE_KEYS.LAKE]: 'lake',
  [SCENE_KEYS.FIELD]: 'field',
  [SCENE_KEYS.WELL]: 'well',
  [SCENE_KEYS.FOG]: 'fog',
  [SCENE_KEYS.WHITE]: 'white',
};

const NPC_ROW: Record<string, number> = {
  afu: 0,
  yuanyuan: 1,
  dabao: 2,
  mimi: 3,
  fu: 4,
  twinbirds: 5,
  gui: 6,
};

export class WorldScene extends Phaser.Scene {
  mapFile: string;
  sceneKey: MapSceneKey;
  player!: Phaser.Physics.Arcade.Sprite;
  cursors!: Phaser.Types.Input.Keyboard.CursorKeys;
  wasd!: { up: Phaser.Input.Keyboard.Key; down: Phaser.Input.Keyboard.Key; left: Phaser.Input.Keyboard.Key; right: Phaser.Input.Keyboard.Key };
  walls!: Phaser.Tilemaps.TilemapLayer;
  interactables: { x: number; y: number; id: string; obj: MapObj }[] = [];
  doors: { x: number; y: number; target: string; spawn: string; w: number; h: number }[] = [];
  npcs: {
    id: NpcId;
    sprite: Phaser.Physics.Arcade.Sprite;
    homeX: number;
    homeY: number;
  }[] = [];
  blocked = false;
  idleMs = 0;
  rainMs = 0;
  gazeMs = 0;
  gazeTarget: 'well' | 'fog' | null = null;
  facing: 'down' | 'up' | 'left' | 'right' = 'down';
  colorMatrix: Phaser.Display.ColorMatrix | null = null;
  rainEmitter: Phaser.GameObjects.Particles.ParticleEmitter | null = null;
  rainGfx: Phaser.GameObjects.Graphics | null = null;
  t14ZoomBase = 1;
  spawnName = 'spawn';
  celebrationEdgeMs = 0;
  doorCooldown = 0;

  constructor(key: MapSceneKey, mapFile: string) {
    super(key);
    this.sceneKey = key;
    this.mapFile = mapFile;
  }

  init(data: { spawn?: string }): void {
    this.spawnName = data?.spawn || 'spawn';
  }

  create(): void {
    this.blocked = false;
    this.interactables = [];
    this.doors = [];
    this.npcs = [];
    this.idleMs = 0;
    this.rainMs = 0;
    this.gazeMs = 0;
    this.doorCooldown = 500;
    this.traveling = false;

    const map = this.make.tilemap({ key: this.mapFile });
    const tiles = map.addTilesetImage('village', 'tiles');
    if (!tiles) {
      console.error('tileset missing');
      return;
    }
    const ground = map.createLayer('ground', tiles, 0, 0)!;
    const obstacles = map.createLayer('obstacles', tiles, 0, 0)!;
    obstacles.setCollisionByExclusion([-1, 0]);
    // Also collide any non-zero tile on obstacles
    obstacles.forEachTile((t) => {
      if (t.index > 0) t.setCollision(true, true, true, true);
    });
    this.walls = obstacles;
    ground.setDepth(0);
    obstacles.setDepth(1);

    const objLayer = map.getObjectLayer('objects');
    let spawnX = 48;
    let spawnY = 48;
    const spawns = new Map<string, { x: number; y: number }>();
    const pendingNpcs: { id: NpcId; x: number; y: number }[] = [];

    if (objLayer) {
      for (const raw of objLayer.objects) {
        const obj = raw as unknown as MapObj;
        const cx = obj.x + (obj.width || 16) / 2;
        const cy = obj.y + (obj.height || 16) / 2;
        const otype = objType(obj);
        if (otype === 'spawn' || obj.name === 'spawn' || obj.name.startsWith('from_')) {
          spawns.set(obj.name || 'spawn', { x: cx, y: cy });
          if (obj.name === 'spawn') {
            spawnX = cx;
            spawnY = cy;
          }
        }
        if (otype === 'door') {
          const dw = obj.width || 16;
          const dh = obj.height || 16;
          this.doors.push({
            x: cx,
            y: cy,
            w: dw,
            h: dh,
            target: prop(obj, 'target') || SCENE_KEYS.PLAZA,
            spawn: prop(obj, 'spawn') || 'spawn',
          });
          // Ensure door tiles + approach tile are walkable
          const tx = Math.floor(cx / 16);
          const ty = Math.floor(cy / 16);
          for (const [ox, oy] of [
            [0, 0],
            [0, -1],
            [0, 1],
            [-1, 0],
            [1, 0],
          ] as const) {
            const tile = obstacles.getTileAt(tx + ox, ty + oy);
            if (tile) {
              tile.setCollision(false, false, false, false);
            }
          }
        }
        if (otype === 'interact') {
          this.interactables.push({
            x: cx,
            y: cy,
            id: prop(obj, 'interactId') || obj.name,
            obj,
          });
        }
        if (otype === 'npc') {
          const nid = (prop(obj, 'npcId') || obj.name) as NpcId;
          // Defer NPC spawn until player exists (collider needs player body)
          pendingNpcs.push({ id: nid, x: cx, y: cy });
        }
      }
    }

    const sp = spawns.get(this.spawnName) || spawns.get('spawn');
    if (sp) {
      spawnX = sp.x;
      spawnY = sp.y;
    }

    this.player = this.physics.add.sprite(spawnX, spawnY, 'player', 0);
    this.player.setDepth(10);
    this.player.setCollideWorldBounds(true);
    this.player.body!.setSize(10, 8);
    this.player.body!.setOffset(3, 16);
    this.physics.add.collider(this.player, obstacles);

    // NPCs after player so colliders are valid
    for (const n of pendingNpcs) {
      this.spawnNpc(n.id, n.x, n.y);
    }

    this.physics.world.setBounds(0, 0, map.widthInPixels, map.heightInPixels);
    this.cameras.main.setBounds(0, 0, map.widthInPixels, map.heightInPixels);
    this.cameras.main.startFollow(this.player, true, 0.15, 0.15);
    this.cameras.main.setRoundPixels(true);
    // Indoor / small maps: warm void instead of sky blue letterbox
    const indoor = ['house', 'bakery', 'shop', 'clinic', 'white'].includes(this.mapFile);
    this.cameras.main.setBackgroundColor(indoor ? '#d8b878' : '#87ceeb');

    this.cursors = this.input.keyboard!.createCursorKeys();
    this.wasd = {
      up: this.input.keyboard!.addKey('W'),
      down: this.input.keyboard!.addKey('S'),
      left: this.input.keyboard!.addKey('A'),
      right: this.input.keyboard!.addKey('D'),
    };

    this.input.keyboard!.on('keydown-E', () => this.tryInteract());

    // Stage FX
    this.applyStageFx(Director.getStageParams());
    gameEvents.on('stage', this.onStage as (...a: unknown[]) => void);
    gameEvents.on('negativeFlash', this.onNegFlash as (...a: unknown[]) => void);
    gameEvents.on('forcedCelebration', this.onCelebration as (...a: unknown[]) => void);
    gameEvents.on('npcLookAtPlayer', this.onNpcLook as (...a: unknown[]) => void);
    gameEvents.on('dusk', this.onDusk as (...a: unknown[]) => void);
    gameEvents.on('rain', this.onRain as (...a: unknown[]) => void);
    gameEvents.on('t14Start', this.onT14Start as (...a: unknown[]) => void);
    gameEvents.on('t14End', this.onT14End as (...a: unknown[]) => void);
    gameEvents.on('ending', this.onEnding as (...a: unknown[]) => void);
    gameEvents.on('teleport', this.onTeleport as (...a: unknown[]) => void);

    if (Director.rainActive) this.showRain(true);
    if (Director.forcedCelebrationActive) this.showCelebrationFx(true);

    this.events.on('shutdown', () => this.cleanup());
  }

  private cleanup(): void {
    gameEvents.off('stage', this.onStage as (...a: unknown[]) => void);
    gameEvents.off('negativeFlash', this.onNegFlash as (...a: unknown[]) => void);
    gameEvents.off('forcedCelebration', this.onCelebration as (...a: unknown[]) => void);
    gameEvents.off('npcLookAtPlayer', this.onNpcLook as (...a: unknown[]) => void);
    gameEvents.off('dusk', this.onDusk as (...a: unknown[]) => void);
    gameEvents.off('rain', this.onRain as (...a: unknown[]) => void);
    gameEvents.off('t14Start', this.onT14Start as (...a: unknown[]) => void);
    gameEvents.off('t14End', this.onT14End as (...a: unknown[]) => void);
    gameEvents.off('ending', this.onEnding as (...a: unknown[]) => void);
    gameEvents.off('teleport', this.onTeleport as (...a: unknown[]) => void);
  }

  private onStage = (_s: unknown, params: unknown) => {
    this.applyStageFx(params as StageParams);
  };
  private onNegFlash = () => {
    const cam = this.cameras.main;
    // single-frame negative-ish flash
    cam.setBackgroundColor(0xffffff);
    this.time.delayedCall(16, () => cam.setBackgroundColor(0x87ceeb));
    if (this.colorMatrix) {
      // Phaser 3 ColorMatrix negative
      try {
        (this.colorMatrix as unknown as { negative: (m?: number) => void }).negative?.(1);
        this.time.delayedCall(16, () => {
          this.applyStageFx(Director.getStageParams());
        });
      } catch {
        /* ignore */
      }
    }
  };
  private onCelebration = (active: unknown) => {
    this.showCelebrationFx(!!active);
    if (active) {
      this.openDialog([{ speaker: '系统', text: FORCE_CELEBRATION_LINE }]);
    }
  };
  private onNpcLook = (npcId: unknown) => {
    if (!npcId) {
      // look with nearest
      let best: (typeof this.npcs)[0] | null = null;
      let bestD = Infinity;
      for (const n of this.npcs) {
        const d = Phaser.Math.Distance.Between(this.player.x, this.player.y, n.sprite.x, n.sprite.y);
        if (d < bestD) {
          bestD = d;
          best = n;
        }
      }
      if (best) {
        best.sprite.setTint(0xffaaaa);
        this.time.delayedCall(400, () => best!.sprite.clearTint());
      }
      return;
    }
    const n = this.npcs.find((x) => x.id === npcId);
    if (n) {
      n.sprite.setTint(0xffaaaa);
      this.time.delayedCall(400, () => n.sprite.clearTint());
    }
  };
  private onDusk = () => {
    audio.bell();
    this.cameras.main.fade(400, 255, 180, 80);
    this.time.delayedCall(400, () => this.cameras.main.fadeIn(400));
    gameEvents.emit('toast', '黄昏的钟声响了……该回家睡觉了。');
  };
  private onRain = (on: unknown) => this.showRain(!!on);
  private onT14Start = () => {
    this.blocked = true;
    this.player.setVelocity(0, 0);
    this.t14ZoomBase = this.cameras.main.zoom;
    // All NPCs face player
    for (const n of this.npcs) {
      n.sprite.setTint(0xffeeaa);
    }
  };
  private onT14End = () => {
    this.blocked = false;
    this.cameras.main.setZoom(1);
    for (const n of this.npcs) n.sprite.clearTint();
    audio.ding();
  };
  private onEnding = (id: unknown) => {
    this.scene.stop(SCENE_KEYS.UI);
    this.scene.start(SCENE_KEYS.ENDING, { endingId: id });
  };
  private onTeleport = (payload: unknown) => {
    const p = payload as { scene: string; spawn?: string };
    this.travelTo(p.scene, p.spawn || 'spawn');
  };

  private applyStageFx(params: StageParams): void {
    const cam = this.cameras.main;
    // Avoid camera postFX (ColorMatrix) — can throw isParent when scenes swap.
    // Approximate saturation via background tint instead.
    // Vignette via overlay
    let vig = this.children.getByName('vignette') as Phaser.GameObjects.Rectangle | null;
    if (!vig) {
      vig = this.add
        .rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH * 4, GAME_HEIGHT * 4, 0x000000, 0)
        .setScrollFactor(0)
        .setDepth(50)
        .setName('vignette');
    }
    vig.setAlpha(params.vignette * 0.5);
    // Sky tint by stage
    if (params.stage === 'S4') cam.setBackgroundColor(0xffe8a0);
    else if (params.stage === 'S3') cam.setBackgroundColor(0xfff0b0);
    else if (params.stage === 'S0') cam.setBackgroundColor(0xa0b8c8);
    else cam.setBackgroundColor(0x87ceeb);

    // Update NPC faces
    for (const n of this.npcs) {
      this.setNpcFace(n);
    }
    // S4: freeze one extra
    if (params.stage === 'S4') {
      const extras = this.npcs.filter((n) => n.id.startsWith('extra'));
      if (extras[0]) {
        extras[0].sprite.anims.stop();
        // face player
        extras[0].sprite.setFlipX(this.player.x < extras[0].sprite.x);
      }
    }
  }

  private spawnNpc(id: NpcId, x: number, y: number): void {
    if (id === 'twinbirds') {
      const s = this.physics.add.sprite(x, y, 'birds', 0);
      s.setDepth(9);
      s.setImmovable(true);
      s.body!.enable = false;
      s.play('bird-beak');
      this.npcs.push({ id, sprite: s, homeX: x, homeY: y });
      return;
    }
    if (id.startsWith('extra')) {
      const idx = Math.max(0, parseInt(id.replace('extra', ''), 10) - 1);
      const s = this.physics.add.sprite(x, y, 'extras', idx * 2);
      s.setDepth(9);
      s.setImmovable(true);
      s.body!.setSize(10, 8);
      s.body!.setOffset(3, 16);
      s.play(`extra-${idx}`);
      if (this.walls) this.physics.add.collider(s, this.walls);
      // patrol
      this.tweens.add({
        targets: s,
        x: x + 24,
        duration: 2000 + idx * 200,
        yoyo: true,
        repeat: -1,
        ease: 'Sine.easeInOut',
      });
      this.npcs.push({ id, sprite: s, homeX: x, homeY: y });
      return;
    }
    const row = NPC_ROW[id] ?? 0;
    const face = Director.faceTier(id);
    const faceIdx = face === 'smile' ? 0 : face === 'flat' ? 1 : 2;
    const frame = row * 3 + faceIdx;
    const s = this.physics.add.sprite(x, y, 'npcs', frame);
    s.setDepth(9);
    s.setImmovable(true);
    s.body!.setSize(10, 8);
    s.body!.setOffset(3, 16);
    if (this.player?.body) {
      this.physics.add.collider(this.player, s);
    }
    this.npcs.push({ id, sprite: s, homeX: x, homeY: y });
  }

  private setNpcFace(n: { id: NpcId; sprite: Phaser.Physics.Arcade.Sprite }): void {
    if (n.id === 'twinbirds' || n.id.startsWith('extra')) return;
    const row = NPC_ROW[n.id] ?? 0;
    const face = Director.faceTier(n.id);
    const faceIdx = face === 'smile' ? 0 : face === 'flat' ? 1 : 2;
    n.sprite.setFrame(row * 3 + faceIdx);
  }

  private showRain(on: boolean): void {
    if (this.rainGfx) {
      this.rainGfx.destroy();
      this.rainGfx = null;
    }
    if (!on) return;
    this.rainGfx = this.add.graphics().setScrollFactor(0).setDepth(40);
    const draw = () => {
      if (!this.rainGfx) return;
      this.rainGfx.clear();
      this.rainGfx.lineStyle(1, 0xa0c8ff, 0.5);
      for (let i = 0; i < 40; i++) {
        const x = Math.random() * GAME_WIDTH;
        const y = Math.random() * GAME_HEIGHT;
        this.rainGfx.lineBetween(x, y, x - 2, y + 8);
      }
    };
    this.time.addEvent({ delay: 80, loop: true, callback: draw });
    draw();
  }

  private showCelebrationFx(on: boolean): void {
    let g = this.children.getByName('celeb') as Phaser.GameObjects.Text | null;
    if (!on) {
      g?.destroy();
      return;
    }
    if (!g) {
      g = this.add
        .text(GAME_WIDTH / 2, 20, '※ 欢乐庆典 ※', {
          fontFamily: 'monospace',
          fontSize: '12px',
          color: '#ff5080',
          backgroundColor: '#fff0e0',
          padding: { x: 6, y: 3 },
        })
        .setScrollFactor(0)
        .setDepth(60)
        .setOrigin(0.5)
        .setName('celeb');
    }
  }

  update(_t: number, dt: number): void {
    if (!this.player?.body) return;
    Director.tick(dt);

    // T14 zoom
    if (Director.t14Active) {
      const p = Math.min(1, Director.t14ElapsedMs / T14_STAND_MS);
      this.cameras.main.setZoom(1 + p * 0.8);
      this.player.setVelocity(0, 0);
      if (Director.t14ElapsedMs >= T14_STAND_MS) {
        Director.finishT14();
      }
      return;
    }

    if (this.blocked) {
      this.player.setVelocity(0, 0);
      return;
    }

    let vx = 0;
    let vy = 0;
    if (this.cursors.left.isDown || this.wasd.left.isDown) vx = -1;
    else if (this.cursors.right.isDown || this.wasd.right.isDown) vx = 1;
    if (this.cursors.up.isDown || this.wasd.up.isDown) vy = -1;
    else if (this.cursors.down.isDown || this.wasd.down.isDown) vy = 1;

    if (vx !== 0 && vy !== 0) {
      vx *= Math.SQRT1_2;
      vy *= Math.SQRT1_2;
    }
    this.player.setVelocity(vx * PLAYER_SPEED, vy * PLAYER_SPEED);

    if (vx !== 0 || vy !== 0) {
      this.idleMs = 0;
      if (Math.abs(vx) > Math.abs(vy)) {
        this.facing = vx < 0 ? 'left' : 'right';
      } else {
        this.facing = vy < 0 ? 'up' : 'down';
      }
      this.player.anims.play(`walk-${this.facing}`, true);
    } else {
      this.player.anims.stop();
      const idleFrame = { down: 0, up: 4, left: 8, right: 12 }[this.facing];
      this.player.setFrame(idleFrame);
      this.idleMs += dt;
      // idle sadness: 10s no move and not at task point
      if (this.idleMs >= IDLE_SADNESS_MS) {
        this.idleMs = 0;
        const nearTask = this.nearestInteract() != null;
        if (!nearTask) {
          Director.applySadness('idle');
        }
      }
    }

    // rain sadness
    if (Director.rainActive) {
      this.rainMs += dt;
      if (this.rainMs >= RAIN_SADNESS_MS) {
        this.rainMs = 0;
        Director.applySadness('rain');
      }
    }

    // celebration escape at map edge
    if (Director.forcedCelebrationActive) {
      const b = this.physics.world.bounds;
      const margin = 8;
      if (
        this.player.x <= b.x + margin ||
        this.player.y <= b.y + margin ||
        this.player.x >= b.right - margin ||
        this.player.y >= b.bottom - margin
      ) {
        Director.escapeForcedCelebration();
        gameEvents.emit('toast', '你从庆典边缘离开了……');
      }
    }

    // door auto (with cooldown after spawn / travel)
    if (this.doorCooldown > 0) {
      this.doorCooldown -= dt;
    } else {
      const d = this.nearestDoor(22);
      if (d) {
        this.doorCooldown = 600;
        this.travelTo(d.target, d.spawn);
        return;
      }
    }

    // prompt
    const nearDoor = this.doorCooldown <= 0 ? this.nearestDoor(22) : null;
    const near = this.nearestInteract() || this.nearestNpc() || nearDoor;
    if (near) {
      gameEvents.emit('prompt', true, nearDoor && near === nearDoor ? '靠近或按 E 出门' : '按 E');
    } else {
      gameEvents.emit('prompt', false, '');
    }

    // well/fog gaze
    const ix = this.nearestInteract();
    if (ix && (ix.id === 'well' || ix.id === 'fogwall') && Director.canInteractWellOrFog()) {
      this.gazeMs += dt;
      if (this.gazeMs >= 5000) {
        this.gazeMs = 0;
        Director.gazeWellOrFog(ix.id === 'well' ? 'well' : 'fog');
        gameEvents.emit('toast', '你注视了很久……');
      }
    } else {
      this.gazeMs = 0;
    }

    // S3+: stationary NPCs face player
    if (Director.stage === 'S3' || Director.stage === 'S4') {
      for (const n of this.npcs) {
        if (n.id.startsWith('extra') || n.id === 'twinbirds') continue;
        n.sprite.setFlipX(this.player.x < n.sprite.x);
      }
    }
  }

  private nearestInteract(): { x: number; y: number; id: string } | null {
    let best: { x: number; y: number; id: string } | null = null;
    let bestD = INTERACT_RANGE;
    for (const it of this.interactables) {
      const d = Phaser.Math.Distance.Between(this.player.x, this.player.y, it.x, it.y);
      if (d <= bestD) {
        bestD = d;
        best = it;
      }
    }
    return best;
  }

  private nearestNpc(): { id: NpcId; sprite: Phaser.Physics.Arcade.Sprite } | null {
    let best: (typeof this.npcs)[0] | null = null;
    let bestD = INTERACT_RANGE;
    for (const n of this.npcs) {
      if (n.id === 'twinbirds' || n.id.startsWith('extra')) continue;
      const d = Phaser.Math.Distance.Between(this.player.x, this.player.y, n.sprite.x, n.sprite.y);
      if (d <= bestD) {
        bestD = d;
        best = n;
      }
    }
    return best;
  }

  private nearestDoor(range = 22): (typeof this.doors)[0] | null {
    let best: (typeof this.doors)[0] | null = null;
    let bestD = range;
    for (const d of this.doors) {
      // AABB soft distance: reach if within expanded door rect or radius
      const halfW = Math.max(d.w, 16) / 2 + 6;
      const halfH = Math.max(d.h, 16) / 2 + 6;
      const inBox =
        Math.abs(this.player.x - d.x) <= halfW && Math.abs(this.player.y - d.y) <= halfH;
      const dist = Phaser.Math.Distance.Between(this.player.x, this.player.y, d.x, d.y);
      if (inBox || dist <= bestD) {
        if (dist < bestD || inBox) {
          bestD = inBox ? Math.min(dist, bestD) : dist;
          best = d;
        }
      }
    }
    return best;
  }

  private tryInteract(): void {
    if (this.blocked) return;
    // Manual door use (backup if auto-trigger missed)
    if (this.doorCooldown <= 0) {
      const door = this.nearestDoor(28);
      if (door) {
        this.doorCooldown = 600;
        this.travelTo(door.target, door.spawn);
        return;
      }
    }
    const it = this.nearestInteract();
    if (it) {
      this.handleInteract(it.id);
      return;
    }
    const n = this.nearestNpc();
    if (n) this.talkNpc(n.id);
  }

  private handleInteract(id: string): void {
    switch (id) {
      case 'bed':
        if (Director.duskReached || Director.dayPhase === 'dusk' || Director.getTodayTasks().every(
          (t) =>
            t.id === 'T15' ||
            Director.completedTasks.has(t.id) ||
            Director.refusedTasks.has(t.id),
        )) {
          Director.goToSleep();
          this.scene.start(SCENE_KEYS.DREAM);
        } else {
          this.openDialog([{ speaker: '旁白', text: '现在还早，先去广场看看公告板吧。' }]);
        }
        break;
      case 'calendar':
        if (Director.day >= 2) {
          if (Director.discoverClue('C1')) {
            this.openDialog([{ speaker: '旁白', text: CLUE_REVEAL.C1! }]);
          } else {
            this.openDialog([{ speaker: '旁白', text: CLUE_REVEAL.C1! }]);
          }
        } else {
          this.openDialog([{ speaker: '旁白', text: '床头的日历，日期写得很清楚。' }]);
        }
        break;
      case 'board':
        Director.readBoard();
        {
          const line = Director.getAfuLine();
          const tasks = Director.getTodayTasks()
            .map((t) => `· ${t.description}`)
            .join('\n');
          this.openDialog([
            { speaker: '阿福', npcId: 'afu', text: line },
            { speaker: '公告板', text: tasks || '今天没有常规任务。' },
          ]);
        }
        break;
      case 'flowers':
        Director.waterFlower();
        gameEvents.emit('toast', '浇水了。');
        audio.beep();
        break;
      case 'mailbox':
        if (Director.day >= 2) {
          Director.discoverClue('C3');
          this.openDialog([{ speaker: '旁白', text: CLUE_REVEAL.C3! }]);
        }
        if (Director.day === 2 && !Director.hasLetters && !Director.completedTasks.has('T4')) {
          Director.takeLetters();
          this.openDialog([TASK_LINES.letterOffer]);
        }
        break;
      case 'recipe':
        if (Director.day >= 3) {
          Director.discoverClue('C2');
          this.openDialog([{ speaker: '旁白', text: CLUE_REVEAL.C2! }]);
        } else {
          this.openDialog([{ speaker: '旁白', text: '一张蜂蜜面包的配方。' }]);
        }
        break;
      case 'bread':
        Director.takeBread();
        this.openDialog([TASK_LINES.breadOffer]);
        break;
      case 'knead':
        if (Director.day === 5 && !Director.completedTasks.has('T11') && !Director.refusedTasks.has('T11')) {
          this.openDialog([TASK_LINES.knead], (choice) => {
            if (choice === 'negate') Director.refuseTask('T11');
            else if (choice === 'gentle') Director.completeTask('T11');
            else Director.completeTask('T11');
          });
        }
        break;
      case 'shelf':
        if (Director.day >= 2) {
          Director.discoverClue('C5');
          this.openDialog([{ speaker: '旁白', text: CLUE_REVEAL.C5! }]);
        }
        if (Director.day === 2 && !Director.completedTasks.has('T5')) {
          this.openDialog([TASK_LINES.stockCount], () => {
            Director.completeTask('T5');
            audio.ding();
          });
        }
        break;
      case 'form':
        if (Director.day >= 5) {
          Director.discoverClue('C8');
          this.openDialog([{ speaker: '旁白', text: CLUE_REVEAL.C8! }]);
        } else {
          this.openDialog([{ speaker: '芙医生', npcId: 'fu', text: '村里有诊所，但你不需要去那里哦。' }]);
        }
        break;
      case 'picnic':
        if ((Director.day === 3 || Director.day === 4) && !Director.completedTasks.has('T7')) {
          this.openDialog([TASK_LINES.picnic], () => {
            Director.completeTask('T7');
            audio.ding();
          });
        }
        break;
      case 'berry':
        Director.pickBerry();
        gameEvents.emit('toast', `莓果 ${Director.berryCount}/5`);
        break;
      case 'pot':
        Director.microInteract('feed');
        break;
      case 'ribbon':
        if (Director.day === 6) {
          Director.hangRibbon();
          gameEvents.emit('toast', `彩带 ${Director.ribbonCount}/4`);
        }
        break;
      case 'well':
        if (!Director.canInteractWellOrFog()) {
          this.openDialog(REDIRECT_WELL);
        } else {
          this.openDialog([{ speaker: '旁白', text: '你靠近古井，倾听……' }], () => {
            this.time.delayedCall(5000, () => {
              Director.discoverClue('C7');
              this.openDialog([{ speaker: '旁白', text: CLUE_REVEAL.C7! }]);
              audio.ecg();
            });
          });
        }
        break;
      case 'fogwall':
        if (!Director.canInteractWellOrFog()) {
          this.openDialog(REDIRECT_FOG);
        } else if (Director.day === 7) {
          // E3 route
          const id = Director.startEnding(true);
          this.scene.stop(SCENE_KEYS.UI);
          this.scene.start(SCENE_KEYS.ENDING, { endingId: id });
        } else {
          this.openDialog([{ speaker: '旁白', text: '浓雾之外什么也看不见。或许庆典那天……' }]);
        }
        break;
      case 'bench':
        this.talkNpc('gui');
        break;
      default:
        break;
    }
  }

  private talkNpc(id: NpcId): void {
    // Task-related deliveries — inventory is on Director (survives travelTo)
    if (Director.hasBread && (id === 'dabao' || id === 'mimi' || id === 'gui')) {
      if (!Director.breadDelivered.includes(id)) {
        Director.deliverBread(id);
        this.openDialog([TASK_LINES.breadThanks(id === 'dabao' ? '大宝' : id === 'mimi' ? '咪咪' : '龟爷爷')]);
        return;
      }
    }
    if (Director.hasLetters && (id === 'yuanyuan' || id === 'mimi' || id === 'fu')) {
      if (!Director.lettersDelivered.includes(id)) {
        const isThird = Director.lettersDelivered.length === 2;
        Director.deliverLetter(id);
        if (isThird) {
          this.openDialog([
            { speaker: '旁白', text: '信送到了。署名忽然闪过乱码……' },
            { speaker: '旁白', text: CLUE_REVEAL.C4! },
          ]);
        } else {
          this.openDialog([{ speaker: '旁白', text: '信送到了。' }]);
        }
        return;
      }
    }

    // Board wipe T12
    if (id === 'afu' && Director.day === 5 && !Director.completedTasks.has('T12') && !Director.refusedTasks.has('T12')) {
      this.openDialog([TASK_LINES.boardWipe], (choice) => {
        if (choice === 'negate') Director.refuseTask('T12');
        else {
          Director.completeTask('T12');
          Director.discoverClue('C6');
          this.time.delayedCall(100, () => {
            this.openDialog([{ speaker: '旁白', text: CLUE_REVEAL.C6! }]);
          });
        }
      });
      return;
    }

    // T14 start from afu day 6
    if (id === 'afu' && Director.day === 6 && Director.completedTasks.has('T13') && !Director.completedTasks.has('T14')) {
      this.openDialog([TASK_LINES.smileStart], () => {
        Director.startT14();
      });
      return;
    }

    // T15 celebration day 7
    if (id === 'afu' && Director.day === 7) {
      this.openDialog(
        [
          { speaker: '阿福', npcId: 'afu', text: Director.getAfuLine() },
          {
            speaker: '阿福',
            npcId: 'afu',
            text: '第 413 届丰收庆典开始了。你想留在广场，还是……走向雾墙？',
            options: [
              { label: '参加庆典', choice: 'gentle' },
              { label: '走向雾墙', choice: 'hesitant' },
              { label: '什么都不做', choice: 'negate' },
            ],
          },
        ],
        (choice) => {
          if (choice === 'hesitant') {
            const eid = Director.startEnding(true);
            this.scene.stop(SCENE_KEYS.UI);
            this.scene.start(SCENE_KEYS.ENDING, { endingId: eid });
          } else {
            const eid = Director.startEnding(false);
            this.scene.stop(SCENE_KEYS.UI);
            this.scene.start(SCENE_KEYS.ENDING, { endingId: eid });
          }
        },
      );
      return;
    }

    // Day2: get letters from 大宝 (also mailbox)
    if (id === 'dabao' && Director.day === 2 && !Director.hasLetters && !Director.completedTasks.has('T4')) {
      Director.takeLetters();
      this.openDialog([TASK_LINES.letterOffer]);
      return;
    }
    // Day1/4: get bread from 圆圆 if not yet carrying
    if (
      id === 'yuanyuan' &&
      (Director.day === 1 || Director.day === 4) &&
      !Director.hasBread &&
      !Director.completedTasks.has(Director.day === 4 ? 'T8' : 'T1')
    ) {
      Director.takeBread();
      this.openDialog([TASK_LINES.breadOffer]);
      return;
    }

    // Gui C9 chain
    if (id === 'gui') {
      const c9 = Director.getC9LineForTalk();
      if (c9) {
        this.openDialog([
          {
            speaker: '龟爷爷',
            npcId: 'gui',
            text: c9,
            options: [
              { label: '点头', choice: 'gentle' },
              { label: '……', choice: 'hesitant' },
              { label: '我不想听', choice: 'negate' },
            ],
          },
        ]);
        return;
      }
    }

    // Rain lines ignore rain
    if (Director.rainActive && (id === 'yuanyuan' || id === 'dabao' || id === 'mimi')) {
      const line = RAIN_NPC_LINES.find((l) => l.npcId === id);
      if (line) {
        this.openDialog([line]);
        // still count greet
        if (Director.day === 1 || Director.day === 4) Director.greetVillager();
        return;
      }
    }

    const lines = GENERIC_GREET[id];
    if (lines) {
      this.openDialog(lines, () => {
        if (Director.day === 1 || Director.day === 4) Director.greetVillager();
      });
    }
  }

  private openDialog(lines: DialogLine[], onDone?: (lastChoice?: 'gentle' | 'hesitant' | 'negate') => void): void {
    this.blocked = true;
    this.player.setVelocity(0, 0);
    // Apply corruption at render
    const rendered = lines.map((l) => {
      const stage = Director.stage;
      const idx = Director.nextSentenceIndex();
      const isMimi = l.npcId === 'mimi';
      return {
        ...l,
        text: corruptText(l.text, stage, { isMimi, sentenceIndex: idx }),
      };
    });
    gameEvents.emit('dialog', rendered, (choice?: 'gentle' | 'hesitant' | 'negate') => {
      this.blocked = false;
      if (choice && lines[0]?.npcId) {
        Director.dialogChoice(choice, lines[0].npcId);
      } else if (choice) {
        Director.dialogChoice(choice);
      }
      onDone?.(choice);
    });
  }

  private traveling = false;

  private travelTo(target: string, spawn: string): void {
    if (this.traveling) return;
    this.traveling = true;
    this.blocked = true;
    this.player?.setVelocity(0, 0);
    // Pause physics before scene swap to avoid collideObjects on torn-down bodies
    this.physics.world.pause();

    const key = target;
    const spawnName = spawn;
    this.time.delayedCall(1, () => {
      try {
        this.scene.start(key, { spawn: spawnName });
      } catch (e) {
        console.error('travelTo failed', key, e);
      }
    });
  }
}

export function createMapScenes(): WorldScene[] {
  return (Object.entries(MAP_KEY_TO_FILE) as [MapSceneKey, string][]).map(
    ([key, file]) => new WorldScene(key, file),
  );
}
