/**
 * «Пепельные луга» на v2 (трек 16 «NPC и квестовый каркас»): мир областей из
 * раскладки v1 (maps.json → buildAreaWorld), герой-манекен, переходы
 * step/click с фейдом и стек возврата, NPC с диалогами по квест-стадиям,
 * сумка, журнал квестов, агентный мост для гейтов. Позиции v1 (спавны/
 * переходы/NPC, в тайлах) конвертируются на границе: tileCenter
 * (tile*UNIT + 5). Референс — демка v1.
 */
import {
    VoxelWorld, VoxelRenderer, meshChunk, ISO_YAW,
    mannequin, walkClip, Character, GameLoop, screenMoveToWorld,
    DialogueRunner, Inventory, CutsceneRunner, validateCutscenes,
} from '@rpg/engine';
import type {
    VoxelCloud, DialogueGraph, DialogueView,
    DialogueEffectOp, Interactable,
} from '@rpg/engine';
import * as THREE from 'three';
import { AREA_LAYOUTS, UNIT, GROUND_Y, buildAreaWorld, tileCenter, rleDecode, type AreaLayout } from './areas';
import { areaOf, type AreaDef, type TransitionDef, type Tile } from './data/areas';
import { GameState } from './state';
import { DIALOGUES } from './data/dialogues';
import { npcsForArea, type NpcDef } from './data/npcs';
import { questDialogueFor, questTracker } from './data/quests';
import { itemName } from './data/items';
import { Interactables, type InteractSink } from './interactables';
import { type InteractableDef } from './data/interactables';
import { stampProp, clearProp } from './props';
import { resolveCutscenes, type CutsceneAction } from './data/cutscenes';
import { FlowerQuest } from './flowers';
import { GameClock } from './clock';
import { LIGHTING, ATMOSPHERE, type Atmosphere, type LightingDef } from './data/lighting';
import { GameLighting, type HeroLampDef } from './lighting';
import { AshParticles } from './ash';
import { AmbientParticles, type AmbientKind } from './ambientParticles';
import { WaterShimmer, type WaterCell } from './waterShimmer';
import { createSaveManager, type GameSave } from './save';
import { GameAudio } from './audio';
import { FaunaSystem } from './fauna';
import { ScreenFx } from './screenFx';

// Слоты палитры сцены: арт-библия v1 (1..10) + тайлы областей (11..17)
const PALETTE = {
    colors: {
        1: '#4a5340', // G1 — базовый луг
        2: '#343b2c', // G0 — тень травы (нижний слой / проплешина)
        3: '#626c4c', // G2 — трава, свет (пятна)
        4: '#5c3b2e', // R1 — стволы/крыша тень
        5: '#7d8662', // G2H — крона, свет
        6: '#5b5b66', // P4 — камень, стены
        7: '#94949e', // P6 — свет камня
        8: '#82503a', // R2 — светлая ржавчина (крыша)
        9: '#d99a32', // B1 — цветок (жизнь!)
        10: '#44454e', // M1 — металл
        11: '#2f2f38', // P2 — пепел
        12: '#3e3e48', // P3 — пепел, светлые пятна
        13: '#3b5e6b', // W1 — вода
        14: '#568590', // W2 — вода, блики
        15: '#5a4a3c', // T0 — пол интерьеров
        16: '#77634f', // T1 — тропа
        17: '#61626c', // M2 — рельсы
    },
};

// ---- Рендер: мир области (пересоздаётся на переходе), меш на чанк ----
const canvas = document.getElementById('scene') as HTMLCanvasElement;
const renderer = new VoxelRenderer(canvas, { target: [80, 2, 120], width: 960, height: 540, viewSize: 80 });

// ---- Катсцены: данные + движковый рантайм (камера — инъекция рендера) ----
const cutsceneActions: Record<string, CutsceneAction> = {
    plant_flowers: () => flowers.plantFlowers(),
    raise_tower_green: () => flowers.raiseTowerGreen(),
    fauna_startle: (p) => { if (p) fauna?.startleAt(p.x, p.z); }, // звон разлетается
    toast: (_p, text) => showToast(text ?? ''),
};
const CUTSCENE_STEPS = resolveCutscenes(cutsceneActions);
const cutsceneErrs = validateCutscenes(CUTSCENE_STEPS);
if (cutsceneErrs.length) throw new Error(`Катсцены: ${cutsceneErrs.join('; ')}`); // битый каталог падает сразу
const cutscenes = new CutsceneRunner({
    get: () => renderer.currentTarget(),
    set: (p) => renderer.setTarget(p),
});

let world = new VoxelWorld(20);
const chunkMeshes = new Map<string, THREE.Mesh>();

function remeshDirty(): void {
    for (const key of world.takeDirty()) {
        const [cx, cz] = key.split(',').map(Number);
        const old = chunkMeshes.get(key);
        if (old) {
            renderer.scene.remove(old);
            old.geometry.dispose();
        }
        const mesh = meshChunk(world, cx, cz, PALETTE);
        if (mesh.triangleCount > 0) chunkMeshes.set(key, renderer.addMesh(mesh));
        else chunkMeshes.delete(key);
    }
}

/** Снять меши старой области (при переходе мир пересоздаётся целиком). */
function disposeMeshes(): void {
    for (const m of chunkMeshes.values()) {
        renderer.scene.remove(m);
        m.geometry.dispose();
    }
    chunkMeshes.clear();
}

// ---- Герой: манекен + walk-цикл (как в демке) ----
// Палитра героя: светлая голова (лицо), плащ путника, тёмные штаны/рукава
const { model: MAN, rig: MAN_RIG } = mannequin({
    head: '#c9a882', torso: '#7a5a40', legs: '#4a4440', arms: '#5f4a38',
});
const WALK = walkClip({ duration: 0.8, legSwing: 0.55, armSwing: 0.4, bob: 0.4, sway: 0.25 });
const BODY_SIZE: [number, number, number] = [5, 16, 5];

const hero = new Character(MAN_RIG, MAN, WALK, [80, 120], {
    size: BODY_SIZE,
    speed: 21, // v1: 1.75 юнита/с × UNIT 10 + фидбек «быстрее» → 2.1 юнита/с
    strideLength: 9.6, // каденция выше (ноги короче): 2.19 цикла/с при 2.1 юнита/с
});
const heroCloud = renderer.addVoxelCloud(hero.visual());

