import { useEffect, useRef, useState, useCallback, useMemo, type CSSProperties } from 'react';
import { GameEngine } from './game/engine';
import {
  GamePhase,
  BuffType,
  type GameState,
  type ActiveBuff,
  type NPCData,
  type AttackPayload,
  type EnemyRegenPayload,
  type NearbyHeroData,
} from './game/types';
import type { NPCEncounterEvent } from './game/types';
import { GameWebSocket } from './network/websocket';
import {
  wireWSHandler,
  sendActivateBuff,
  sendUsePotion,
  sendRevive,
  sendNPCAlmsAccept,
  sendNPCAlmsDecline,
  sendTownTourNPCDialogClosed,
  sendTownTourNPCInteractionOpened,
  sendTownTourNPCInteractionClosed,
  sendRequestNearbyHeroes,
  buildLootFromCombatEnd,
  buildMerchantLootDrop,
} from './game/ws-handler';
import {
  initHero,
  ackChangelog,
  getAdventureLog,
  getTowns,
  getTownNPCs,
  getTownBuildings,
  getHeroQuests,
  getHeroEquipment,
  claimQuest,
  abandonQuest,
  getAchievements,
  requestRevive,
  defaultNpcShopBundle,
  npcShopCostsFromInit,
  offlineReportHasActivity,
} from './network/api';
import type { HeroResponse, Achievement, ChangelogPayload } from './network/api';
import type {
  AdventureLogEntry,
  Town,
  HeroQuest,
  NPC,
  TownData,
  EquipmentItem,
  BuildingData,
  TownTourPhasePayload,
} from './game/types';
import type { OfflineReport as OfflineReportData } from './network/api';
import {
  BUFF_COOLDOWN_MS,
  BUFF_DURATION_MS,
  debuffDurationsFromCatalog,
  mapHeroBuffsFromServer,
  mapHeroDebuffsFromServer,
} from './network/buffMap';
import { hapticImpact, hapticNotification, onThemeChanged, getTelegramUserId } from './shared/telegram';
import { Rarity } from './game/types';
import type { HeroState, BuffChargeState } from './game/types';
import { useUiClock } from './hooks/useUiClock';
import {
  adventureEntriesFromServerLog,
  appendAdventureLogRawRow,
  buildAdventureLogEntriesFromRaw,
  type AdventureLogRawRow,
} from './game/adventureLogMap';
import {
  isAdventureLogCombatCode,
  isAdventureLogEncounterCode,
  parseAdventureLogLine,
  shouldSuppressThoughtBubblePayload,
} from './game/adventureLogMarkers';
import { formatAdventureLogPayload, formatClientLogLine } from './game/adventureLogFormat';
import { townLabel, npcLabel, dialogueText } from './i18n/contentLabels';
import type { AdventureLogLinePayload } from './game/types';
import { HUD } from './ui/HUD';
import { DeathScreen } from './ui/DeathScreen';
import { CombatOverlay, type CombatOverlayEvent } from './ui/CombatOverlay';
import { CombatLogPanel } from './ui/CombatLogPanel';
import { GameToast } from './ui/GameToast';
import { OfflineReport } from './ui/OfflineReport';
import { HeroSheetModal, type HeroSheetTab } from './ui/HeroSheetModal';
import { NPCDialog } from './ui/NPCDialog';
import { NameEntryScreen } from './ui/NameEntryScreen';
import { ChangelogModal } from './ui/ChangelogModal';
import { AchievementsPanel } from './ui/AchievementsPanel';
import { Minimap } from './ui/Minimap';
import { NPCInteraction } from './ui/NPCInteraction';
import { WanderingNPCPopup } from './ui/WanderingNPCPopup';
import { HeroMeetPanel } from './ui/HeroMeetPanel';
import { I18nContext, t, detectLocale, getTranslations, type Locale } from './i18n';
import { adventureLogTemplate, randomRoadsideThoughtLine } from './i18n/loadLocales';

const appStyle: CSSProperties = {
  width: '100%',
  height: '100%',
  position: 'relative',
  overflow: 'hidden',
};

const canvasContainerStyle: CSSProperties = {
  width: '100%',
  height: '100%',
  position: 'absolute',
  top: 0,
  left: 0,
};

const connectionBanner: CSSProperties = {
  position: 'absolute',
  top: 0,
  left: 0,
  right: 0,
  textAlign: 'center',
  padding: '4px 0',
  fontSize: 12,
  fontWeight: 600,
  color: '#fff',
  backgroundColor: 'rgba(204, 51, 51, 0.85)',
  zIndex: 200,
};

function buffCdStorageKey(heroId: number): string {
  return `autohero-buffCd-v1-${heroId}`;
}

function pruneCooldownEnds(
  raw: Partial<Record<BuffType, number>>,
  now: number,
): Partial<Record<BuffType, number>> {
  const out: Partial<Record<BuffType, number>> = {};
  for (const [k, v] of Object.entries(raw)) {
    if (typeof v === 'number' && v > now && (Object.values(BuffType) as string[]).includes(k)) {
      out[k as BuffType] = v;
    }
  }
  return out;
}

function loadCooldownsFromStorage(heroId: number): Partial<Record<BuffType, number>> {
  try {
    const raw = sessionStorage.getItem(buffCdStorageKey(heroId));
    if (!raw) return {};
    const parsed = JSON.parse(raw) as Partial<Record<BuffType, number>>;
    return pruneCooldownEnds(parsed, Date.now());
  } catch {
    return {};
  }
}

function saveCooldownsToStorage(heroId: number, ends: Partial<Record<BuffType, number>>): void {
  try {
    const pruned = pruneCooldownEnds(ends, Date.now());
    sessionStorage.setItem(buffCdStorageKey(heroId), JSON.stringify(pruned));
  } catch {
    /* ignore quota / private mode */
  }
}

function xpToNextLevel(level: number): number {
  if (level < 1) level = 1;
  if (level <= 9) return Math.round(180 * Math.pow(1.28, level - 1));
  if (level <= 29) return Math.round(1450 * Math.pow(1.15, level - 10));
  return Math.round(23000 * Math.pow(1.10, level - 30));
}

function normalizeHeroModelVariant(v: unknown): number {
  return typeof v === 'number' && Number.isInteger(v) && v >= 0 && v <= 2 ? v : 0;
}

/** Map backend buffCharges (keyed by string) into typed Partial<Record<BuffType, BuffChargeState>>. */
function mapBuffCharges(
  raw: Record<string, { remaining: number; periodEnd: string | null }> | undefined,
): Partial<Record<BuffType, BuffChargeState>> {
  if (!raw) return {};
  const out: Partial<Record<BuffType, BuffChargeState>> = {};
  const validTypes = new Set(Object.values(BuffType) as string[]);
  for (const [key, val] of Object.entries(raw)) {
    if (validTypes.has(key)) {
      out[key as BuffType] = { remaining: val.remaining, periodEnd: val.periodEnd };
    }
  }
  return out;
}