// ---- Состояние сюжета, сумка: диалоги и квест-стадии пишут прямо сюда ----
const state = new GameState();
const inv = new Inventory({ maxSlots: 8 });
const invWorld = { hasItem: (id: string) => inv.has(id) };

// ---- Время суток: 1 сек реального = 1 игровая минута (сутки 24 мин) ----
const clock = new GameClock();
/** Время цикла в секундах (тик/60) — детерминизм мерцания/пепла в гейтах. */
let elapsed = 0;

// ---- Освещение и атмосфера (трек 18): области задают тон и источники ----
const HERO_LAMP: HeroLampDef = { color: 0xf2b45a, radius: 2.0, intensity: 1.0, flicker: 0.12 };
let lighting: GameLighting | null = null;
const ash = new AshParticles(renderer);
/** Глобальный вкл/выкл пепла (мост: гейт кадр-равенства). */
let ashEnabled = true;
let areaAsh = false; // играет ли пепел в текущей области
/** Мотыльки лугов / дымка прудов (v1 Atmosphere): частицы области. */
let ambient: AmbientParticles | null = null;
/** Шиммер воды (блики на поверхности прудов). */
let shimmer: WaterShimmer | null = null;
const audio = new GameAudio();

/** Профиль частиц по атмосфере области: луга — мотыльки, пруды — дымка. */
function ambientOf(atm: Atmosphere): AmbientKind | null {
    if (atm === 'ash') return 'moths';
    if (atm === 'fog') return 'fog';
    return null;
}

/** Позиции тайлов раскладки по id (источники `tiles`: колокольчики и пр.). */
function layerTilePos(): Map<number, { x: number; y: number }[]> {
    const l = layout;
    const tiles = rleDecode(l.tiles, l.width * l.height);
    const out = new Map<number, { x: number; y: number }[]>();
    for (let ty = 0; ty < l.height; ty++)
        for (let tx = 0; tx < l.width; tx++) {
            const t = tiles[ty * l.width + tx]!;
            let list = out.get(t);
            if (!list) out.set(t, (list = []));
            list.push({ x: tx, y: ty });
        }
    return out;
}

/** Воксели поверхности воды области (тайл T_WATER=2 v1): центры кубов слоя. */
function waterSurfaceCells(l: AreaLayout): WaterCell[] {
    const tiles = rleDecode(l.tiles, l.width * l.height);
    const out: WaterCell[] = [];
    for (let ty = 0; ty < l.height; ty++)
        for (let tx = 0; tx < l.width; tx++) {
            if (tiles[ty * l.width + tx] !== 2) continue; // T_WATER раскладки v1
            for (let dz = 0; dz < UNIT; dz++)
                for (let dx = 0; dx < UNIT; dx++) {
                    out.push({ x: tx * UNIT + dx + 0.5, z: ty * UNIT + dz + 0.5 });
                }
        }
    return out;
}

/** Живой слой тайлов света текущей области (правки цветков двигают тайлы). */
let layerPos = new Map<number, { x: number; y: number }[]>();
/** Тайл колокольчика (T_BELLFLOWER раскладки — источник света `bells`). */
const T_LIGHT_FLOWER = 5;

/** Правка цветка → слой тайлов света: собранный гаснет, посаженный загорается. */
function trackFlowerTile(
    pos: Map<number, { x: number; y: number }[]>,
    tx: number,
    ty: number,
    present: boolean,
): void {
    const from = pos.get(T_LIGHT_FLOWER);
    if (from) {
        const i = from.findIndex((p) => p.x === tx && p.y === ty);
        if (i >= 0) {
            from.splice(i, 1);
            if (from.length === 0) pos.delete(T_LIGHT_FLOWER);
        }
    }
    if (present) {
        const list = pos.get(T_LIGHT_FLOWER) ?? [];
        if (!list.some((p) => p.x === tx && p.y === ty)) list.push({ x: tx, y: ty });
        pos.set(T_LIGHT_FLOWER, list);
    }
}

// ---- NPC: статичные по области + размещения по флагам (Клинт) ----
const NPC_RADIUS = 15; // радиус подхода (v1: 1.5 юнита; расчёты расстановки NPC v1 держат этот радиус)
interface NpcEntity {
    def: NpcDef;
    /** Точка взаимодействия (движковый формат; выбор цели — pickInteract). */
    spot: Interactable;
    char: Character;
    cloud: VoxelCloud;
}
const npcEntities: NpcEntity[] = [];

/** Поставить NPC-манекен в центр его тайла (поза покоя, без walk-цикла). */
function spawnNpc(def: NpcDef): NpcEntity {
    const c = tileCenter(def.tile.x, def.tile.y);
    const { model, rig } = mannequin(def.colors); // у каждого NPC свой дизайн
    const char = new Character(rig, model, WALK, [c.x, c.z], {
        size: BODY_SIZE, speed: 0, strideLength: 9.6,
    });
    char.body.pos = [c.x, GROUND_Y, c.z];
    return {
        def,
        spot: {
            id: def.id,
            pos: [c.x, GROUND_Y, c.z],
            radius: NPC_RADIUS,
            label: 'Поговорить',
            dialogueId: def.id,
        },
        char,
        cloud: renderer.addVoxelCloud(char.visual()),
    };
}

function disposeNpcs(): void {
    for (const e of npcEntities) e.cloud.dispose();
    npcEntities.length = 0;
}

/** Пересобрать NPC области (вызывается из setArea и при смене флагов). */
function spawnNpcs(): void {
    disposeNpcs();
    for (const def of npcsForArea(currentArea.id, (f) => state.hasFlag(f))) {
        npcEntities.push(spawnNpc(def));
    }
}

// ---- Области: текущая, стек возврата, смена области ----
let currentArea: AreaDef = areaOf('meadows');
let layout = AREA_LAYOUTS.meadows!;
const returnStack: { area: string; entry: Tile }[] = [];

// ---- Интерактивы области: реакции + воксельные пропсы ----
/** Сюжетные события custom (кат-сцены) — подключаются ниже, в сюжете. */
let onCustomEvent: ((name: string) => void) | null = null;
const interactSink: InteractSink = {
    hasItem: (id) => inv.has(id),
    give: (id, count) => inv.add(id, count),
    showToast,
    onCustom: (name) => onCustomEvent?.(name),
    // одноразовый объект поднят — проп убираем с поля
    onConsumed: (def) => clearProp(world, def),
};
let interactables: Interactables = new Interactables([], state, interactSink);

// ---- Квест «Три цветка»: сбор у прудов, посадка, зелень у башни ----
const flowers = new FlowerQuest(
    state,
    () => world,
    () => layout,
    () => currentArea.id,
    showToast,
    (id) => inv.add(id), // собранный цветок — в сумку (v1: inventory.add)
    (tx, ty, present) => trackFlowerTile(layerPos, tx, ty, present),
);

function stampAreaProps(): void {
    for (const def of currentArea.interactables ?? []) {
        if (def.once && interactables.isUsed(def.id)) continue; // поднято — не возрождается
        stampProp(world, def);
    }
}

// ---- Пейзажная фауна (акт 2): безгласные олени области ----
let fauna: FaunaSystem | null = null;
/** Глобальный вкл/выкл фауны (мост: гейт кадр-равенства — олени живые). */
let faunaEnabled = true;

/** Непроходимый тайл для фауны: колонка над землёй занята (вода/пропы). */
function tileBlocked(tx: number, ty: number): boolean {
    const vx = tx * UNIT + 1;
    const vz = ty * UNIT + 1;
    for (let y = GROUND_Y; y <= GROUND_Y + 1; y++) {
        if (world.get(vx, y, vz) !== 0) return true;
    }
    return false;
}

/** Сюжетные custom-эффекты интерактивов (вне диалогов, как в v1). */
onCustomEvent = (name: string): void => {
    if (name === 'lull_machine') cutscenes.start(CUTSCENE_STEPS.machine_lull!); // финал акта 2
};

/** Смена области: новый мир + мешы, герой на входном тайле, NPC, свет, музыка. */
function setArea(id: string, entry: Tile): void {
    currentArea = areaOf(id);
    layout = AREA_LAYOUTS[currentArea.id]!;
    disposeMeshes();
    world = buildAreaWorld(layout);
    remeshDirty();
    interactables = new Interactables(currentArea.interactables ?? [], state, interactSink);
    flowers.restore(); // правки цветков переживают пересборку мира
    stampAreaProps();
    spawnNpcs();
    const c = tileCenter(entry.x, entry.y);
    hero.body.pos = [c.x, GROUND_Y, c.z];
    hero.body.vel = [0, 0, 0];
    heroCloud.update(hero.visual());
    prevTile = { ...entry }; // входной тайл — не «только что вошёл» (без триггера)
    nearPick = null; // подсказка старой области не должна пережить переход
    inHazard = null;
    screenFx.reset(); // старый накат не переезжает в новую область
    showHint('');
    // свет области заново (источники/тон), пепел по атмосфере, тема с кроссфейдом
    lighting?.destroy();
    layerPos = layerTilePos();
    // правки цветков области (сбор/посадка) — в слой света сразу
    const flowerEdits = flowers.edits.get(currentArea.id);
    if (flowerEdits) {
        for (const [idx, present] of flowerEdits) {
            trackFlowerTile(layerPos, idx % layout.width, Math.floor(idx / layout.width), present);
        }
    }
    lighting = new GameLighting({
        renderer,
        lighting: LIGHTING[currentArea.id] as LightingDef | undefined,
        layerTilePos: layerPos,
        heroPos: () => ({ x: hero.body.pos[0], z: hero.body.pos[2] }),
        hasFlag: (f) => state.hasFlag(f),
        timeHours: () => clock.hours,
        timeSeconds: () => elapsed,
        lamp: HERO_LAMP,
    });
    areaAsh = ashEnabled && ATMOSPHERE[currentArea.id] === 'ash';
    if (!areaAsh) ash.hide(); // интерьер/туман — облако пепла не рисуется
    // Мотыльки лугов / дымка прудов (v1 Atmosphere): облако на профиль области.
    ambient?.dispose();
    const amb = ashEnabled ? ambientOf(ATMOSPHERE[currentArea.id]) : null;
    const waterCells = ashEnabled ? waterSurfaceCells(layout) : []; // якоря дымки/блики
    ambient = amb ? new AmbientParticles(renderer, amb, waterCells) : null;
    // Шиммер воды: блики на поверхности прудов (слой y=2 — вода)
    shimmer?.dispose();
    shimmer = ashEnabled ? new WaterShimmer(renderer, waterCells) : null;
    fauna?.dispose(); // фауна области заново (олени по спавнам v1)
    fauna = faunaEnabled ? new FaunaSystem({
        spawns: currentArea.fauna ?? [],
        blocked: tileBlocked,
        addCloud: (v) => renderer.addVoxelCloud(v),
    }) : null;
    void audio.playTheme(currentArea.id);
    // float-цель: следящая камера продолжит с той же точки без микро-скачка
    renderer.setTarget([c.x, GROUND_Y, c.z]);
}

// ---- Фейд и переходы: out → смена области → in (полный = 2×duration) ----
const fadeEl = document.getElementById('fade') as HTMLDivElement;
const hintEl = document.getElementById('hint') as HTMLDivElement;

interface FadeState {
    active: boolean;
    t: number;
    dur: number;
    phase: 'out' | 'in';
    /** Смена области — в середине перехода (за чёрным кадром). */
    apply: (() => void) | null;
}
const fade: FadeState = { active: false, t: 0, dur: 0.4, phase: 'out', apply: null };