/** Map backend equipment record to typed EquipmentItem map. */
function mapEquipment(
  raw: HeroResponse['equipment'],
  res: HeroResponse,
): Record<string, EquipmentItem> {
  const out: Record<string, EquipmentItem> = {};

  // REST uses `equipment`, WS hero_state from Go uses `gear`. Treat JSON null like missing.
  const merged = raw ?? res.gear;
  const rawSlots =
    merged != null && typeof merged === 'object' ? merged : undefined;

  if (rawSlots) {
    for (const [slot, item] of Object.entries(rawSlots)) {
      out[slot] = {
        id: item.id,
        slot: item.slot ?? slot,
        formId: item.formId ?? '',
        name: item.name,
        rarity: (item.rarity?.toLowerCase() ?? 'common') as EquipmentItem['rarity'],
        ilvl: item.ilvl ?? 1,
        primaryStat: item.primaryStat ?? 0,
        statType: item.statType ?? 'mixed',
        subtype: item.subtype,
      };
    }
  }

  // Fallback: populate main_hand from legacy weapon if not already set
  if (!out['main_hand'] && res.weapon) {
    out['main_hand'] = {
      id: res.weapon.id ?? 0,
      slot: 'main_hand',
      formId: '',
      name: res.weapon.name ?? 'Unknown',
      rarity: (res.weapon.rarity?.toLowerCase() ?? 'common') as EquipmentItem['rarity'],
      ilvl: res.weapon.ilvl ?? 1,
      primaryStat: res.weapon.damage ?? 0,
      statType: 'attack',
    };
  }

  if (!out['chest'] && res.armor) {
    out['chest'] = {
      id: res.armor.id ?? 0,
      slot: 'chest',
      formId: '',
      name: res.armor.name ?? 'Unknown',
      rarity: (res.armor.rarity?.toLowerCase() ?? 'common') as EquipmentItem['rarity'],
      ilvl: res.armor.ilvl ?? 1,
      primaryStat: res.armor.defense ?? 0,
      statType: 'defense',
    };
  }

  return out;
}

function townTourPayloadToNPCData(p: TownTourPhasePayload, loc: Locale, town: Town | null): NPCData {
  const tw = town ?? undefined;
  const displayName = p.npcNameKey ? npcLabel(loc, p.npcNameKey, p.npcName ?? '') : (p.npcName ?? '');
  return {
    id: p.npcId ?? 0,
    name: displayName,
    nameKey: p.npcNameKey,
    type: (p.npcType ?? 'merchant') as NPCData['type'],
    worldX: p.worldX ?? 0,
    worldY: p.worldY ?? 0,
    townId: p.townId,
    townLevelMin: tw?.levelMin ?? 1,
    townLevelMax: tw?.levelMax ?? 1,
  };
}

/** Convert Town (from /towns API) to engine-facing TownData, optionally with NPCs and buildings */
function townToTownData(
  town: Town,
  npcs?: NPC[],
  buildings?: BuildingData[],
  locale: Locale = 'en',
): TownData {
  const npcData: NPCData[] | undefined = npcs?.map((n) => ({
    id: n.id,
    name: n.nameKey ? npcLabel(locale, n.nameKey, n.name) : n.name,
    nameKey: n.nameKey,
    type: n.type,
    worldX: town.worldX + n.offsetX,
    worldY: town.worldY + n.offsetY,
    buildingId: n.buildingId,
    townId: town.id,
    townLevelMin: town.levelMin,
    townLevelMax: town.levelMax,
  }));
  return {
    id: town.id,
    name: town.nameKey ? townLabel(locale, town.nameKey, town.name) : town.name,
    nameKey: town.nameKey,
    centerX: town.worldX,
    centerY: town.worldY,
    radius: town.radius,
    biome: town.biome,
    levelMin: town.levelMin,
    size: town.radius > 40 ? 'XL' : town.radius > 25 ? 'M' : town.radius > 15 ? 'S' : 'XS',
    npcs: npcData,
    buildings: buildings,
  };
}

function heroResponseToState(res: HeroResponse): HeroState {
  const now = Date.now();
  return {
    id: res.id,
    modelVariant: normalizeHeroModelVariant(res.modelVariant),
    hp: res.hp,
    maxHp: res.maxHp,
    position: { x: res.positionX ?? 0, y: res.positionY ?? 0 },
    serverActivityState: res.state,
    restKind: res.restKind,
    excursionPhase: res.excursionPhase,
    excursionKind:
      res.excursionKind === 'roadside' ||
      res.excursionKind === 'adventure' ||
      res.excursionKind === 'town' ||
      res.excursionKind === 'hero_meet'
        ? (res.excursionKind as HeroState['excursionKind'])
        : undefined,
    townTourPhase: res.townTourPhase,
    townTourNpcId: res.townTourNpcId,
    townTourExitPending: res.townTourExitPending,
    attackSpeed: res.attackSpeed ?? res.speed,
    damage: res.attackPower ?? res.attack,
    defense: res.defensePower ?? res.defense,
    weaponType: (res.gear?.main_hand?.subtype ?? res.weapon?.type ?? 'sword') as HeroState['weaponType'],
    weaponName: res.gear?.main_hand?.name ?? res.weapon?.name ?? '',
    weaponRarity: (res.gear?.main_hand?.rarity ?? res.weapon?.rarity ?? 'common') as Rarity,
    weaponIlvl: res.gear?.main_hand?.ilvl ?? res.weapon?.ilvl,
    armorType: (res.gear?.chest?.subtype ?? res.armor?.type ?? 'medium') as HeroState['armorType'],
    armorName: res.gear?.chest?.name ?? res.armor?.name ?? '',
    armorRarity: (res.gear?.chest?.rarity ?? res.armor?.rarity ?? 'common') as Rarity,
    armorIlvl: res.gear?.chest?.ilvl ?? res.armor?.ilvl,
    activeBuffs: mapHeroBuffsFromServer(res.buffs, now),
    debuffs: mapHeroDebuffsFromServer(res.debuffs, now),
    level: res.level,
    xp: res.xp,
    xpToNext: res.xpToNext ?? xpToNextLevel(res.level || 1),
    gold: res.gold,
    strength: res.strength,
    constitution: res.constitution,
    agility: res.agility,
    luck: res.luck,
    reviveCount: res.reviveCount,
    subscriptionActive: res.subscriptionActive,
    buffFreeChargesRemaining: res.buffFreeChargesRemaining,
    buffQuotaPeriodEnd: res.buffQuotaPeriodEnd,
    buffCharges: mapBuffCharges(res.buffCharges),
    potions: res.potions ?? 0,
    moveSpeed: res.moveSpeed,
    equipment: mapEquipment(res.equipment, res),
    inventory: mapInventoryFromResponse(res.inventory),
    debuffCatalogDurations: debuffDurationsFromCatalog(res.debuffCatalog),
  };
}

function mapInventoryFromResponse(raw: HeroResponse['inventory']): EquipmentItem[] | undefined {
  if (!raw?.length) return undefined;
  return raw.map((it) => ({
    id: it.id,
    slot: it.slot,
    formId: it.formId ?? '',
    name: it.name,
    rarity: it.rarity as Rarity,
    ilvl: it.ilvl ?? 1,
    primaryStat: it.primaryStat ?? 0,
    statType: it.statType ?? 'mixed',
  }));
}