let hintText = '';
/** Кандидат кнопки действия (v1 InteractCandidate): то, с чем можно «E» в радиусе. */
interface PickCandidate {
    kind: 'npc' | 'transition' | 'interact' | 'flower';
    /** Дистанция от героя до центра тайла цели (воксели) — решает выбор. */
    dist: number;
    /** Человекочитаемое имя для подписи «E — …». */
    label: string;
    npc?: NpcEntity;
    trans?: TransitionDef;
    inter?: InteractableDef;
    flower?: { x: number; y: number };
}
/** Приоритет при ничьей по дистанции: NPC важнее перехода, тот — объекта, объект — цветка. */
const PICK_PRIORITY: Record<PickCandidate['kind'], number> = {
    npc: 0,
    transition: 1,
    interact: 2,
    flower: 3,
};
/** Эпсилон ничьей (воксели; v1: 0.05 юнита × UNIT). */
const PICK_EPS = 0.5;

/** Выбор цели «E» (v1 pickInteract): ближайший кандидат; при ничьей — приоритет kind. */
function pickInteract(cands: PickCandidate[]): PickCandidate | null {
    let best: PickCandidate | null = null;
    for (const c of cands) {
        if (c.dist > INTERACT_RANGE) continue;
        if (
            best === null ||
            c.dist < best.dist - PICK_EPS ||
            (c.dist <= best.dist + PICK_EPS && PICK_PRIORITY[c.kind] < PICK_PRIORITY[best.kind])
        ) {
            best = c;
        }
    }
    return best;
}
/** Выбранная цель кнопки действия этого тика. */
let nearPick: PickCandidate | null = null;
/** Зона наката под ногами в прошлом тике (тост при входе — один раз). */
let inHazard: string | null = null;
/** Экранные слои атмосферы: виньетка опасности + пелена наката (v1). */
const screenFx = new ScreenFx(
    document.getElementById('vignette') as HTMLElement,
    document.getElementById('haze') as HTMLElement,
);
/** Тайл героя с прошлого тика (origin возврата + «только что вошёл»). */
let prevTile: Tile | null = null;

function showHint(text: string): void {
    hintText = text;
    hintEl.hidden = text === '';
    hintEl.textContent = text;
}

// ---- Диалоги: выбор графа по квест-стадиям → рантайм движка → DOM-view ----
const dialogEl = document.getElementById('dialog') as HTMLDivElement;

/** View поверх DOM-панели: реплика + кнопки вариантов (как в демке). */
function makeDialogueView(): DialogueView {
    return {
        show({ speaker, text, choices }) {
            const sp = document.createElement('div');
            sp.className = 'speaker';
            sp.textContent = speaker ? `${speaker}:` : '';
            const tx = document.createElement('div');
            tx.textContent = text;
            const box = document.createElement('div');
            box.className = 'choices';
            for (const c of choices) {
                const btn = document.createElement('button');
                btn.textContent = c.text;
                btn.addEventListener('click', () => {
                    runner.pick(c.index);
                    updateHud();
                });
                box.append(btn);
            }
            const cont = document.createElement('div');
            cont.className = 'continue';
            cont.textContent = choices.length ? '' : 'E — дальше';
            dialogEl.replaceChildren(sp, tx, box, cont);
            dialogEl.hidden = false;
        },
        hide() {
            dialogEl.hidden = true;
        },
    };
}

/** id открытого диалога (runner граф не помнит — для снапшота моста). */
let currentDialogueId: string | null = null;
const runner = new DialogueRunner(state, makeDialogueView(), invWorld);

runner.onEffect = (op: DialogueEffectOp) => {
    // движок только сообщает: исполнение — игра (give/take — сумка)
    if (op.kind === 'giveItem' && op.id) inv.add(op.id, op.count ?? 1);
    if (op.kind === 'takeItem' && op.id) inv.remove(op.id, op.count ?? 1);
    if (op.kind === 'toast' && op.text) showToast(op.text);
    if (op.kind === 'custom' && op.id) {
        // сюжетные катсцены — после реплик (поверх диалога не идут, как в v1)
        if (op.id === 'plant_flowers') pendingHandIn = true;
        else if (op.id === 'tune_resonator') pendingTune = true;
        else showToast(op.id);
    }
};

/** Катсцены отложены до конца диалога (v1: pendingCustom в onFinish). */
let pendingHandIn = false;
let pendingTune = false;
runner.onFinish = () => {
    if (pendingHandIn) {
        pendingHandIn = false;
        cutscenes.start(CUTSCENE_STEPS.hand_in!); // сдача цветов (акт 1)
    }
    if (pendingTune) {
        pendingTune = false;
        cutscenes.start(CUTSCENE_STEPS.tune!); // настройка резонатора (акт 2)
    }
};

/** Какой граф у NPC сейчас (v1): незнакомый — всегда first; знакомый — квест-стадия → repeat. */
function dialogueIdFor(npc: NpcDef): string {
    if (!state.hasFlag(npc.flagKey)) return npc.dialogueFirst; // знакомство не перекрывается квестом
    return questDialogueFor(state, npc.id, invWorld) ?? npc.dialogueRepeat;
}

/** Начать разговор с NPC (id или ближайший). */
function talkTo(id: string): void {
    const e = npcEntities.find((n) => n.def.id === id);
    if (!e || runner.active) return;
    const graphId = dialogueIdFor(e.def);
    const graph: DialogueGraph | undefined = DIALOGUES[graphId];
    if (!graph) return;
    currentDialogueId = graphId;
    runner.start(graph);
    updateHud();
}

/** Нажатие «E»: диалог → NPC → click-переход → интерактив → цветок (v1). */
function interact(): void {
    if (runner.active) {
        if (!runner.waitingForChoice) runner.advance();
        updateHud();
        return;
    }
    // катсцена/переход: цели «E» не сканируются (мир замер) — нажатия глотаем
    if (cutscenes.active || fade.active) return;
    const pick = nearPick;
    if (!pick) return;
    if (pick.kind === 'npc') {
        talkTo(pick.npc!.def.id);
        return;
    }
    if (pick.kind === 'transition') {
        startTransition(pick.trans!, heroTile());
        return;
    }
    if (pick.kind === 'interact') {
        const res = interactables.tryInteract(pick.inter!);
        if (res && !res.ok) showToast(res.lockedText); // заперто: сундук/машина/тон
        updateHud();
        return;
    }
    if (pick.flower) {
        flowers.collect(pick.flower.x, pick.flower.y);
        updateHud();
    }
}

// ---- HUD: журнал квестов (трекер) + сумка + тосты ----
const questEl = document.getElementById('quest') as HTMLDivElement;
const invEl = document.getElementById('inv') as HTMLDivElement;
const toastsEl = document.getElementById('toasts') as HTMLDivElement;

function updateHud(): void {
    // трекер: первая активная стадия (скрыт, когда квестов нет)
    const tr = questTracker(state);
    questEl.hidden = tr === null;
    if (tr !== null) questEl.textContent = tr;
    // сумка: «Имя ×N» по слотам
    if (inv.empty) {
        invEl.hidden = true;
        invEl.replaceChildren();
        return;
    }
    invEl.replaceChildren();
    const title = document.createElement('div');
    title.className = 'title';
    title.textContent = 'Сумка';
    invEl.append(title);
    for (const slot of inv.all) {
        const el = document.createElement('div');
        el.textContent = `${itemName(slot.id)} ×${slot.count}`;
        invEl.append(el);
    }
    invEl.hidden = false;
}
inv.onChange(updateHud);
updateHud();

/** Тост: всплывашка эффекта, уходит сама (реальное время). */
function showToast(text: string): void {
    const el = document.createElement('div');
    el.textContent = text;
    toastsEl.append(el);
    setTimeout(() => el.remove(), 2600);
}

// ---- Кат-сцены: данные (data/cutscenes.ts) + движковый CutsceneRunner ----
// мир замер, герой стоит, камера глайдит по шагам; по окончании камера
// возвращается к герою (политика игры — в tick).

/** Снапшот диалога для моста: узел/выборы/путь + флаги/вары + журнал. */
function dialogueSnapshot() {
    const node = runner.node;
    return {
        active: runner.active,
        graphId: currentDialogueId,
        nodeId: runner.nodeId,
        speaker: node?.speaker ?? null,
        text: node?.text ?? null,
        choices: runner.choices,
        waitingForChoice: runner.waitingForChoice,
        path: runner.path,
        result: runner.result,
        questTracker: questTracker(state),
        toasts: [...toastsEl.children].map((c) => c.textContent),
        ...state.toData(),
        inv: inv.all.map((s) => ({ id: s.id, count: s.count })),
    };
}

/** Начать переход (фейд out, середина — смена области). */
function startTransition(tr: TransitionDef, origin: Tile): void {
    if (fade.active) return; // параллельные переходы глотаются (как в v1)
    fade.active = true;
    fade.t = 0;
    fade.dur = tr.duration ?? 0.4;
    fade.phase = 'out';
    const fromArea = currentArea.id;
    fade.apply = () => {
        if (tr.target === 'return') {
            const back = returnStack.pop();
            // пустой стек (вход мостом/сейвом): v1 фолбэчится на спавн —
            // иначе герой остаётся на триггере и фейд зацикливается
            setArea(back ? back.area : fromArea, back ? back.entry : currentArea.spawn);
        } else {
            // origin — откуда вошли (шаг на триггер = прошлый тайл, дверь = стояли)
            returnStack.push({ area: fromArea, entry: origin });
            setArea(tr.target.area, tr.target.entry);
        }
    };
    showHint('');
}

// ---- Меню и сейвы: Esc — пауза с автосейвом; «Продолжить» после перезагрузки ----
const menuEl = document.getElementById('menu') as HTMLDivElement;
const menuContinue = document.getElementById('menu-continue') as HTMLButtonElement;
const menuNew = document.getElementById('menu-new') as HTMLButtonElement;
const saves = createSaveManager(window.localStorage);
let menuOpen = true; // страница стартует в меню (мир виден под панелью)

/** Снапшот текущего мира в пейлоад сейва. */
function saveSnapshot(): GameSave {
    const flowersData: [string, [number, boolean][]][] = [];
    for (const [area, per] of flowers.edits) flowersData.push([area, [...per]]);
    return {
        version: 1,
        area: currentArea.id,
        tile: heroTile(),
        state: state.toData(),
        inv: inv.serialize(),
        minutes: clock.minutes,
        flowers: flowersData,
    };
}

/** Применить сейв: правки цветков ДО setArea (мир и слой света читают журнал). */
function applySave(s: GameSave): void {
    state.fromData(s.state);
    inv.load(s.inv);
    clock.set(s.minutes);
    flowers.edits.clear();
    for (const [area, list] of s.flowers) flowers.edits.set(area, new Map(list));
    setArea(s.area, s.tile);
    updateHud(); // мутации вне тика обязаны обновить HUD сами (грабля)
}

/** Открыть меню: мир на паузе, автосейв текущего состояния в слот auto. */
function openMenu(): void {
    menuOpen = true;
    menuEl.hidden = false;
    saves.save('auto', saveSnapshot(), {
        title: `Автосейв — ${currentArea.id}`,
        savedAt: Date.now(),
        version: 1,
        extras: { area: currentArea.id },
    });
    menuContinue.disabled = !saves.has('auto');
}

/** Закрыть меню без загрузки (Esc в меню; «Войти в мир» — мир уже стоит). */
function closeMenu(): void {
    menuOpen = false;
    menuEl.hidden = true;
}

/** Кнопки меню: продолжить с автосейва (load) или начать заново. */
function startGame(load: boolean): void {
    if (load) {
        const s = saves.load<GameSave>('auto');
        if (s) applySave(s);
    }
    closeMenu();
}

menuContinue.addEventListener('click', () => startGame(true));
menuNew.addEventListener('click', () => startGame(false));
menuContinue.disabled = !saves.has('auto'); // сейвы прошлой сессии готовы к загрузке

// ---- Ввод: клавиатура (экранные оси) + виртуальный стик агента ----
const heldKeys = new Set<string>();
const KEYMAP: Record<string, readonly [number, number]> = {
    KeyW: [0, -1], KeyS: [0, 1], KeyA: [-1, 0], KeyD: [1, 0],
    ArrowUp: [0, -1], ArrowDown: [0, 1], ArrowLeft: [-1, 0], ArrowRight: [1, 0],
};
let agentInput: readonly [number, number] | null = null;
let followHero = true;