export function App() {
  const [locale, setLocale] = useState<Locale>(() => detectLocale());
  const translations = useMemo(() => getTranslations(locale), [locale]);
  const handleSetLocale = useCallback((l: Locale) => {
    setLocale(l);
    try { localStorage.setItem('autohero_locale', l); } catch { /* ignore */ }
  }, []);
  const tr = translations;

  const canvasRef = useRef<HTMLDivElement>(null);
  const engineRef = useRef<GameEngine | null>(null);
  const wsRef = useRef<GameWebSocket | null>(null);

  const [gameState, setGameState] = useState<GameState>({
    phase: GamePhase.Walking,
    hero: null,
    enemy: null,
    loot: null,
    lastVictoryLoot: null,
    tick: 0,
    serverTimeMs: 0,
    routeWaypoints: null,
  });

  const [combatEvents, setCombatEvents] = useState<CombatOverlayEvent[]>([]);
  const [wsConnected, setWsConnected] = useState(false);
  const [wsEverConnected, setWsEverConnected] = useState(false);
  const [buffCooldownEndsAt, setBuffCooldownEndsAt] = useState<
    Partial<Record<BuffType, number>>
  >({});
  const [connectionError, setConnectionError] = useState<string | null>(null);
  const [toast, setToast] = useState<{ message: string; color: string } | null>(null);
  const [logEntries, setLogEntries] = useState<AdventureLogEntry[]>([]);
  /** Live combat narration (mirrors prefixed adventure log lines). */
  const [combatLogLines, setCombatLogLines] = useState<string[]>([]);
  const [offlineReport, setOfflineReport] = useState<OfflineReportData | null>(null);
  const [needsName, setNeedsName] = useState(false);
  type ChangelogOpen = { payload: ChangelogPayload; serverVersion?: string };
  const pendingChangelogRef = useRef<ChangelogOpen | null>(null);
  const [changelogOpen, setChangelogOpen] = useState<ChangelogOpen | null>(null);
  const logIdCounter = useRef(0);
  const logRawRef = useRef<AdventureLogRawRow[]>([]);
  const i18nForLogRef = useRef({ locale, tr: translations });
  i18nForLogRef.current = { locale, tr: translations };
  const nearbyIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);

  useEffect(() => {
    engineRef.current?.refreshRandomRestThoughtForLocale();
  }, [locale]);

  // Quest system state
  const [towns, setTowns] = useState<Town[]>([]);
  const townsRef = useRef<Town[]>([]);
  const [heroQuests, setHeroQuests] = useState<HeroQuest[]>([]);
  const [currentTown, setCurrentTown] = useState<Town | null>(null);
  const [selectedNPC, setSelectedNPC] = useState<NPC | null>(null);
  const [heroSheetOpen, setHeroSheetOpen] = useState(false);
  const [heroSheetInitialTab, setHeroSheetInitialTab] = useState<HeroSheetTab>('stats');

  // NPC interaction (legacy / non-tour); town tour uses `townTourLastPayload` (NPCInteraction first; NPCDialog only after tap).
  const [nearestNPC, setNearestNPC] = useState<NPCData | null>(null);
  const [npcInteractionDismissed, setNpcInteractionDismissed] = useState<number | null>(null);
  const [townTourLastPayload, setTownTourLastPayload] = useState<TownTourPhasePayload | null>(null);

  const townTourDialogWs = useMemo(
    () => ({
      onInteractionOpened: () => {
        const w = wsRef.current;
        if (w) sendTownTourNPCInteractionOpened(w);
      },
      onDialogAndInteractionClosed: () => {
        const w = wsRef.current;
        if (!w) return;
        sendTownTourNPCDialogClosed(w);
        sendTownTourNPCInteractionClosed(w);
      },
    }),
    [],
  );

  // Wandering NPC encounter state
  const [wanderingNPC, setWanderingNPC] = useState<NPCEncounterEvent | null>(null);

  const [heroMeetPanel, setHeroMeetPanel] = useState<{
    partnerName: string;
    anySideOnline: boolean;
    maxChars: number;
  } | null>(null);
  const [npcShopCosts, setNpcShopCosts] = useState(defaultNpcShopBundle);
  // Achievements
  const [achievements, setAchievements] = useState<Achievement[]>([]);
  const prevAchievementsRef = useRef<Achievement[]>([]);

  const sheetNowMs = useUiClock(100);

  const appendLogPayload = useCallback((p: AdventureLogLinePayload) => {
    const { locale: loc, tr: bundle } = i18nForLogRef.current;
    logIdCounter.current += 1;
    const id = logIdCounter.current;
    logRawRef.current = appendAdventureLogRawRow(
      logRawRef.current,
      { id, message: p.message ?? '', timestamp: Date.now(), event: p.event },
      () => 0,
    );
    setLogEntries(buildAdventureLogEntriesFromRaw([...logRawRef.current], loc, bundle));

    const thoughtText = formatAdventureLogPayload(loc, bundle, p);
    const eng = engineRef.current;
    if (thoughtText && eng && !shouldSuppressThoughtBubblePayload(p)) {
      eng.applyAdventureLogLine(thoughtText);
    }

    if (isAdventureLogEncounterCode(p.event?.code) && thoughtText) {
      setCombatLogLines([thoughtText]);
    } else {
      const parsed = parseAdventureLogLine(p.message ?? '');
      if (parsed.type === 'encounter') {
        setCombatLogLines([parsed.title]);
      } else if (parsed.type === 'battle') {
        setCombatLogLines((prev) => [...prev, parsed.text].slice(-5));
      } else if (isAdventureLogCombatCode(p.event?.code) && thoughtText) {
        setCombatLogLines((prev) => [...prev, thoughtText].slice(-5));
      }
    }
  }, []);

  const appendLogClientMessage = useCallback((rawMessage: string) => {
    const { locale: loc, tr: bundle } = i18nForLogRef.current;
    logIdCounter.current += 1;
    const id = logIdCounter.current;
    logRawRef.current = appendAdventureLogRawRow(
      logRawRef.current,
      { id, message: rawMessage, timestamp: Date.now() },
      () => 0,
    );
    setLogEntries(buildAdventureLogEntriesFromRaw([...logRawRef.current], loc, bundle));
    const parsed = parseAdventureLogLine(rawMessage);
    if (parsed.type === 'encounter') {
      setCombatLogLines([parsed.title]);
    } else if (parsed.type === 'battle') {
      setCombatLogLines((prev) => [...prev, parsed.text].slice(-5));
    }
  }, []);

  useEffect(() => {
    const { locale: loc, tr: bundle } = i18nForLogRef.current;
    setLogEntries(buildAdventureLogEntriesFromRaw([...logRawRef.current], loc, bundle));
  }, [locale, translations]);

  useEffect(() => {
    const eng = engineRef.current;
    if (!eng || towns.length === 0) return;
    let cancelled = false;
    (async () => {
      const townNPCMap = new Map<number, NPC[]>();
      const townBuildingMap = new Map<number, BuildingData[]>();
      try {
        const [npcResults, buildingResults] = await Promise.all([
          Promise.allSettled(towns.map((town) => getTownNPCs(town.id).then((npcs) => ({ townId: town.id, npcs })))),
          Promise.allSettled(towns.map((town) => getTownBuildings(town.id).then((b) => ({ townId: town.id, buildings: b })))),
        ]);
        for (const result of npcResults) {
          if (result.status === 'fulfilled') townNPCMap.set(result.value.townId, result.value.npcs);
        }
        for (const result of buildingResults) {
          if (result.status === 'fulfilled') townBuildingMap.set(result.value.townId, result.value.buildings);
        }
      } catch {
        /* ignore */
      }
      if (cancelled) return;
      const loc = locale;
      const townDataList = towns.map((town) =>
        townToTownData(town, townNPCMap.get(town.id), townBuildingMap.get(town.id), loc),
      );
      eng.setTowns(townDataList);
      const allNPCs: NPCData[] = [];
      for (const td of townDataList) {
        if (td.npcs) allNPCs.push(...td.npcs);
      }
      eng.setNPCs(allNPCs);
    })();
    return () => {
      cancelled = true;
    };
  }, [locale, towns]);

  const appendCombatEvent = useCallback((evt: CombatOverlayEvent) => {
    setCombatEvents((prev) => [...prev, evt]);
  }, []);

  const clearCombatEvents = useCallback(() => {
    setCombatEvents([]);
  }, []);

  const expireCombatEvent = useCallback((id: number) => {
    setCombatEvents((prev) => prev.filter((evt) => evt.id !== id));
  }, []);

  const handleCombatAttack = useCallback((p: AttackPayload) => {
    if (p.source === 'potion') {
      return;
    }

    const defender: CombatOverlayEvent['target'] =
      p.source === 'enemy' ? 'hero' : 'enemy';
    const isBlocked = p.outcome === 'block';
    const isEvaded = p.outcome === 'dodge';
    const isStunned = p.outcome === 'stun';
    const isCrit = Boolean(p.isCrit);

    if (isStunned) {
      appendCombatEvent({
        id: Date.now() + Math.random(),
        kind: 'stunned',
        target: 'hero',
        value: 0,
        createdAt: performance.now(),
      });
    } else if (isBlocked || isEvaded) {
      appendCombatEvent({
        id: Date.now() + Math.random(),
        kind: isBlocked ? 'blocked' : 'evaded',
        target: defender,
        value: 0,
        createdAt: performance.now(),
      });
    } else {
      appendCombatEvent({
        id: Date.now() + Math.random(),
        kind: 'damage',
        target: defender,
        value: p.damage,
        isCrit,
        createdAt: performance.now(),
      });
    }

    const engine = engineRef.current;
    if (!engine) return;

    const isDotLike = p.source === 'dot' || p.source === 'summon';
    if (isDotLike) return;

    if (isStunned) {
      hapticImpact('light');
      engine.camera.shake(3, 120);
      return;
    }

    if (isBlocked || isEvaded) {
      hapticImpact('light');
      engine.camera.shake(3, 120);
      return;
    }

    if (isCrit) {
      hapticImpact('heavy');
      engine.camera.shake(8, 250);
    } else {
      hapticImpact('light');
      engine.camera.shake(4, 150);
    }
  }, [appendCombatEvent]);

  const handleEnemyRegen = useCallback((p: EnemyRegenPayload) => {
    if (p.amount <= 0) return;
    appendCombatEvent({
      id: Date.now() + Math.random(),
      kind: 'regen',
      target: 'enemy',
      value: p.amount,
      createdAt: performance.now(),
    });
  }, [appendCombatEvent]);

  const refreshEquipment = useCallback(() => {
    const telegramId = getTelegramUserId() ?? 1;
    getHeroEquipment(telegramId)
      .then((eqMap) => {
        const merged: Record<string, EquipmentItem> = {};
        for (const [slot, item] of Object.entries(eqMap)) {
          merged[slot] = {
            id: item.id,
            slot: item.slot ?? slot,
            formId: item.formId ?? '',
            name: item.name,
            rarity: (item.rarity?.toLowerCase() ?? 'common') as EquipmentItem['rarity'],
            ilvl: item.ilvl ?? 1,
            primaryStat: item.primaryStat ?? 0,
            statType: item.statType ?? 'mixed',
          };
        }
        const engine = engineRef.current;
        if (engine?.gameState.hero) {
          const prevEquip = engine.gameState.hero.equipment ?? {};
          const combined = { ...prevEquip, ...merged };
          engine.gameState.hero.equipment = combined;
        }
        setGameState((prev) => {
          if (!prev.hero) return prev;
          const prevEquip = prev.hero.equipment ?? {};
          return { ...prev, hero: { ...prev.hero, equipment: { ...prevEquip, ...merged } } };
        });
      })
      .catch(() => console.warn('[App] Could not fetch equipment'));
  }, []);

  const refreshHeroQuests = useCallback(() => {
    const telegramId = getTelegramUserId() ?? 1;
    getHeroQuests(telegramId)
      .then((q) => setHeroQuests(q))
      .catch(() => console.warn('[App] Could not refresh hero quests'));
  }, []);

  // Initialize engine and WebSocket
  useEffect(() => {
    const container = canvasRef.current;
    if (!container) return;

    // ---- Game Engine ----
    const engine = new GameEngine();
    engineRef.current = engine;
    engine.setRestThoughtPicker(() =>
      randomRoadsideThoughtLine(i18nForLogRef.current.locale),
    );

    engine.onStateChange((state) => {
      setGameState(state);
    });

    engine.init(container).then(async () => {
      let shouldOpenWS = false;
      try {
        const telegramId = getTelegramUserId() ?? 1;
        const initRes = await initHero(telegramId);
        setNpcShopCosts(npcShopCostsFromInit(initRes));

        if (initRes.showChangelog && initRes.changelog) {
          const bundle: ChangelogOpen = {
            payload: initRes.changelog,
            serverVersion: initRes.serverVersion,
          };
          if (initRes.needsName) {
            pendingChangelogRef.current = bundle;
          } else {
            setChangelogOpen(bundle);
          }
        }

        // Gate game start behind name entry — no hero row until POST /hero/name
        if (initRes.needsName) {
          setNeedsName(true);
          if (initRes.hero) {
            const heroState = heroResponseToState(initRes.hero);
            engine.initFromServer(heroState, initRes.hero.state);
          }
          getTowns()
            .then(async (t) => {
              setTowns(t);
              townsRef.current = t;
              const townNPCMap = new Map<number, NPC[]>();
              const townBuildingMap = new Map<number, BuildingData[]>();
              try {
                const [npcResults, buildingResults] = await Promise.all([
                  Promise.allSettled(t.map((town) => getTownNPCs(town.id).then((npcs) => ({ townId: town.id, npcs })))),
                  Promise.allSettled(t.map((town) => getTownBuildings(town.id).then((b) => ({ townId: town.id, buildings: b })))),
                ]);
                for (const result of npcResults) {
                  if (result.status === 'fulfilled') townNPCMap.set(result.value.townId, result.value.npcs);
                }
                for (const result of buildingResults) {
                  if (result.status === 'fulfilled') townBuildingMap.set(result.value.townId, result.value.buildings);
                }
              } catch {
                /* ignore */
              }
              const loc = i18nForLogRef.current.locale;
              const townDataList = t.map((town) =>
                townToTownData(town, townNPCMap.get(town.id), townBuildingMap.get(town.id), loc),
              );
              engine.setTowns(townDataList);
              const allNPCs: NPCData[] = [];
              for (const td of townDataList) {
                if (td.npcs) allNPCs.push(...td.npcs);
              }
              engine.setNPCs(allNPCs);
            })
            .catch(() => console.warn('[App] Could not fetch towns (name gate)'));
          console.info('[App] Hero needs name, showing name entry screen');
          return;
        }

        if (!initRes.hero) {
          console.error('[App] init: missing hero without needsName');
          setConnectionError('Invalid server response.');
          return;
        }

        shouldOpenWS = true;
        const heroState = heroResponseToState(initRes.hero);
        engine.initFromServer(heroState, initRes.hero.state);
        engine.setHeroName(initRes.hero.name);
        console.info('[App] Loaded hero from server, id=', initRes.hero.id);

        if (initRes.offlineReport && offlineReportHasActivity(initRes.offlineReport)) {
          const r = initRes.offlineReport;
          console.info(
            `[Offline] ${r.message ?? ''} Killed ${r.monstersKilled} monsters, +${r.xpGained} XP, +${r.goldGained} gold, +${r.levelsGained} levels, deaths ${r.deaths ?? 0}, revives ${r.revives ?? 0}`,
          );
          setOfflineReport(r);
        }

        // Fetch towns, then their NPCs, and pass everything to the engine
        getTowns()
          .then(async (t) => {
            setTowns(t);
            townsRef.current = t;
            const townNPCMap = new Map<number, NPC[]>();
            const townBuildingMap = new Map<number, BuildingData[]>();
            try {
              const [npcResults, buildingResults] = await Promise.all([
                Promise.allSettled(t.map((town) => getTownNPCs(town.id).then((npcs) => ({ townId: town.id, npcs })))),
                Promise.allSettled(t.map((town) => getTownBuildings(town.id).then((b) => ({ townId: town.id, buildings: b })))),
              ]);
              for (const result of npcResults) {
                if (result.status === 'fulfilled') townNPCMap.set(result.value.townId, result.value.npcs);
              }
              for (const result of buildingResults) {
                if (result.status === 'fulfilled') townBuildingMap.set(result.value.townId, result.value.buildings);
              }
            } catch {
              console.warn('[App] Error fetching town NPCs/buildings');
            }
            const loc = i18nForLogRef.current.locale;
            const townDataList = t.map((town) =>
              townToTownData(town, townNPCMap.get(town.id), townBuildingMap.get(town.id), loc),
            );
            engine.setTowns(townDataList);
            const allNPCs: NPCData[] = [];
            for (const td of townDataList) {
              if (td.npcs) allNPCs.push(...td.npcs);
            }
            engine.setNPCs(allNPCs);
          })
          .catch(() => console.warn('[App] Could not fetch towns'));

        getHeroQuests(telegramId)
          .then((q) => setHeroQuests(q))
          .catch(() => console.warn('[App] Could not fetch hero quests'));
        refreshEquipment();

        getAchievements(telegramId)
          .then((a) => { prevAchievementsRef.current = a; setAchievements(a); })
          .catch(() => console.warn('[App] Could not fetch achievements'));

        // Fetch adventure log (same source as server DB; response shape { log: [...] })
        try {
          const serverLog = await getAdventureLog(telegramId, 50);
          const { locale: loc, tr: bundle } = i18nForLogRef.current;
          const { entries, rawRows, maxId } = adventureEntriesFromServerLog(serverLog, loc, bundle);
          logRawRef.current = rawRows;
          logIdCounter.current = Math.max(logIdCounter.current, maxId);
          setLogEntries(entries);
        } catch {
          console.warn('[App] Could not fetch adventure log');
        }
      } catch (err) {
        console.error('[App] Backend not available. Game requires a server connection:', err);
        setConnectionError('Cannot reach game server. Please try again later.');
        return;
      }
      engine.start();
      if (shouldOpenWS) {
        ws.connect();
      }
    }).catch((err) => {
      console.error('[App] Failed to initialize game engine:', err);
    });

    // ---- WebSocket ----
    const ws = new GameWebSocket();
    wsRef.current = ws;

    // Pass telegram ID so the WS connection identifies the correct hero.
    const wsTelegramId = getTelegramUserId() ?? 1;
    ws.setTelegramId(wsTelegramId);

    ws.onConnectionState((connState) => {
      const connected = connState === 'connected';
      setWsConnected(connected);
      if (connected) setWsEverConnected(true);
    });

    // Wire WS handler -- routes server messages to engine + UI callbacks
    wireWSHandler(ws, engine, {
      onCombatStart: () => {
        setCombatLogLines([]);
        clearCombatEvents();
      },
      onCombatAttack: handleCombatAttack,
      onEnemyRegen: handleEnemyRegen,

      onHeroStateReceived: (payload) => {
        // Convert raw payload to HeroResponse shape and apply
        const res = payload as unknown as HeroResponse;
        const heroState = heroResponseToState(res);
        engine.applyHeroState(heroState);
      },

      onCombatEnd: (p) => {
        const bundle = i18nForLogRef.current.tr;
        setCombatLogLines([]);
        clearCombatEvents();
        const loot = buildLootFromCombatEnd(p);
        engine.applyLoot(loot);
        hapticNotification('success');

        const parts: string[] = [];
        if (p.xpGained > 0) parts.push(t(bundle.toastGainedXp, { xp: p.xpGained }));
        if (p.goldGained > 0) parts.push(t(bundle.toastGainedGold, { gold: p.goldGained }));
        const equipDrop = (p.loot ?? []).find(
          (l) => l.itemType !== 'gold' && l.itemType !== 'potion',
        );
        if (equipDrop?.name) parts.push(t(bundle.toastFoundItem, { name: equipDrop.name }));
        // Victory line comes from server adventure log (Defeated …) + WS adventure_log_line

        if (p.leveledUp && p.newLevel) {
          setToast({ message: t(bundle.levelUp, { level: p.newLevel }), color: '#ffd700' });
          hapticNotification('success');
        }

        // Refresh quests, equipment, achievements after combat
        const tid = getTelegramUserId() ?? 1;
        getHeroQuests(tid).then((q) => setHeroQuests(q)).catch(() => {});
        refreshEquipment();
        getAchievements(tid).then((a) => {
          const prevIds = new Set(prevAchievementsRef.current.filter((x) => x.unlocked).map((x) => x.id));
          const newlyUnlocked = a.filter((x) => x.unlocked && !prevIds.has(x.id));
          for (const ach of newlyUnlocked) {
            setToast({ message: t(bundle.achievementUnlockedToast, { title: ach.title }), color: '#ffd700' });
            hapticNotification('success');
          }
          prevAchievementsRef.current = a;
          setAchievements(a);
        }).catch(() => {});
      },

      onHeroDied: (_p) => {
        hapticNotification('error');
        // Death line comes from server log + WS
      },

      onHeroRevived: () => {
        setCombatLogLines([]);
        clearCombatEvents();
        setToast({ message: i18nForLogRef.current.tr.heroRevived, color: '#44cc44' });
        // "Hero revived" comes from server log + WS
      },

      onBuffApplied: (_p) => {
        // Buff activation comes from server log + WS
      },

      onTownEnter: (p) => {
        const { locale: loc, tr: bundle } = i18nForLogRef.current;
        const town = townsRef.current.find((t) => t.id === p.townId) ?? null;
        setCurrentTown(town);
        const townDisp = townLabel(loc, p.townNameKey, p.townName);
        setToast({ message: t(bundle.entering, { townName: townDisp }), color: '#daa520' });
        appendLogClientMessage(formatClientLogLine(bundle, 'enteredTown', { town: townDisp }));
        setNearestNPC(null);
        setSelectedNPC(null);
        setNpcInteractionDismissed(null);
        setTownTourLastPayload(null);
      },

      onAdventureLogLine: (p) => {
        appendLogPayload(p);
      },

      onTownNPCVisit: () => {
        // Town NPC UI is driven by `town_tour_phase` (engine still gets lines via ws-handler).
      },

      onTownTourPhase: (p) => {
        setTownTourLastPayload(p);
        const ph = p.phase;
        if (ph === 'npc_welcome' || ph === 'npc_service') {
          setNpcInteractionDismissed(null);
        }
        if (ph === 'wander' || ph === 'rest' || ph === 'npc_approach') {
          setSelectedNPC(null);
        }
      },

      onTownTourServiceEnd: () => {
        setTownTourLastPayload(null);
        setSelectedNPC(null);
      },

      onTownExit: () => {
        setCurrentTown(null);
        setNearestNPC(null);
        setTownTourLastPayload(null);
      },

      onNPCEncounter: (p) => {
        const loc = i18nForLogRef.current.locale;
        const name = p.npcNameKey ? npcLabel(loc, p.npcNameKey, p.npcName) : p.npcName;
        const msg = p.dialogueKey
          ? dialogueText(loc, p.dialogueKey, p.dialogue ?? `${name} approaches!`)
          : (p.dialogue ?? `${name} approaches!`);
        const npcEvent: NPCEncounterEvent = {
          type: 'npc_event',
          npcName: name,
          npcNameKey: p.npcNameKey,
          dialogueKey: p.dialogueKey,
          message: msg,
          cost: p.cost,
        };
        setWanderingNPC(npcEvent);
      },

      onNPCEncounterEnd: (p) => {
        if (p.reason === 'timeout') {
          appendLogClientMessage(formatClientLogLine(i18nForLogRef.current.tr, 'merchantMovedOn'));
        }
        setWanderingNPC(null);
      },

      onLevelUp: (p) => {
        setToast({ message: t(i18nForLogRef.current.tr.levelUp, { level: p.newLevel }), color: '#ffd700' });
        hapticNotification('success');
        // Level-up lines come from server log + WS
      },

      onEquipmentChange: (p) => {
        const bundle = i18nForLogRef.current.tr;
        setToast({ message: t(bundle.newEquipment, { slot: p.slot, itemName: p.item.name }), color: '#cc88ff' });
        // Equipment line comes from server log + WS
        refreshEquipment();
      },

      onPotionCollected: (p) => {
        const bundle = i18nForLogRef.current.tr;
        setToast({ message: t(bundle.potionsCollected, { count: p.count }), color: '#44cc44' });
      },

      onQuestProgress: (p) => {
        const bundle = i18nForLogRef.current.tr;
        setHeroQuests((prev) =>
          prev.map((hq) =>
            hq.questId === p.questId
              ? { ...hq, progress: p.current }
              : hq,
          ),
        );
        if (p.title) {
          setToast({
            message: t(bundle.questProgress, { title: p.title, current: p.current, target: p.target }),
            color: '#44aaff',
          });
        }
      },

      onQuestComplete: (p) => {
        const bundle = i18nForLogRef.current.tr;
        setHeroQuests((prev) =>
          prev.map((hq) =>
            hq.questId === p.questId
              ? { ...hq, status: 'completed' as const }
              : hq,
          ),
        );
        setToast({ message: t(bundle.questCompleted, { title: p.title }), color: '#ffd700' });
        hapticNotification('success');
      },

      onError: (p) => {
        setToast({ message: p.message, color: '#ff4444' });
      },

      onMerchantLoot: (p) => {
        const loot = buildMerchantLootDrop(p);
        const eng = engineRef.current;
        if (loot && eng) {
          eng.applyLoot(loot);
        }
        hapticNotification('success');
        refreshEquipment();
      },

      onHeroMeetStart: (p) => {
        const partner: NearbyHeroData = {
            id: p.partner.id,
            name: p.partner.name,
            level: p.partner.level,
            modelVariant: normalizeHeroModelVariant(p.partner.modelVariant),
            positionX: p.partner.positionX,
            positionY: p.partner.positionY,
          };
        engine.setHeroMeetOverlay(partner);
        const phase = p.meetPhase;
        const showChat = phase === 'meet' || phase === undefined;
        if (showChat) {
          setHeroMeetPanel({
            partnerName: p.partner.name,
            anySideOnline: p.anySideOnline,
            maxChars: 140,
          });
        } else {
          setHeroMeetPanel(null);
        }
      },

      onHeroMeetLine: (p) => {
        const loc = i18nForLogRef.current.locale;
        let text = (p.text ?? '').trim();
        if (p.kind === 'scripted' && p.lineKey) {
          text = (adventureLogTemplate(loc, p.lineKey) ?? p.lineKey).trim();
        }
        if (!text) return;
        engine.applyHeroMeetChatLine(p.fromHeroId, p.kind, text);
      },

      onHeroMeetEnd: (p) => {
        setHeroMeetPanel(null);
        const linger = p.partnerLingerMs != null && p.partnerLingerMs > 0 ? p.partnerLingerMs : 20000;
        engine.endHeroMeetOverlayLinger(linger);
      },
    });

    // ---- Telegram Theme Listener ----
    const unsubTheme = onThemeChanged();

    // ---- Cleanup ----
    return () => {
      engine.destroy();
      ws.disconnect();
      unsubTheme();
      if (nearbyIntervalRef.current) {
        clearInterval(nearbyIntervalRef.current);
        nearbyIntervalRef.current = null;
      }
      engineRef.current = null;
      wsRef.current = null;
    };
  }, []);

  // WS-driven nearby heroes: request list + subscribe to updates.
  useEffect(() => {
    const ws = wsRef.current;
    if (!wsConnected || !ws) {
      if (nearbyIntervalRef.current) {
        clearInterval(nearbyIntervalRef.current);
        nearbyIntervalRef.current = null;
      }
      return;
    }
    const request = (): void => {
      sendRequestNearbyHeroes(ws);
    };
    request();
    const interval = setInterval(request, 5000);
    nearbyIntervalRef.current = interval;
    return () => {
      clearInterval(interval);
      if (nearbyIntervalRef.current === interval) {
        nearbyIntervalRef.current = null;
      }
    };
  }, [wsConnected]);

  // Restore per-hero buff button cooldowns
  useEffect(() => {
    const id = gameState.hero?.id;
    if (id == null) return;
    const loaded = loadCooldownsFromStorage(id);
    if (Object.keys(loaded).length === 0) return;
    setBuffCooldownEndsAt((prev) => ({ ...loaded, ...prev }));
  }, [gameState.hero?.id]);

  // ---- Handlers ----

  const handleBuffActivate = useCallback((type: BuffType) => {
    const engine = engineRef.current;
    const ws = wsRef.current;
    const hero = engine?.gameState.hero;
    const now = Date.now();

    if (engine?.gameState.phase === GamePhase.Dead) {
      return;
    }

    // Check per-buff charge quota
    if (hero) {
      const charge = hero.buffCharges?.[type];
      if (charge != null && charge.remaining <= 0) {
        const label = type.charAt(0).toUpperCase() + type.slice(1).replace('_', ' ');
        setToast({ message: t(tr.noChargesLeft, { label }), color: '#ff8844' });
        return;
      }
    }

    // Optimistic update
    if (engine && hero) {
      const durationMs = BUFF_DURATION_MS[type];
      const optimisticBuff: ActiveBuff = {
        type,
        remainingMs: durationMs,
        durationMs,
        cooldownMs: BUFF_COOLDOWN_MS[type],
        cooldownRemainingMs: 0,
        expiresAtMs: now + durationMs,
      };

      const alreadyActive = hero.activeBuffs.some(
        (b) => b.type === type && (b.expiresAtMs ?? 0) > now,
      );
      const updatedBuffs = alreadyActive
        ? hero.activeBuffs.map((b) => (b.type === type ? optimisticBuff : b))
        : [...hero.activeBuffs, optimisticBuff];

      let { damage, defense, attackSpeed, hp, maxHp } = hero;

      if (!alreadyActive) {
        switch (type) {
          case BuffType.Rage:
            damage = Math.round((damage * 5) / 3);
            break;
          case BuffType.PowerPotion:
            damage = Math.round(damage * 2);
            break;
          case BuffType.WarCry:
            attackSpeed = Math.round(((attackSpeed * 5) / 3) * 100) / 100;
            break;
          case BuffType.Heal:
            hp = Math.min(maxHp, hp + Math.round(maxHp / 3));
            break;
        }
      }

      engine.patchHeroCombat({
        damage,
        defense,
        attackSpeed,
        activeBuffs: updatedBuffs,
      });

      if (type === BuffType.Heal && hp !== hero.hp) {
        engine.patchHeroHp(hp);
      }

      setBuffCooldownEndsAt((prev) => {
        const next = { ...prev, [type]: now + BUFF_COOLDOWN_MS[type] };
        if (hero.id != null) saveCooldownsToStorage(hero.id, next);
        return next;
      });

      // Optimistic decrement of per-buff charge (server always consumes via ConsumeBuffCharge)
      const currentCharge = hero.buffCharges?.[type];
      if (currentCharge != null && currentCharge.remaining > 0) {
        const updatedCharges: Partial<Record<BuffType, BuffChargeState>> = {
          ...hero.buffCharges,
          [type]: { ...currentCharge, remaining: currentCharge.remaining - 1 },
        };
        engine.patchHeroBuffQuota({
          buffFreeChargesRemaining: hero.buffFreeChargesRemaining,
          buffQuotaPeriodEnd: hero.buffQuotaPeriodEnd,
          buffCharges: updatedCharges,
        });
      }

      hapticImpact('medium');
      // Server logs buff activation + sends adventure_log_line
    }

    // Send command to server via WebSocket
    if (ws) {
      sendActivateBuff(ws, type);
    }
  }, []);

  const handleRevive = useCallback(() => {
    const ws = wsRef.current;
    if (ws && ws.getState() === 'connected') {
      sendRevive(ws);
    } else {
      // Fallback to REST if WS not connected
      const telegramId = getTelegramUserId() ?? 1;
      requestRevive(telegramId)
        .then((hero) => {
          const engine = engineRef.current;
          if (engine) {
            const state = heroResponseToState(hero);
            engine.applyHeroRevived(state.hp);
            engine.applyHeroState(state);
          }
        })
        .catch((err) => {
          console.warn('[App] Revive failed:', err);
        });
    }
  }, []);

  const handleQuestClaim = useCallback(
    (heroQuestId: number) => {
      const telegramId = getTelegramUserId() ?? 1;
      claimQuest(heroQuestId, telegramId)
        .then((hero) => {
          const merged = heroResponseToState(hero);
          const engine = engineRef.current;
          if (engine) {
            const pos = engine.gameState.hero?.position;
            if (pos) merged.position = pos;
            engine.applyHeroState(merged);
          }
          setToast({ message: tr.questRewardsClaimed, color: '#ffd700' });
          hapticNotification('success');
          refreshHeroQuests();
        })
        .catch((err) => {
          console.warn('[App] Failed to claim quest:', err);
          setToast({ message: tr.failedToClaimRewards, color: '#ff4444' });
        });
    },
    [refreshHeroQuests],
  );

  const handleQuestAbandon = useCallback(
    (heroQuestId: number) => {
      const telegramId = getTelegramUserId() ?? 1;
      abandonQuest(heroQuestId, telegramId)
        .then(() => {
          setToast({ message: tr.questAbandoned, color: '#ff8844' });
          refreshHeroQuests();
        })
        .catch((err) => {
          console.warn('[App] Failed to abandon quest:', err);
          setToast({ message: tr.failedToAbandonQuest, color: '#ff4444' });
        });
    },
    [refreshHeroQuests],
  );

  const handleNPCHeroUpdated = useCallback((hero: HeroResponse) => {
    const merged = heroResponseToState(hero);
    const engine = engineRef.current;
    if (engine) {
      const pos = engine.gameState.hero?.position;
      if (pos) merged.position = pos;
      engine.applyHeroState(merged);
    }
  }, []);

  const handleNameSet = useCallback((hero: HeroResponse) => {
    setNeedsName(false);
    const engine = engineRef.current;
    if (engine) {
      const heroState = heroResponseToState(hero);
      engine.initFromServer(heroState, hero.state);
      engine.setHeroName(hero.name);
      engine.start();
      wsRef.current?.connect();

      const telegramId = getTelegramUserId() ?? 1;
      getTowns()
        .then(async (t) => {
          setTowns(t);
          const townNPCMap = new Map<number, NPC[]>();
          const townBuildingMap = new Map<number, BuildingData[]>();
          try {
            const [npcResults, buildingResults] = await Promise.all([
              Promise.allSettled(t.map((town) => getTownNPCs(town.id).then((npcs) => ({ townId: town.id, npcs })))),
              Promise.allSettled(t.map((town) => getTownBuildings(town.id).then((b) => ({ townId: town.id, buildings: b })))),
            ]);
            for (const result of npcResults) {
              if (result.status === 'fulfilled') townNPCMap.set(result.value.townId, result.value.npcs);
            }
            for (const result of buildingResults) {
              if (result.status === 'fulfilled') townBuildingMap.set(result.value.townId, result.value.buildings);
            }
          } catch { /* ignore */ }
          const loc = i18nForLogRef.current.locale;
          const townDataList = t.map((town) =>
            townToTownData(town, townNPCMap.get(town.id), townBuildingMap.get(town.id), loc),
          );
          engine.setTowns(townDataList);
          const allNPCs: NPCData[] = [];
          for (const td of townDataList) {
            if (td.npcs) allNPCs.push(...td.npcs);
          }
          engine.setNPCs(allNPCs);
        })
        .catch(() => console.warn('[App] Could not fetch towns'));
      getHeroQuests(telegramId)
        .then((q) => setHeroQuests(q))
        .catch(() => console.warn('[App] Could not fetch hero quests'));
      getAdventureLog(telegramId, 50)
        .then((serverLog) => {
          const { locale: loc, tr: bundle } = i18nForLogRef.current;
          const { entries, rawRows, maxId } = adventureEntriesFromServerLog(serverLog, loc, bundle);
          logRawRef.current = rawRows;
          logIdCounter.current = Math.max(logIdCounter.current, maxId);
          setLogEntries(entries);
        })
        .catch(() => console.warn('[App] Could not fetch adventure log'));
      getAchievements(telegramId)
        .then((a) => { prevAchievementsRef.current = a; setAchievements(a); })
        .catch(() => {});

      if (pendingChangelogRef.current) {
        setChangelogOpen(pendingChangelogRef.current);
        pendingChangelogRef.current = null;
      }
    }
  }, []);

  const handleDismissChangelog = useCallback(() => {
    setChangelogOpen(null);
    const telegramId = getTelegramUserId() ?? 1;
    ackChangelog(telegramId).catch(() => console.warn('[App] changelog ack failed'));
  }, []);

  const handleUsePotion = useCallback(() => {
    const ws = wsRef.current;
    const hero = engineRef.current?.gameState.hero;
    if (!hero || (hero.potions ?? 0) <= 0) return;

    // Send via WS
    if (ws) {
      sendUsePotion(ws);
    }
    hero.potions--

    hapticImpact('medium');
    // Server logs potion use + sends adventure_log_line
  }, []);

  // ---- NPC Interaction Handlers ----

  const handleNPCViewQuests = useCallback((npc: NPCData) => {
    const matchedNPC: NPC = {
      id: npc.id,
      townId: npc.townId,
      name: npc.name,
      nameKey: npc.nameKey,
      type: npc.type,
      offsetX: 0,
      offsetY: 0,
      townLevelMin: npc.townLevelMin,
      townLevelMax: npc.townLevelMax,
    };
    setSelectedNPC(matchedNPC);
    setNpcInteractionDismissed(npc.id);
  }, []);

  const handleTownTourInteractionDismiss = useCallback(() => {
    const w = wsRef.current;
    if (!w) return;
    const ph = townTourLastPayload?.phase;
    if (ph === 'npc_welcome') {
      sendTownTourNPCDialogClosed(w);
    } else if (ph === 'npc_service') {
      sendTownTourNPCInteractionClosed(w);
    }
  }, [townTourLastPayload?.phase]);

  // ---- Wandering NPC Encounter Handlers (via WS) ----

  const handleWanderingAccept = useCallback(() => {
    const ws = wsRef.current;
    if (ws) {
      sendNPCAlmsAccept(ws);
    }
    setWanderingNPC(null);
    // Alms outcome is logged on server + WS when trade completes
  }, []);

  const handleWanderingDecline = useCallback(() => {
    const ws = wsRef.current;
    if (ws) {
      sendNPCAlmsDecline(ws);
    }
    setWanderingNPC(null);
    appendLogClientMessage(formatClientLogLine(i18nForLogRef.current.tr, 'declinedWanderingMerchant'));
  }, [appendLogClientMessage]);

  const heroOnTownTour = gameState.hero?.excursionKind === 'town';

  const townTourChipActive =
    (townTourLastPayload?.phase === 'npc_welcome' || townTourLastPayload?.phase === 'npc_service') &&
    (townTourLastPayload.npcId ?? 0) > 0;

  const townTourInteractionNPC =
    townTourChipActive && townTourLastPayload
      ? townTourPayloadToNPCData(townTourLastPayload, locale, currentTown)
      : null;

  const legacyProximityNPC = !heroOnTownTour ? nearestNPC : null;

  const interactionNpc = townTourInteractionNPC ?? legacyProximityNPC;

  const showNPCInteraction =
    interactionNpc != null &&
    npcInteractionDismissed !== interactionNpc.id &&
    (gameState.phase === GamePhase.Walking || gameState.phase === GamePhase.InTown) &&
    !selectedNPC;

  const dialogNpc = selectedNPC;

  const handleNPCInteractionDismiss = useCallback(() => {
    if (interactionNpc) setNpcInteractionDismissed(interactionNpc.id);
  }, [interactionNpc]);

  const completedQuestCount = useMemo(
    () =>
      heroQuests.filter((q) => q.status === 'completed').length,
    [heroQuests],
  );

  const dismissToast = useCallback(() => setToast(null), []);

  const questClaimDisabled =
    gameState.phase === GamePhase.Dead || (gameState.hero?.hp ?? 0) <= 0;

  return (
    <I18nContext.Provider value={{ tr: translations, locale, setLocale: handleSetLocale }}>
    <div style={appStyle}>
      {/* PixiJS Canvas */}
      <div ref={canvasRef} style={canvasContainerStyle} />

      {/* React UI Overlay */}
      <HUD
        gameState={gameState}
        onBuffActivate={handleBuffActivate}
        buffCooldownEndsAt={buffCooldownEndsAt}
        onUsePotion={handleUsePotion}
        onHeroUpdated={handleNPCHeroUpdated}
        completedQuestCount={completedQuestCount}
        onOpenHeroSheet={() => {
          setHeroSheetInitialTab('stats');
          setHeroSheetOpen(true);
        }}
      />

      {heroMeetPanel && wsRef.current ? (
        <HeroMeetPanel
          partnerName={heroMeetPanel.partnerName}
          anySideOnline={heroMeetPanel.anySideOnline}
          ws={wsRef.current}
          maxChars={heroMeetPanel.maxChars}
        />
      ) : null}

      {gameState.hero && (
        <HeroSheetModal
          open={heroSheetOpen}
          onClose={() => setHeroSheetOpen(false)}
          initialTab={heroSheetInitialTab}
          hero={gameState.hero}
          nowMs={sheetNowMs}
          equipment={gameState.hero.equipment ?? {}}
          logEntries={logEntries}
          quests={heroQuests}
          onQuestClaim={handleQuestClaim}
          onQuestAbandon={handleQuestAbandon}
          questClaimDisabled={questClaimDisabled}
        />
      )}

      {/* Combat overlay */}
      <CombatOverlay events={combatEvents} onExpire={expireCombatEvent} />

      <CombatLogPanel
        visible={
          gameState.phase === GamePhase.Fighting || gameState.phase === GamePhase.Dead
        }
        lines={combatLogLines}
        anchor={gameState.enemyOnScreenRight !== false ? 'left' : 'right'}
      />

      {/* Name Entry Screen */}
      {needsName && <NameEntryScreen onNameSet={handleNameSet} />}

      {changelogOpen && (
        <ChangelogModal
          title={changelogOpen.payload.title}
          items={changelogOpen.payload.items}
          serverVersion={changelogOpen.serverVersion}
          onDismiss={handleDismissChangelog}
        />
      )}

      {/* Death Screen */}
      <DeathScreen
        visible={gameState.phase === GamePhase.Dead}
        onRevive={handleRevive}
        subscriptionUnlimited={!!gameState.hero?.subscriptionActive}
        revivesRemaining={Math.max(0, 2 - (gameState.hero?.reviveCount ?? 0))}
      />

      {/* Toast Notification */}
      {toast && (
        <GameToast
          message={toast.message}
          color={toast.color}
          onDone={dismissToast}
        />
      )}

      {/* Town Name Indicator */}
      {currentTown && (
        <div
          style={{
            position: 'absolute',
            top: 48,
            left: '50%',
            transform: 'translateX(-50%)',
            padding: '3px 14px',
            borderRadius: 6,
            backgroundColor: 'rgba(218, 165, 32, 0.15)',
            border: '1px solid rgba(218, 165, 32, 0.3)',
            color: '#daa520',
            fontSize: 11,
            fontWeight: 600,
            zIndex: 30,
            pointerEvents: 'none',
            textShadow: '0 0 6px rgba(218, 165, 32, 0.3)',
          }}
        >
          {currentTown.name}
        </div>
      )}

      {/* Town tour service chip or legacy proximity NPC */}
      {showNPCInteraction && interactionNpc && (
        <NPCInteraction
          npc={interactionNpc}
          onViewQuests={handleNPCViewQuests}
          onOpenServiceDialog={handleNPCViewQuests}
          onDismiss={handleNPCInteractionDismiss}
          onDismissTownTour={townTourInteractionNPC ? handleTownTourInteractionDismiss : undefined}
        />
      )}

      {/* NPC Dialog: opened from interaction / sheet only (not auto on town tour approach). */}
      {dialogNpc && (
        <NPCDialog
          npc={dialogNpc}
          heroQuests={heroQuests}
          heroGold={gameState.hero?.gold ?? 0}
          potionCost={npcShopCosts.potionCost}
          healCost={npcShopCosts.healCost}
          getHeroWorldPosition={() => engineRef.current?.getHeroDisplayWorldPosition() ?? { x: 0, y: 0 }}
          onClose={() => {
            setNpcInteractionDismissed(dialogNpc.id);
            setSelectedNPC(null);
          }}
          onQuestsChanged={refreshHeroQuests}
          onHeroUpdated={handleNPCHeroUpdated}
          onToast={(message, color) => setToast({ message, color })}
          questClaimDisabled={questClaimDisabled}
          townTourWs={heroOnTownTour && selectedNPC ? townTourDialogWs : undefined}
        />
      )}

      {/* Wandering NPC Encounter Popup */}
      {wanderingNPC && (
        <WanderingNPCPopup
          npcName={wanderingNPC.npcName}
          message={wanderingNPC.message}
          cost={wanderingNPC.cost}
          heroGold={gameState.hero?.gold ?? 0}
          onAccept={handleWanderingAccept}
          onDecline={handleWanderingDecline}
        />
      )}

      {/* Minimap (top-right) */}
      {gameState.hero && (
        <Minimap
          heroX={gameState.hero.position.x}
          heroY={gameState.hero.position.y}
          towns={towns}
          routeWaypoints={gameState.routeWaypoints}
        />
      )}

      {/* Achievements Panel */}
      {gameState.hero && <AchievementsPanel achievements={achievements} />}

      {/* Offline Report Overlay */}
      {offlineReport && (
        <OfflineReport
          monstersKilled={offlineReport.monstersKilled}
          xpGained={offlineReport.xpGained}
          goldGained={offlineReport.goldGained}
          levelsGained={offlineReport.levelsGained}
          deaths={offlineReport.deaths}
          revives={offlineReport.revives}
          loot={offlineReport.loot}
          message={offlineReport.message}
          onDismiss={() => setOfflineReport(null)}
        />
      )}

      {/* Connection Status Banner */}
      {wsEverConnected && !wsConnected && (
        <div style={connectionBanner}>
          Reconnecting...
        </div>
      )}

      {/* Fatal connection error */}
      {connectionError && (
        <div style={{
          position: 'absolute', inset: 0,
          display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
          backgroundColor: 'rgba(0,0,0,0.85)', color: '#fff', zIndex: 1000,
          fontFamily: 'system-ui, sans-serif', textAlign: 'center', padding: 24,
        }}>
          <div style={{ fontSize: 48, marginBottom: 16 }}>&#x26A0;</div>
          <div style={{ fontSize: 18, fontWeight: 700, marginBottom: 8 }}>Connection Failed</div>
          <div style={{ fontSize: 14, opacity: 0.8, marginBottom: 24 }}>{connectionError}</div>
          <button
            onClick={() => window.location.reload()}
            style={{
              padding: '10px 28px', fontSize: 14, fontWeight: 600,
              border: 'none', borderRadius: 8, cursor: 'pointer',
              backgroundColor: '#4a90d9', color: '#fff',
            }}
          >
            Retry
          </button>
        </div>
      )}
    </div>
    </I18nContext.Provider>
  );
}