window.addEventListener('keydown', (e) => {
    // Esc: меню-пауза с автосейвом; открытый диалог прерывается (сейвы без диалогов)
    if (e.code === 'Escape') {
        e.preventDefault();
        if (menuOpen) closeMenu();
        else {
            if (runner.active) runner.abort();
            openMenu();
        }
        updateHud();
        return;
    }
    if (KEYMAP[e.code]) {
        heldKeys.add(e.code);
        e.preventDefault();
    }
    if (e.code === 'KeyE') {
        e.preventDefault();
        interact();
    }
    // выбор варианта ответа — цифрами (позиция в показанном списке)
    if (runner.active && runner.waitingForChoice && /^Digit[1-9]$/.test(e.code)) {
        e.preventDefault();
        runner.pick(Number(e.code.slice(5)) - 1);
        updateHud();
    }
});
window.addEventListener('keyup', (e) => {
    heldKeys.delete(e.code);
});

function keyboardMove(): [number, number] {
    let x = 0, y = 0;
    for (const code of heldKeys) {
        const m = KEYMAP[code]!;
        x += m[0];
        y += m[1];
    }
    return [x, y];
}

/** Тайл героя (в тайлах v1) — триггеры и снапшоты моста. */
function heroTile(): Tile {
    const b = hero.body.pos;
    return { x: Math.floor(b[0] / UNIT), y: Math.floor(b[2] / UNIT) };
}

/** Радиус взаимодействия с интерактивами/цветками: 1.5 юнита v1 = 15 вокселей. */
const INTERACT_RANGE = 15;

/** Один шаг логики: часы/свет → фейд → катсцена → движение/накат → цели «E». */
function tick(dt: number): void {
    if (menuOpen) return; // меню-пауза: мир стоит (автосейв снимается при открытии)
    // время суток и свет идут всегда (в фейде/катсцене — тоже, как в v1)
    clock.advance(dt);
    elapsed += dt;
    lighting?.update();
    if (areaAsh) ash.update(elapsed, renderer.currentTarget());
    ambient?.update(elapsed, renderer.currentTarget()); // мотыльки/дымка области
    shimmer?.update(elapsed); // блики на воде прудов
    fauna?.update(dt, elapsed); // олени бродят (в фейде/катсцене мир замер)
    if (fade.active) {
        fade.t += dt;
        if (fade.phase === 'out') {
            fadeEl.style.opacity = String(Math.min(1, fade.t / fade.dur));
            if (fade.t >= fade.dur) {
                fade.apply?.();
                fade.phase = 'in';
                fade.t = 0;
            }
        } else {
            fadeEl.style.opacity = String(Math.max(0, 1 - fade.t / fade.dur));
            if (fade.t >= fade.dur) {
                fade.active = false;
                fadeEl.style.opacity = '0';
            }
        }
        return; // во время перехода мир замер
    }
    if (cutscenes.active) {
        // катсцена: герой стоит, камера глайдит по шагам
        if (!cutscenes.update(dt)) {
            const b = hero.body.pos; // конец: камера возвращается к герою
            renderer.setTarget([b[0], GROUND_Y, b[2]]);
        }
        heroCloud.update(hero.visual());
        showHint('');
        return;
    }
    const talking = runner.active;
    // в диалоге герой стоит (как в v1: мир не тикается под репликой)
    const move: readonly [number, number] = talking ? [0, 0] : (agentInput ?? keyboardMove());
    const wm = screenMoveToWorld(move, ISO_YAW);
    const tile = heroTile();
    if (!talking) {
        // зона наката под ногами: без полотна еле идём, вход — тост (один раз)
        const hazard =
            (currentArea.hazards ?? []).find((h) =>
                h.tiles.some((t) => t.x === tile.x && t.y === tile.y),
            ) ?? null;
        const masked = hazard !== null && inv.has(hazard.requiresItem);
        if (hazard !== null && inHazard === null) {
            showToast(
                masked
                    ? `${hazard.name}: полотно держит — но пепел у самых губ.`
                    : `${hazard.name}! Дышать поверх — потерять голос.`,
            );
        }
        inHazard = hazard?.name ?? null;
        // виньетка (накат без полотна) и пелена низины — с фейдом (v1)
        screenFx.update(
            dt,
            hazard !== null && !masked ? 0.35 : 0,
            hazard === null ? 0 : masked ? 0.12 : 0.28,
        );
        if (hazard !== null && !masked) {
            hero.setMove([wm[0] * 0.5, wm[1] * 0.5]); // ползём по свежему пеплу
        } else {
            hero.setMove(wm);
        }
    } else {
        hero.setMove(wm);
    }
    hero.step(world, dt);
    heroCloud.update(hero.visual());
    if (followHero) {
        const b = hero.body.pos;
        renderer.setTarget([b[0], GROUND_Y, b[2]]); // без округления — камера не дёргается
    }
    // цели «E» (v1 InteractRouting): кандидаты в радиусе → ближайший,
    // приоритет kind только при ничьей
    nearPick = null;
    if (!talking) {
        const b = hero.body.pos;
        const cands: PickCandidate[] = [];
        for (const e of npcEntities) {
            const c = tileCenter(e.def.tile.x, e.def.tile.y);
            cands.push({ kind: 'npc', dist: Math.hypot(c.x - b[0], c.z - b[2]), label: 'Поговорить', npc: e });
        }
        for (const def of currentArea.interactables ?? []) {
            if (def.once && interactables.isUsed(def.id)) continue; // поднято — мимо
            const c = tileCenter(def.tile.x, def.tile.y);
            cands.push({
                kind: 'interact',
                dist: Math.hypot(c.x - b[0], c.z - b[2]),
                label: def.label ?? def.id,
                inter: def,
            });
        }
        // цветы: скан окна ±2 тайла вокруг героя по сборному тайлу области
        if (currentArea.harvestTile !== undefined) {
            for (let dy = -2; dy <= 2; dy++) {
                for (let dx = -2; dx <= 2; dx++) {
                    const tx = tile.x + dx;
                    const ty = tile.y + dy;
                    if (!flowers.isFlower(tx, ty)) continue;
                    const c = tileCenter(tx, ty);
                    cands.push({
                        kind: 'flower',
                        dist: Math.hypot(c.x - b[0], c.z - b[2]),
                        label: 'Сорвать лунный колокольчик',
                        flower: { x: tx, y: ty },
                    });
                }
            }
        }
        for (const t of currentArea.transitions) {
            if (t.trigger !== 'click') continue; // step-переходы — входом на тайл
            const c = tileCenter(t.tile.x, t.tile.y);
            cands.push({ kind: 'transition', dist: Math.hypot(c.x - b[0], c.z - b[2]), label: t.label ?? 'перейти', trans: t });
        }
        nearPick = pickInteract(cands);
    }
    if (talking) {
        showHint('');
        return; // диалог: без переходов и подсказок двери
    }
    // step-переход: только что вошёл на тайл-триггер
    const justEntered = prevTile !== null && (tile.x !== prevTile.x || tile.y !== prevTile.y);
    if (justEntered) {
        const tr = currentArea.transitions.find(
            (t) => (t.trigger ?? 'step') === 'step' && t.tile.x === tile.x && t.tile.y === tile.y,
        );
        if (tr) {
            startTransition(tr, prevTile!);
            return;
        }
    }
    prevTile = tile;
    // подсказка — выбранная цель (ближайшая, приоритет при ничьей — v1)
    showHint(nearPick ? `E — ${nearPick.label}` : '');
}

// ---- Цикл: фиксированный шаг 60 Гц, ручной режим для гейтов ----
const loop = new GameLoop(60, {
    update: (dt) => tick(dt),
    render: () => {
        remeshDirty();
        renderer.render();
    },
});
setArea(currentArea.id, currentArea.spawn); // старт — спавн лугов
loop.start();

// ---- Агентный мост (DEV): гейты областей ----
const agent = {
    /** Ручной/автоматический режим цикла. */
    setManual(on: boolean): void {
        loop.setManual(on);
    },
    /** Ручные шаги логики + рендер (детерминизм гейта). */
    stepTicks(n: number): number {
        return loop.stepTick(n, true);
    },
    /** Снапшот героя для гейта. */
    heroInfo() {
        const b = hero.body.pos;
        return {
            area: currentArea.id,
            pos: [...b], vel: [...hero.body.vel], grounded: hero.body.grounded,
            phase: hero.phase, yaw: hero.yaw,
            tile: heroTile(),
            camTarget: renderer.currentTarget(),
        };
    },
    /** Телепорт героя (гейт: слежение камеры, непроходимость). */
    heroTeleport(x: number, z: number): void {
        hero.body.pos = [x, GROUND_Y, z];
        hero.body.vel = [0, 0, 0];
        heroCloud.update(hero.visual());
        prevTile = heroTile(); // телепорт — не «вход на тайл»
        renderer.setTarget([x, GROUND_Y, z]);
    },
    /** Виртуальный стик агента (экранные оси −1..1); null — клавиатура. */
    setInput(x: number | null, z: number | null): void {
        agentInput = x === null || z === null ? null : [x, z];
    },
    /** Слежение камеры за героем (off — кадр гейта фиксируется). */
    setFollow(on: boolean): void {
        followHero = on;
        if (!on) {
            // float-цель спавна: кадр детерминирован (спавн фиксирован) и
            // совпадает с кадром после gotoArea (там тоже float-цель)
            const c = tileCenter(currentArea.spawn.x, currentArea.spawn.y);
            renderer.setTarget([c.x, GROUND_Y, c.z]);
        }
    },
    /** Яркость солнца (утро/день/вечер — как в демке). */
    setSun(k: number): void {
        renderer.setSun(k);
    },
    /** Время суток: снапшот, перемотка (гейт золотого часа/ночи). */
    clockInfo() {
        return {
            minutes: clock.minutes,
            hours: Math.round(clock.hours * 1000) / 1000,
            label: clock.label,
            nightFactor: lighting?.nightFactor ?? 0,
        };
    },
    setClock(minutes: number): void {
        clock.set(minutes);
        lighting?.update(); // свет пересчитать сразу, не дожидаясь тика
    },
    /** Снапшот освещения: ночь + активные источники (пост-мерцание). */
    lightingInfo(): ReturnType<GameLighting['snapshot']> {
        return lighting?.snapshot() ?? { nightFactor: 0, sources: [] };
    },
    /** Снапшот частиц области: пепел/мотыльки-дымка/шиммер воды (гейт). */
    ambientInfo() {
        return {
            ash: areaAsh,
            ambient: ambient?.info ?? null,
            shimmer: shimmer?.info ?? null,
        };
    },
    /** Снапшот аудио: тема области + кольцевой буфер фактических запусков. */
    audioInfo() {
        return {
            theme: audio.theme,
            played: audio.log,
        };
    },
    /** Пепел вкл/выкл (гейт: кадр-равенство сборки — мир без атмосферы). */
    setAsh(on: boolean): void {
        ashEnabled = on;
        areaAsh = on && ATMOSPHERE[currentArea.id] === 'ash';
        if (!areaAsh) ash.hide();
        // частицы области гасятся тем же мостом (кадр-равенства)
        ambient?.hide();
        ambient = null;
        shimmer?.hide();
        shimmer = null;
    },
    /** Фауна вкл/выкл (гейт: кадр-равенство — олени бродят в реальном времени). */
    setFauna(on: boolean): void {
        faunaEnabled = on;
        if (!on) {
            fauna?.dispose();
            fauna = null;
        }
    },
    /** Снапшот области: раскладка, переходы, фейд, стек возврата, меши. */
    areaInfo() {
        return {
            id: currentArea.id,
            name: currentArea.name,
            width: layout.width,
            height: layout.height,
            spawn: currentArea.spawn,
            heroTile: heroTile(),
            transitions: currentArea.transitions.map((t) => ({
                tile: t.tile,
                label: t.label ?? null,
                trigger: t.trigger ?? 'step',
                target: t.target === 'return' ? 'return' : t.target.area,
                duration: t.duration ?? 0.4,
            })),
            fade: { active: fade.active, phase: fade.phase },
            returns: returnStack.length,
            chunks: world.chunkCount,
            meshes: chunkMeshes.size,
        };
    },
    /** Прямой переход области (гейт: скриншоты и переходы без ходьбы). */
    gotoArea(id: string, entry?: Tile): void {
        returnStack.length = 0;
        fade.active = false;
        fadeEl.style.opacity = '0';
        const def = areaOf(id);
        setArea(id, entry ?? def.spawn);
    },
    /** Сплошная ли колонка НАД землёй (вода/объект) — гейт blocked. */
    solidAt(x: number, z: number): boolean {
        const fx = Math.floor(x), fz = Math.floor(z);
        for (let y = GROUND_Y; y <= GROUND_Y + 1; y++) {
            if (world.get(fx, y, fz) !== 0) return true;
        }
        return false;
    },
    /** Центр тайла в мировых вокселях (тулза shot.mjs, гейты). */
    tileCenter(tx: number, ty: number): { x: number; z: number } {
        return tileCenter(tx, ty);
    },
    /** Нажать «E» агентом: диалог NPC / click-переход / «дальше» в диалоге. */
    interact(): void {
        interact();
    },
    /** Снапшот взаимодействия: ближайшая цель (приоритет v1) и подсказка. */
    interactInfo() {
        const p = nearPick;
        return {
            label: p?.kind === 'transition' ? p.trans?.label ?? null : null,
            hint: hintText,
            kind: p ? p.kind : null,
            near:
                p?.kind === 'npc' ? p.npc!.def.id
                : p?.kind === 'interact' ? p.inter!.id
                : p?.kind === 'flower' ? 'flower'
                : null,
            target:
                p?.kind === 'interact'
                    ? { id: p.inter!.id, kind: p.inter!.kind, tile: p.inter!.tile }
                    : null,
            flower: p?.kind === 'flower' ? p.flower! : null,
        };
    },
    /** Слот вокселя мира (гейт: пропы, цветы, вода). */
    voxAt(x: number, y: number, z: number): number {
        return world.get(Math.floor(x), Math.floor(y), Math.floor(z));
    },
    /** Тайлы области с лунными колокольчиками (гейт квеста цветов). */
    flowerTiles(): { x: number; y: number }[] {
        const out: { x: number; y: number }[] = [];
        for (let y = 0; y < layout.height; y++) {
            for (let x = 0; x < layout.width; x++) {
                if (flowers.isFlower(x, y)) out.push({ x, y });
            }
        }
        return out;
    },
    /** NPC области: кто стоит, тайлы, кто ближайший для «E». */
    npcInfo() {
        return {
            area: currentArea.id,
            npcs: npcEntities.map((e) => ({
                id: e.def.id,
                name: e.def.name,
                tile: e.def.tile,
                pos: [...e.char.body.pos],
                dialogue: dialogueIdFor(e.def),
            })),
            near: nearPick?.kind === 'npc' ? nearPick.npc!.def.id : null,
        };
    },
    /** Начать разговор (id NPC; без аргумента — ближайший). */
    talkTo(id?: string): ReturnType<typeof dialogueSnapshot> {
        const target = id ?? (nearPick?.kind === 'npc' ? nearPick.npc!.def.id : undefined);
        if (target) talkTo(target);
        return dialogueSnapshot();
    },
    /** Снапшот диалога/квестов: узел, выборы, путь, флаги, вары, журнал. */
    dialogueInfo(): ReturnType<typeof dialogueSnapshot> {
        return dialogueSnapshot();
    },
    /** Выбрать вариант ответа (индекс в показанном списке). */
    dialoguePick(index: number): ReturnType<typeof dialogueSnapshot> {
        runner.pick(index);
        updateHud();
        return dialogueSnapshot();
    },
    /** «Дальше» в диалоге. */
    dialogueAdvance(): ReturnType<typeof dialogueSnapshot> {
        if (runner.active && !runner.waitingForChoice) runner.advance();
        updateHud();
        return dialogueSnapshot();
    },
    /** Прервать диалог досрочно (гейт/сейв). */
    dialogueAbort(): void {
        runner.abort();
        updateHud();
    },
    /** Правка состояния агентом (гейт квест-стадий): вары и флаги. */
    setState(patch: { vars?: Record<string, number | string | boolean>; flags?: string[] }): void {
        for (const [k, v] of Object.entries(patch.vars ?? {})) state.setVar(k, v);
        for (const f of patch.flags ?? []) state.setFlag(f);
        updateHud();
    },
    /** Снапшот фауны: олени области (позиция/состояние) — гейт трека 19. */
    faunaInfo() {
        return { area: currentArea.id, deer: fauna?.info() ?? [] };
    },
    /** Громкий звон в точке мира (гейт: олени подходят к источнику). */
    startleFauna(x: number, z: number): void {
        fauna?.startleAt(x, z);
    },
    /** Камера на точку мира без телепорта героя (агентный просмотр участка). */
    setCam(x: number, z: number): void {
        followHero = false;
        renderer.setTarget([x, GROUND_Y, z]);
    },
    /** Снапшот катсцены: играет ли, шаг, таймер, каталог сцен. */
    cutsceneInfo() {
        return { ...cutscenes.info(), catalog: Object.keys(CUTSCENE_STEPS) };
    },
    /** Играть катсцену по id (агентный dry-run: гейт доигрывает до конца). */
    playCutscene(id: string) {
        cutscenes.start(CUTSCENE_STEPS[id] ?? []);
        return cutscenes.info();
    },
    /** Снапшот состояния сюжета (флаги/вары) и сумки. */
    stateInfo() {
        return { ...state.toData(), inv: inv.all.map((s) => ({ id: s.id, count: s.count })) };
    },
    /** Снапшот меню: открыто ли, есть ли автосейв (гейт меню/сейвов). */
    menuInfo() {
        return { open: menuOpen, hasAuto: saves.has('auto') };
    },
    /** Меню: открытие с автосейвом / закрытие / вход в игру (гейт). */
    gotoMenu(): void {
        if (menuOpen) return;
        if (runner.active) runner.abort();
        openMenu();
        updateHud();
    },
    startGame(load: boolean): void {
        startGame(load);
        updateHud();
    },
};
(window as unknown as Record<string, unknown>).__agent = agent;
(window as unknown as Record<string, unknown>).__beacon = true;