import {
  AppstoreOutlined,
  CloseOutlined,
  EnvironmentOutlined,
  GoldOutlined,
  LeftOutlined,
  MenuOutlined,
  RightOutlined,
  ShopOutlined,
  ThunderboltOutlined,
  UserOutlined,
} from '@ant-design/icons';
import { Switch } from 'antd';
import { useEffect, useEffectEvent, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { useShallow } from 'zustand/react/shallow';
import './TheQuestAppView.css';
import { createTheQuestGame } from './game/createTheQuestGame';
import { useTheQuestStore, type ConsumableEffectsState, type ManualSkillCastCommand, type QuestProgressState } from './game/theQuestStore';
import { THE_QUEST_ITEMS, THE_QUEST_NPCS, THE_QUEST_STAGE_PORTAL } from './game/theQuestData';
import type { EquipmentSlot, InventoryItem, NpcDefinition, SkillDefinition } from './game/theQuestData';

type TheQuestAppViewProps = {
  onBack: () => void;
  launchContext?: 'direct' | 'embedded';
};

type GestureSkillDirection =
  | 'up'
  | 'up-right'
  | 'right'
  | 'down-right'
  | 'down'
  | 'down-left'
  | 'left'
  | 'up-left';
type QuickConsumableId = 'hp-potion' | 'auto-heal-elixir' | 'celerity-potion';
type InventoryCategoryFilter = 'all' | InventoryItem['category'];

const QUICK_ACTION_META = {
  character: { label: '정보', title: '캐릭터 상세', icon: <UserOutlined /> },
  inventory: { label: '가방', title: '가방/장비', icon: <AppstoreOutlined /> },
  quests: { label: '퀘', title: '퀘스트', icon: <EnvironmentOutlined /> },
  npcs: { label: '상점', title: 'NPC/상점', icon: <ShopOutlined /> },
} as const;
const NPC_ROLE_META: Record<NpcDefinition['role'], { label: string; badge: string; className: string }> = {
  quest: { label: '퀘스트 담당', badge: 'QUEST', className: 'the-quest__role-badge--quest' },
  shop: { label: '상점 담당', badge: 'SHOP', className: 'the-quest__role-badge--shop' },
  guide: { label: '가이드 담당', badge: 'GUIDE', className: 'the-quest__role-badge--guide' },
};

const EQUIPMENT_SLOT_ORDER: EquipmentSlot[] = [
  'weapon',
  'helmet',
  'shoulders',
  'chest',
  'belt',
  'gloves',
  'boots',
  'ring',
  'necklace',
  'bracelet',
  'artifact',
];

const EQUIPMENT_SLOT_META: Record<EquipmentSlot, { label: string; hint: string }> = {
  weapon: { label: '무기', hint: '검, 지팡이, 활 장착' },
  helmet: { label: '투구', hint: '후드, 투구, 바이저' },
  shoulders: { label: '견갑', hint: '어깨 보호구, 숄더 가드' },
  chest: { label: '갑옷', hint: '메일, 경갑, 상의' },
  belt: { label: '벨트', hint: '허리 장비, 전투 벨트' },
  gloves: { label: '장갑', hint: '건틀릿, 랩스, 손보호구' },
  boots: { label: '신발', hint: '부츠, 경화, 이동 장비' },
  ring: { label: '반지', hint: '공격/치명 보조 반지' },
  necklace: { label: '목걸이', hint: '부적, 펜던트, 목장식' },
  bracelet: { label: '팔찌', hint: '주문/민첩 보조 팔찌' },
  artifact: { label: '유물', hint: '핵심 버프형 특수 장비' },
};

const ITEM_RARITY_META: Record<InventoryItem['rarity'], { label: string; className: string }> = {
  common: { label: '일반', className: 'the-quest__item-card--common' },
  rare: { label: '희귀', className: 'the-quest__item-card--rare' },
  epic: { label: '영웅', className: 'the-quest__item-card--epic' },
};
const INVENTORY_CATEGORY_FILTERS: Array<{ key: InventoryCategoryFilter; label: string }> = [
  { key: 'all', label: '전체' },
  { key: 'weapon', label: '무기' },
  { key: 'armor', label: '방어구' },
  { key: 'accessory', label: '장신구' },
  { key: 'consumable', label: '소모품' },
  { key: 'material', label: '재료' },
];

const PRIMARY_ACTION_SKILL: ManualSkillCastCommand['skillId'] = 'energy-bolt';
const GESTURE_DIRECTION_ORDER: GestureSkillDirection[] = [
  'up',
  'up-right',
  'right',
  'down-right',
  'down',
  'down-left',
  'left',
  'up-left',
];
const GESTURE_DIRECTION_META: Record<GestureSkillDirection, { icon: string; label: string }> = {
  up: { icon: '↑', label: '상단' },
  'up-right': { icon: '↗', label: '우상단' },
  right: { icon: '→', label: '우측' },
  'down-right': { icon: '↘', label: '우하단' },
  down: { icon: '↓', label: '하단' },
  'down-left': { icon: '↙', label: '좌하단' },
  left: { icon: '←', label: '좌측' },
  'up-left': { icon: '↖', label: '좌상단' },
};
const QUICK_CONSUMABLE_ORDER: QuickConsumableId[] = ['hp-potion', 'auto-heal-elixir', 'celerity-potion'];
const QUICK_CONSUMABLE_SLOTS = 4;
const GESTURE_SKILL_PAGE_SIZE = GESTURE_DIRECTION_ORDER.length;
const MOVEMENT_KEY_ALIASES = {
  ArrowUp: 'up',
  KeyW: 'up',
  ArrowDown: 'down',
  KeyS: 'down',
  ArrowLeft: 'left',
  KeyA: 'left',
  ArrowRight: 'right',
  KeyD: 'right',
} as const;
const SKILL_HOTKEY_ORDER = ['Digit1', 'Digit2', 'Digit3', 'Digit4', 'Digit5', 'Digit6', 'Digit7', 'Digit8'] as const;
const PANEL_HOTKEYS = {
  KeyC: 'character',
  KeyI: 'inventory',
  KeyQ: 'quests',
  KeyN: 'npcs',
  Comma: 'settings',
} as const satisfies Record<string, 'character' | 'inventory' | 'quests' | 'npcs' | 'settings'>;

function clamp(value: number, min: number, max: number) {
  return Math.min(Math.max(value, min), max);
}

function findTouchByIdentifier(touches: TouchList, identifier: number) {
  for (let index = 0; index < touches.length; index += 1) {
    const touch = touches.item(index);
    if (touch && touch.identifier === identifier) {
      return touch;
    }
  }

  return null;
}

function formatRatio(value: number, max: number) {
  return `${Math.max(0, Math.round(value))}/${Math.max(1, Math.round(max))}`;
}

function buildGaugePercent(value: number, max: number) {
  if (max <= 0) {
    return 0;
  }
  return clamp((value / max) * 100, 0, 100);
}

function formatEquipmentSlot(slot: EquipmentSlot) {
  return EQUIPMENT_SLOT_META[slot].label;
}

function formatCooldownBadge(remainingMs: number) {
  return `${(remainingMs / 1000).toFixed(1)}s`;
}

function formatSecondsLabel(remainingMs: number) {
  return `${Math.max(1, Math.ceil(remainingMs / 1000))}초`;
}

function formatPercent(value: number) {
  return `${Math.round(value * 100)}%`;
}

type ActiveBuffEntry = {
  id: string;
  label: string;
  detail: string;
  remainingMs: number;
};

function resolveSkillEffectState(skillId: string, consumableEffects: ConsumableEffectsState, now: number) {
  switch (skillId) {
    case 'greater-haste': {
      const remainingMs = Math.max(0, consumableEffects.arcaneSurgeUntil - now);
      return {
        remainingMs,
        label: remainingMs > 0 ? `가속 x${consumableEffects.arcaneSurgeMultiplier}` : null,
      };
    }
    case 'magic-shield': {
      const remainingMs = Math.max(0, consumableEffects.manaWardUntil - now);
      return {
        remainingMs,
        label: remainingMs > 0 ? `피감 ${Math.round(consumableEffects.manaWardReduction * 100)}%` : null,
      };
    }
    case 'summon-skeleton': {
      const remainingMs = Math.max(0, consumableEffects.familiarUntil - now);
      return {
        remainingMs,
        label: remainingMs > 0 ? '언데드 소환 유지' : null,
      };
    }
    default:
      return {
        remainingMs: 0,
        label: null,
      };
  }
}

function buildActiveBuffEntries(consumableEffects: ConsumableEffectsState, now: number) {
  const entries: ActiveBuffEntry[] = [];

  if (consumableEffects.arcaneSurgeUntil > now) {
    entries.push({
      id: 'greater-haste',
      label: '그레이터 헤이스트',
      detail: `가속 x${consumableEffects.arcaneSurgeMultiplier}`,
      remainingMs: consumableEffects.arcaneSurgeUntil - now,
    });
  }

  if (consumableEffects.manaWardUntil > now) {
    entries.push({
      id: 'magic-shield',
      label: '매직 실드',
      detail: `피감 ${Math.round(consumableEffects.manaWardReduction * 100)}%`,
      remainingMs: consumableEffects.manaWardUntil - now,
    });
  }

  if (consumableEffects.familiarUntil > now) {
    entries.push({
      id: 'summon-skeleton',
      label: '서먼 스켈레톤',
      detail: '언데드 소환 유지',
      remainingMs: consumableEffects.familiarUntil - now,
    });
  }

  if (consumableEffects.autoHealBoostUntil > now) {
    entries.push({
      id: 'auto-heal',
      label: '오토 힐',
      detail: `AUTO x${consumableEffects.autoHealMultiplier}`,
      remainingMs: consumableEffects.autoHealBoostUntil - now,
    });
  }

  if (consumableEffects.moveSpeedBoostUntil > now) {
    entries.push({
      id: 'celerity-potion',
      label: '속도 물약',
      detail: `SPD x${consumableEffects.moveSpeedMultiplier}`,
      remainingMs: consumableEffects.moveSpeedBoostUntil - now,
    });
  }

  consumableEffects.queuedRecovery.forEach((effect, index) => {
    const lastTickAt = effect.nextTickAt + Math.max(0, effect.ticksRemaining - 1) * effect.tickIntervalMs;
    entries.push({
      id: `${effect.itemId}-${index}`,
      label: effect.label,
      detail: `${effect.healPerTick} HP x${effect.ticksRemaining}`,
      remainingMs: Math.max(0, lastTickAt - now),
    });
  });

  return entries.sort((left, right) => left.remainingMs - right.remainingMs);
}

function getPagedGestureSkills(
  skills: ReturnType<typeof useTheQuestSnapshot>['skills'],
  page: number,
) {
  const gestureSkills = skills.filter((skill) => skill.id !== PRIMARY_ACTION_SKILL);
  const startIndex = page * GESTURE_SKILL_PAGE_SIZE;
  return gestureSkills.slice(startIndex, startIndex + GESTURE_SKILL_PAGE_SIZE);
}

function buildItemCategoryLabel(category: InventoryItem['category']) {
  switch (category) {
    case 'weapon':
      return '무기';
    case 'armor':
      return '방어구';
    case 'accessory':
      return '장신구';
    case 'consumable':
      return '소모품';
    case 'material':
      return '재료';
    default:
      return '아이템';
  }
}

function buildItemStats(item: InventoryItem) {
  return [
    item.attack ? `공격 +${item.attack}` : null,
    item.defense ? `방어 +${item.defense}` : null,
    item.critRate ? `치명 +${Math.round(item.critRate * 100)}%` : null,
    item.spellPower ? `주문력 +${item.spellPower}` : null,
    item.maxMpBonus ? `최대 MP +${item.maxMpBonus}` : null,
    item.manaAbsorbChance ? `마나 흡수 ${Math.round(item.manaAbsorbChance * 100)}%` : null,
    item.heal ? `HP ${item.heal} 회복` : null,
    item.mpRestore ? `MP ${item.mpRestore} 회복` : null,
    item.healOverTimeTotal ? `HP ${item.healOverTimeTotal} 순차 회복` : null,
    item.autoHealMultiplier ? `자동회복 ${item.autoHealMultiplier}배` : null,
    item.moveSpeedMultiplier ? `이동속도 ${item.moveSpeedMultiplier}배` : null,
    item.effectDurationMs ? `${Math.round(item.effectDurationMs / 1000)}초 지속` : null,
    item.quantity ? `보유 ${item.quantity}` : null,
  ].filter(Boolean) as string[];
}

function buildItemComparison(item: InventoryItem, equippedItem: InventoryItem | null) {
  const statRows = [
    {
      key: 'attack',
      label: '공격',
      nextValue: item.attack ?? 0,
      currentValue: equippedItem?.attack ?? 0,
    },
    {
      key: 'defense',
      label: '방어',
      nextValue: item.defense ?? 0,
      currentValue: equippedItem?.defense ?? 0,
    },
    {
      key: 'critRate',
      label: '치명',
      nextValue: Math.round((item.critRate ?? 0) * 100),
      currentValue: Math.round((equippedItem?.critRate ?? 0) * 100),
      unit: '%',
    },
    {
      key: 'spellPower',
      label: '주문력',
      nextValue: item.spellPower ?? 0,
      currentValue: equippedItem?.spellPower ?? 0,
    },
    {
      key: 'maxMpBonus',
      label: '최대 MP',
      nextValue: item.maxMpBonus ?? 0,
      currentValue: equippedItem?.maxMpBonus ?? 0,
    },
  ];

  return statRows.filter((row) => row.nextValue > 0 || row.currentValue > 0).map((row) => {
    const delta = row.nextValue - row.currentValue;
    return {
      ...row,
      delta,
    };
  });
}

function buildQuestTag(title: string) {
  return title.includes('[메인]') ? '메인' : title.includes('[서브]') ? '서브' : '일반';
}

function buildNpcDialogue(npc: NpcDefinition) {
  if (npc.role === 'shop') {
    return '슬라임 젤을 모았다면 상점 담당 미라에게 바로 보고해 보상을 받으세요. 완료 표시는 끝이 아니라 제출 가능 상태를 뜻합니다.';
  }
  if (npc.role === 'quest') {
    return '포탈 근처 슬라임 정리가 끝나면 기사단장 브롬에게 다시 말을 걸어 메인 퀘스트 보상을 받으세요.';
  }
  return '이동은 좌측 조이스틱, 공격과 스킬은 우측 제스처 패드 하나로 처리하세요. 중앙은 에너지 볼트, 방향 스와이프는 파이어 애로우, 아이스 대거, 라이트닝, 파이어볼, 그레이터 헤이스트, 매직 실드, 서먼 스켈레톤으로 이어집니다.';
}

function buildNpcSupportLabel(npc: NpcDefinition) {
  if (npc.role === 'shop') {
    return '추천 업무: 슬라임 젤 전달, 포션 보상 수령';
  }
  if (npc.role === 'quest') {
    return '추천 업무: 슬라임 토벌 보고, 메인 보상 수령';
  }
  return '추천 확인: 에너지 볼트와 방향별 리니지풍 마법 배치';
}

function buildNpcLinkedQuestIds(npc: NpcDefinition) {
  if (npc.role === 'quest') {
    return ['slime-hunt'];
  }
  if (npc.role === 'shop') {
    return ['slime-gel'];
  }
  if (npc.role === 'guide') {
    return ['slime-hunt'];
  }
  return [];
}

function findNpcName(npcId: string) {
  return THE_QUEST_NPCS.find((npc) => npc.id === npcId)?.name ?? '담당 NPC';
}

function buildQuestStatusLabel(quest: QuestProgressState) {
  if (quest.completed) {
    return '완료';
  }
  if (quest.readyToTurnIn) {
    return '보고 가능';
  }
  return buildQuestTag(quest.title);
}

function buildQuestStatusClassName(quest: QuestProgressState) {
  if (quest.completed) {
    return ' the-quest__quest-tag--done';
  }
  if (quest.readyToTurnIn) {
    return ' the-quest__quest-tag--ready';
  }
  return '';
}

function buildQuestObjectiveLabel(quest: QuestProgressState) {
  if (quest.progressType === 'collect') {
    const itemName = findRewardItemLabel(quest.turnInItemId) ?? '재료';
    return `${itemName} ${quest.requiredCount}개 수집`;
  }
  return '슬라임 처치';
}

function buildQuestNextStep(quest: QuestProgressState) {
  const npcName = findNpcName(quest.turnInNpcId);
  if (quest.completed) {
    return `${npcName} 보고와 보상 수령이 끝났습니다.`;
  }
  if (quest.readyToTurnIn) {
    return `${npcName}에게 가서 지금 보상을 받으세요.`;
  }
  if (quest.progressType === 'collect') {
    const remainingCount = Math.max(0, quest.requiredCount - quest.currentCount);
    const itemName = findRewardItemLabel(quest.turnInItemId) ?? '재료';
    return `${itemName} ${remainingCount}개를 더 모은 뒤 ${npcName}에게 전달하세요.`;
  }
  const remainingCount = Math.max(0, quest.requiredCount - quest.currentCount);
  return `슬라임 ${remainingCount}마리를 더 처치한 뒤 ${npcName}에게 보고하세요.`;
}

function findRewardItemLabel(itemId?: string) {
  if (!itemId) {
    return null;
  }

  return THE_QUEST_ITEMS.find((item) => item.id === itemId)?.name ?? null;
}

function formatBossStageLabel(stageCurrent: number) {
  return `보스 Lv.${stageCurrent + 13}`;
}

function useViewportClock() {
  const [now, setNow] = useState(() => Date.now());

  useEffect(() => {
    const interval = window.setInterval(() => setNow(Date.now()), 120);
    return () => window.clearInterval(interval);
  }, []);

  return now;
}

function findNearestAliveEnemyDistance(
  playerLocation: { x: number; y: number },
  enemies: Array<{ x: number; y: number; alive: boolean }>,
) {
  let nearestDistance = Number.POSITIVE_INFINITY;

  enemies.forEach((enemy) => {
    if (!enemy.alive) {
      return;
    }

    const distance = Math.hypot(enemy.x - playerLocation.x, enemy.y - playerLocation.y);
    if (distance < nearestDistance) {
      nearestDistance = distance;
    }
  });

  return nearestDistance;
}

function useTheQuestSnapshot() {
  return useTheQuestStore(
    useShallow((state) => ({
      vitals: state.vitals,
      equipment: state.equipment,
      inventory: state.inventory,
      quests: state.quests,
      skills: state.skills,
      skillCooldowns: state.skillCooldowns,
      enemies: state.enemies,
      npcs: state.npcs,
      autoCombat: state.autoCombat,
      overlayPanel: state.overlayPanel,
      activeNpcId: state.activeNpcId,
      selectedInventoryId: state.selectedInventoryId,
      selectedQuestId: state.selectedQuestId,
      joystick: state.joystick,
      minimap: state.minimap,
      playerLocation: state.playerLocation,
      performance: state.performance,
      stage: state.stage,
      lastCombatLog: state.lastCombatLog,
      consumableEffects: state.consumableEffects,
      setOverlayPanel: state.setOverlayPanel,
      setJoystick: state.setJoystick,
      setAutoCombat: state.setAutoCombat,
      setActiveNpc: state.setActiveNpc,
      setSelectedInventoryId: state.setSelectedInventoryId,
      setSelectedQuestId: state.setSelectedQuestId,
      useConsumable: state.useConsumable,
      equipItem: state.equipItem,
      unequipItem: state.unequipItem,
      turnInQuest: state.turnInQuest,
      advanceStage: state.advanceStage,
      setLastCombatLog: state.setLastCombatLog,
      requestManualSkillCast: state.requestManualSkillCast,
      reset: state.reset,
    })),
  );
}

function TheQuestJoystick() {
  const joystick = useTheQuestStore((state) => state.joystick);
  const setJoystick = useTheQuestStore((state) => state.setJoystick);
  const knobRef = useRef<HTMLDivElement | null>(null);
  const baseRef = useRef<HTMLDivElement | null>(null);
  const activePointerIdRef = useRef<number | null>(null);
  const activeTouchIdRef = useRef<number | null>(null);

  const setKnobTransform = (x: number, y: number) => {
    if (!knobRef.current) {
      return;
    }

    knobRef.current.style.transform = `translate(-50%, -50%) translate(${x}px, ${y}px)`;
  };

  const updateFromPointer = (clientX: number, clientY: number) => {
    const rect = baseRef.current?.getBoundingClientRect();
    if (!rect) {
      return;
    }

    const localX = clientX - rect.left - rect.width / 2;
    const localY = clientY - rect.top - rect.height / 2;
    const distance = Math.hypot(localX, localY);
    const maxDistance = rect.width * 0.28;
    const limitedDistance = Math.min(distance, maxDistance);
    const angle = Math.atan2(localY, localX);
    const knobX = Math.cos(angle) * limitedDistance;
    const knobY = Math.sin(angle) * limitedDistance;
    const normalizedX = distance === 0 ? 0 : knobX / maxDistance;
    const normalizedY = distance === 0 ? 0 : knobY / maxDistance;

    setJoystick({
      active: true,
      x: clamp(normalizedX, -1, 1),
      y: clamp(normalizedY, -1, 1),
    });

    setKnobTransform(knobX, knobY);
  };

  const handlePointerDown: React.PointerEventHandler<HTMLDivElement> = (event) => {
    if (event.pointerType === 'touch') {
      return;
    }
    activePointerIdRef.current = event.pointerId;
    event.currentTarget.setPointerCapture(event.pointerId);
    updateFromPointer(event.clientX, event.clientY);
  };

  const handlePointerMove: React.PointerEventHandler<HTMLDivElement> = (event) => {
    if (event.pointerType === 'touch') {
      return;
    }
    if (activePointerIdRef.current !== event.pointerId) {
      return;
    }

    updateFromPointer(event.clientX, event.clientY);
  };

  const handlePointerUp: React.PointerEventHandler<HTMLDivElement> = (event) => {
    if (event.pointerType === 'touch') {
      return;
    }
    if (activePointerIdRef.current !== event.pointerId) {
      return;
    }

    activePointerIdRef.current = null;
    setJoystick({ active: false, x: 0, y: 0 });
    setKnobTransform(0, 0);
  };

  const resetTouchJoystick = () => {
    activeTouchIdRef.current = null;
    setJoystick({ active: false, x: 0, y: 0 });
    setKnobTransform(0, 0);
  };

  const handleTouchStart: React.TouchEventHandler<HTMLDivElement> = (event) => {
    if (activeTouchIdRef.current !== null) {
      return;
    }

    const touch = event.changedTouches.item(0);
    if (!touch) {
      return;
    }

    activeTouchIdRef.current = touch.identifier;
    updateFromPointer(touch.clientX, touch.clientY);
    event.preventDefault();
  };

  const handleTouchMove: React.TouchEventHandler<HTMLDivElement> = (event) => {
    if (activeTouchIdRef.current === null) {
      return;
    }

    const touch = findTouchByIdentifier(event.touches, activeTouchIdRef.current);
    if (!touch) {
      return;
    }

    updateFromPointer(touch.clientX, touch.clientY);
    event.preventDefault();
  };

  const handleTouchEnd: React.TouchEventHandler<HTMLDivElement> = (event) => {
    if (activeTouchIdRef.current === null) {
      return;
    }

    const touch = findTouchByIdentifier(event.changedTouches, activeTouchIdRef.current);
    if (!touch) {
      return;
    }

    resetTouchJoystick();
    event.preventDefault();
  };

  const handleTouchCancel: React.TouchEventHandler<HTMLDivElement> = (event) => {
    if (activeTouchIdRef.current === null) {
      return;
    }

    resetTouchJoystick();
    event.preventDefault();
  };

  return (
    <div
      ref={baseRef}
      className={`the-quest__joystick${joystick.active ? ' the-quest__joystick--active' : ''}`}
      onPointerDown={handlePointerDown}
      onPointerMove={handlePointerMove}
      onPointerUp={handlePointerUp}
      onPointerCancel={handlePointerUp}
      onTouchStart={handleTouchStart}
      onTouchMove={handleTouchMove}
      onTouchEnd={handleTouchEnd}
      onTouchCancel={handleTouchCancel}
    >
      <div className="the-quest__joystick-ring" />
      <div ref={knobRef} className="the-quest__joystick-knob" />
    </div>
  );
}

function TheQuestGesturePad({
  now,
  skills,
  gesturePage,
  skillCooldowns,
  consumableEffects,
  emitSkillCast,
  activeNpc,
  activePortal,
  prioritizeCombat,
  portalUnlocked,
  onInteractNpc,
  onInteractPortal,
}: {
  now: number;
  skills: ReturnType<typeof useTheQuestSnapshot>['skills'];
  gesturePage: number;
  skillCooldowns: ReturnType<typeof useTheQuestSnapshot>['skillCooldowns'];
  consumableEffects: ReturnType<typeof useTheQuestSnapshot>['consumableEffects'];
  emitSkillCast: (skillId: ManualSkillCastCommand['skillId']) => void;
  activeNpc: NpcDefinition | null;
  activePortal: boolean;
  prioritizeCombat: boolean;
  portalUnlocked: boolean;
  onInteractNpc: (npc: NpcDefinition) => void;
  onInteractPortal: () => void;
}) {
  const pointerStateRef = useRef<{
    pointerId: number;
    startX: number;
    startY: number;
    startedInCore: boolean;
    executedAction: GestureSkillDirection | 'core' | null;
  } | null>(null);
  const castEffectTimerRef = useRef<number | null>(null);
  const [gestureDirection, setGestureDirection] = useState<GestureSkillDirection | null>(null);
  const [isPressed, setIsPressed] = useState(false);
  const [castEffectTarget, setCastEffectTarget] = useState<GestureSkillDirection | 'core' | null>(null);
  const gestureSkills = useMemo(
    () => getPagedGestureSkills(skills, gesturePage),
    [gesturePage, skills],
  );
  const skillDirectionMap = useMemo(
    () =>
      gestureSkills.reduce<Partial<Record<GestureSkillDirection, (typeof gestureSkills)[number]>>>((acc, skill, index) => {
        const direction = GESTURE_DIRECTION_ORDER[index];
        if (direction) {
          acc[direction] = skill;
        }
        return acc;
      }, {}),
    [gestureSkills],
  );

  const resolveDirection = (deltaX: number, deltaY: number) => {
    const distance = Math.hypot(deltaX, deltaY);
    if (distance < 22) {
      return null;
    }

    const angle = ((Math.atan2(-deltaY, deltaX) * 180) / Math.PI + 360) % 360;
    if (angle >= 67.5 && angle < 112.5) {
      return skillDirectionMap.up ? 'up' : null;
    }
    if (angle >= 22.5 && angle < 67.5) {
      return skillDirectionMap['up-right'] ? 'up-right' : null;
    }
    if (angle >= 337.5 || angle < 22.5) {
      return skillDirectionMap.right ? 'right' : null;
    }
    if (angle >= 292.5 && angle < 337.5) {
      return skillDirectionMap['down-right'] ? 'down-right' : null;
    }
    if (angle >= 247.5 && angle < 292.5) {
      return skillDirectionMap.down ? 'down' : null;
    }
    if (angle >= 202.5 && angle < 247.5) {
      return skillDirectionMap['down-left'] ? 'down-left' : null;
    }
    if (angle >= 157.5 && angle < 202.5) {
      return skillDirectionMap.left ? 'left' : null;
    }
    return skillDirectionMap['up-left'] ? 'up-left' : null;
  };

  useEffect(() => {
    return () => {
      if (castEffectTimerRef.current !== null) {
        window.clearTimeout(castEffectTimerRef.current);
      }
    };
  }, []);

  const triggerCastEffect = (target: GestureSkillDirection | 'core') => {
    if (castEffectTimerRef.current !== null) {
      window.clearTimeout(castEffectTimerRef.current);
    }
    setCastEffectTarget(target);
    castEffectTimerRef.current = window.setTimeout(() => {
      setCastEffectTarget((current) => (current === target ? null : current));
      castEffectTimerRef.current = null;
    }, 280);
  };

  const triggerGestureSkill = (direction: GestureSkillDirection | null) => {
    if (!direction) {
      return;
    }

    const skill = skillDirectionMap[direction];
    if (!skill) {
      return;
    }

    emitSkillCast(skill.id);
    triggerCastEffect(direction);
  };

  const triggerPrimaryAction = () => {
    if (prioritizeCombat) {
      emitSkillCast(PRIMARY_ACTION_SKILL);
      triggerCastEffect('core');
      return;
    }

    if (activePortal) {
      onInteractPortal();
      return;
    }

    if (activeNpc) {
      onInteractNpc(activeNpc);
      return;
    }

    emitSkillCast(PRIMARY_ACTION_SKILL);
    triggerCastEffect('core');
  };

  const isInsideGestureCore = (event: React.PointerEvent<HTMLDivElement>) => {
    const rect = event.currentTarget.getBoundingClientRect();
    const centerX = rect.left + rect.width / 2;
    const centerY = rect.top + rect.height / 2;
    const normalizedDistance =
      Math.hypot(event.clientX - centerX, event.clientY - centerY) / Math.min(rect.width, rect.height);

    return normalizedDistance <= 0.24;
  };

  const handlePointerDown: React.PointerEventHandler<HTMLDivElement> = (event) => {
    const startedInCore = isInsideGestureCore(event);
    pointerStateRef.current = {
      pointerId: event.pointerId,
      startX: event.clientX,
      startY: event.clientY,
      startedInCore,
      executedAction: null,
    };
    setIsPressed(true);
    setGestureDirection(null);
    event.currentTarget.setPointerCapture(event.pointerId);
  };

  const handlePointerMove: React.PointerEventHandler<HTMLDivElement> = (event) => {
    const pointerState = pointerStateRef.current;
    if (!pointerState || pointerState.pointerId !== event.pointerId) {
      return;
    }

    const direction = resolveDirection(event.clientX - pointerState.startX, event.clientY - pointerState.startY);
    setGestureDirection(direction);

    if (pointerState.startedInCore || !direction || pointerState.executedAction === direction) {
      return;
    }

    triggerGestureSkill(direction);
    pointerStateRef.current = {
      ...pointerState,
      executedAction: direction,
    };
  };

  const finishGesture: React.PointerEventHandler<HTMLDivElement> = (event) => {
    const pointerState = pointerStateRef.current;
    if (!pointerState || pointerState.pointerId !== event.pointerId) {
      return;
    }

    const direction = resolveDirection(event.clientX - pointerState.startX, event.clientY - pointerState.startY);
    pointerStateRef.current = null;
    setGestureDirection(null);
    setIsPressed(false);
    if (event.currentTarget.hasPointerCapture(event.pointerId)) {
      event.currentTarget.releasePointerCapture(event.pointerId);
    }
    if (pointerState.executedAction) {
      return;
    }
    if (!direction) {
      if (event.pointerType === 'touch' || event.target === event.currentTarget || pointerState.startedInCore) {
        triggerPrimaryAction();
      }
      return;
    }

    triggerGestureSkill(direction);
  };
  return (
    <div
      className={`the-quest__gesture-pad${isPressed ? ' the-quest__gesture-pad--active' : ''}${
        gestureDirection ? ` the-quest__gesture-pad--dir-${gestureDirection}` : ''
      }`}
      onPointerDown={handlePointerDown}
      onPointerMove={handlePointerMove}
      onPointerUp={finishGesture}
      onPointerCancel={finishGesture}
      role="group"
      aria-label="전투 제스처 패드"
    >
      <div className="the-quest__gesture-grid">
        {GESTURE_DIRECTION_ORDER.map((direction) => {
          const skill = skillDirectionMap[direction];
          const meta = GESTURE_DIRECTION_META[direction];
          if (!skill) {
            return (
              <div key={direction} className={`the-quest__gesture-node the-quest__gesture-node--${direction} the-quest__gesture-node--empty`}>
                <span className="the-quest__gesture-node-icon" aria-hidden="true">
                  {meta.icon}
                </span>
              </div>
            );
          }

          const readyAt = skillCooldowns[skill.id] ?? 0;
          const remaining = Math.max(0, readyAt - now);
          const effectState = resolveSkillEffectState(skill.id, consumableEffects, now);

          return (
            <div
              key={skill.id}
              className={`the-quest__gesture-node the-quest__gesture-node--${direction}${
                remaining > 0 ? ' the-quest__gesture-node--cooldown' : ''
              }${gestureDirection === direction ? ' the-quest__gesture-node--selected' : ''}${
                castEffectTarget === direction ? ' the-quest__gesture-node--cast' : ''
              }`}
            >
              <span className="the-quest__gesture-node-icon" aria-hidden="true">
                {meta.icon}
              </span>
              <strong>{skill.icon}</strong>
              <small>
                {effectState.remainingMs > 0
                  ? `${effectState.label} · ${formatSecondsLabel(effectState.remainingMs)}`
                  : remaining > 0
                  ? formatCooldownBadge(remaining)
                  : skill.name}
              </small>
            </div>
          );
        })}
        <button
          type="button"
          className={`the-quest__gesture-core${isPressed && !gestureDirection ? ' the-quest__gesture-core--pressed' : ''}${
            castEffectTarget === 'core' ? ' the-quest__gesture-core--cast' : ''
          }`}
          onClick={(event) => {
            if (event.detail !== 0) {
              return;
            }
            event.stopPropagation();
            triggerPrimaryAction();
          }}
        >
          <strong>{prioritizeCombat ? 'ATK' : activePortal ? (portalUnlocked ? 'GO' : 'LOCK') : activeNpc ? 'TALK' : 'ATK'}</strong>
        </button>
      </div>
    </div>
  );
}

function TheQuestPanel({ onBack }: { onBack: () => void }) {
  const {
    overlayPanel,
    vitals,
    stage,
    inventory,
    equipment,
    quests,
    skills,
    skillCooldowns,
    npcs,
    activeNpcId,
    selectedInventoryId,
    selectedQuestId,
    playerLocation,
    consumableEffects,
    setOverlayPanel,
    setActiveNpc,
    setSelectedInventoryId,
    setSelectedQuestId,
    useConsumable,
    equipItem,
    unequipItem,
    turnInQuest,
  } = useTheQuestSnapshot();
  const now = useViewportClock();
  const [draggedItemId, setDraggedItemId] = useState<string | null>(null);
  const [dragPointer, setDragPointer] = useState<{ x: number; y: number } | null>(null);
  const [inventoryTab, setInventoryTab] = useState<'equipment' | 'inventory'>('inventory');
  const [inventoryFilter, setInventoryFilter] = useState<InventoryCategoryFilter>('all');
  const [detailItemId, setDetailItemId] = useState<string | null>(null);
  const touchDragRef = useRef<{ pointerId: number; itemId: string } | null>(null);

  const filteredInventory = useMemo(
    () => inventory.filter((item) => inventoryFilter === 'all' || item.category === inventoryFilter),
    [inventory, inventoryFilter],
  );
  const selectedItem = filteredInventory.find((item) => item.id === selectedInventoryId) ?? filteredInventory[0] ?? null;
  const draggedItem = inventory.find((item) => item.id === draggedItemId) ?? null;
  const selectedQuest = quests.find((quest) => quest.id === selectedQuestId) ?? quests[0] ?? null;
  const activeNpc = npcs.find((npc) => npc.id === activeNpcId) ?? npcs[0] ?? null;
  const firstEquippedItem =
    EQUIPMENT_SLOT_ORDER.map((slot) => inventory.find((candidate) => candidate.id === equipment[slot]) ?? null).find(Boolean) ?? null;

  useEffect(() => {
    if (overlayPanel === 'equipment') {
      setInventoryTab('equipment');
      return;
    }
    if (overlayPanel === 'inventory') {
      setInventoryTab('inventory');
    }
    setDetailItemId(null);
  }, [overlayPanel]);

  useEffect(() => {
    setDetailItemId(null);
  }, [inventoryTab]);

  useEffect(() => {
    if (inventoryTab !== 'inventory') {
      return;
    }
    if (selectedItem && selectedItem.id !== selectedInventoryId) {
      setSelectedInventoryId(selectedItem.id);
      return;
    }
    if (!selectedItem && selectedInventoryId) {
      setSelectedInventoryId(null);
    }
  }, [inventoryTab, inventoryFilter, selectedInventoryId, selectedItem, setSelectedInventoryId]);

  const openDetailItem = inventory.find((item) => item.id === detailItemId) ?? null;
  const detailItemSlot = openDetailItem?.slot;
  const equippedComparisonItem =
    detailItemSlot && equipment[detailItemSlot] && equipment[detailItemSlot] !== openDetailItem.id
      ? inventory.find((candidate) => candidate.id === equipment[detailItemSlot]) ?? null
      : null;
  const comparisonRows = detailItemSlot ? buildItemComparison(openDetailItem, equippedComparisonItem) : [];
  const isDetailItemEquipped = Boolean(detailItemSlot && equipment[detailItemSlot] === openDetailItem.id);
  const linkedQuestIds = activeNpc ? buildNpcLinkedQuestIds(activeNpc) : [];
  const linkedQuests = linkedQuestIds
    .map((questId) => quests.find((quest) => quest.id === questId) ?? null)
    .filter(Boolean);
  const reportableLinkedQuests = linkedQuests.filter((quest) => quest.readyToTurnIn && !quest.completed && quest.turnInNpcId === activeNpc?.id);
  const selectedQuestNpcName = selectedQuest ? findNpcName(selectedQuest.turnInNpcId) : null;
  const canTurnInSelectedQuest = Boolean(selectedQuest && !selectedQuest.completed && selectedQuest.readyToTurnIn && activeNpc?.id === selectedQuest.turnInNpcId);
  const openQuestTurnInNpc = (quest: QuestProgressState) => {
    setActiveNpc(quest.turnInNpcId);
    setSelectedQuestId(quest.id);
    setOverlayPanel('npcs');
  };

  const handleEquipDrop = (itemId: string, slot: EquipmentSlot) => {
    const item = inventory.find((candidate) => candidate.id === itemId);
    if (!item?.slot || item.slot !== slot) {
      return;
    }

    equipItem(itemId);
    setSelectedInventoryId(itemId);
    setDetailItemId(itemId);
    setDraggedItemId(null);
    setDragPointer(null);
  };

  const handleItemPointerDown = (itemId: string): React.PointerEventHandler<HTMLButtonElement> => (event) => {
    if (event.pointerType === 'mouse') {
      return;
    }

    touchDragRef.current = {
      pointerId: event.pointerId,
      itemId,
    };
    setDraggedItemId(itemId);
    setDragPointer({ x: event.clientX, y: event.clientY });
    event.currentTarget.setPointerCapture(event.pointerId);
  };

  const handleItemPointerMove: React.PointerEventHandler<HTMLButtonElement> = (event) => {
    if (!touchDragRef.current || touchDragRef.current.pointerId !== event.pointerId) {
      return;
    }

    setDragPointer({ x: event.clientX, y: event.clientY });
  };

  const finishTouchDrag = (event: React.PointerEvent<HTMLButtonElement>) => {
    if (!touchDragRef.current || touchDragRef.current.pointerId !== event.pointerId) {
      return;
    }

    const currentDrag = touchDragRef.current;
    touchDragRef.current = null;
    if (event.currentTarget.hasPointerCapture(event.pointerId)) {
      event.currentTarget.releasePointerCapture(event.pointerId);
    }

    const dropTarget = document.elementFromPoint(event.clientX, event.clientY)?.closest<HTMLElement>('[data-equip-slot]');
    const slot = dropTarget?.dataset.equipSlot as EquipmentSlot | undefined;
    if (slot) {
      handleEquipDrop(currentDrag.itemId, slot);
      return;
    }

    setDraggedItemId(null);
    setDragPointer(null);
  };

  if (!overlayPanel) {
    return null;
  }

  return (
    <div className="the-quest__panel-sheet">
      <header className="the-quest__panel-header">
        <strong>
          {overlayPanel === 'character'
            ? '캐릭터 상세'
            : overlayPanel === 'inventory' || overlayPanel === 'equipment'
            ? '가방/장비'
            : overlayPanel === 'quests'
            ? '퀘스트'
            : overlayPanel === 'npcs'
            ? 'NPC'
            : '설정'}
        </strong>
        <button
          type="button"
          className="the-quest__panel-close"
          aria-label="패널 닫기"
          title="닫기"
          onClick={() => setOverlayPanel(null)}
        >
          <CloseOutlined />
        </button>
      </header>

      {overlayPanel === 'character' ? (
        <div className="the-quest__panel-content the-quest__panel-content--character">
          <div className="the-quest__character-layout">
            <section className="the-quest__character-hero">
              <button
                type="button"
                className="the-quest__character-portrait-button"
                onClick={() => setOverlayPanel('equipment')}
                aria-label="장비 패널 열기"
                title="장비 관리"
              >
                <div className="the-quest__portrait-card the-quest__portrait-card--player" aria-hidden="true">
                  <span className="the-quest__portrait-glow" />
                  <span className="the-quest__portrait-frame" />
                  <span className="the-quest__portrait-hair" />
                  <span className="the-quest__portrait-face" />
                  <span className="the-quest__portrait-cloak" />
                  <span className="the-quest__portrait-armor" />
                  <span className="the-quest__portrait-weapon" />
                </div>
              </button>
              <div className="the-quest__character-hero-copy">
                <span>Moon Guard / Wizard</span>
                <strong>문가드 메이지</strong>
                <div className="the-quest__character-hero-meta">
                  <span>Lv.{vitals.level}</span>
                  <span>Stage {stage.current}</span>
                  <span>{playerLocation.zoneLabel}</span>
                  <span>{playerLocation.placeLabel}</span>
                </div>
                <div className="the-quest__character-hero-actions">
                  <button type="button" onClick={() => setOverlayPanel('equipment')}>
                    장비 보기
                  </button>
                  <button type="button" onClick={() => setOverlayPanel('inventory')}>
                    가방 보기
                  </button>
                </div>
              </div>
            </section>

            <section className="the-quest__character-section">
              <span className="the-quest__sidebar-label">Status</span>
              <strong>기본 스탯</strong>
              <div className="the-quest__character-stat-grid">
                <div className="the-quest__sidebar-stat-box">
                  <span>EXP</span>
                  <strong>
                    {vitals.exp}/{vitals.expToNext}
                  </strong>
                </div>
                <div className="the-quest__sidebar-stat-box">
                  <span>장착</span>
                  <strong>{EQUIPMENT_SLOT_ORDER.filter((slot) => equipment[slot]).length}/11</strong>
                </div>
                <div className="the-quest__sidebar-stat-box">
                  <span>HP</span>
                  <strong>{formatRatio(vitals.hp, vitals.maxHp)}</strong>
                </div>
                <div className="the-quest__sidebar-stat-box">
                  <span>MP</span>
                  <strong>{formatRatio(vitals.mp, vitals.maxMp)}</strong>
                </div>
                <div className="the-quest__sidebar-stat-box">
                  <span>공격</span>
                  <strong>{vitals.attack}</strong>
                </div>
                <div className="the-quest__sidebar-stat-box">
                  <span>주문력</span>
                  <strong>{vitals.spellPower}</strong>
                </div>
                <div className="the-quest__sidebar-stat-box">
                  <span>방어</span>
                  <strong>{vitals.defense}</strong>
                </div>
                <div className="the-quest__sidebar-stat-box">
                  <span>치명</span>
                  <strong>{formatPercent(vitals.critRate)}</strong>
                </div>
                <div className="the-quest__sidebar-stat-box">
                  <span>마나 흡수</span>
                  <strong>{formatPercent(vitals.manaAbsorbChance)}</strong>
                </div>
                <div className="the-quest__sidebar-stat-box">
                  <span>골드</span>
                  <strong>{vitals.gold.toLocaleString('ko-KR')}</strong>
                </div>
              </div>
            </section>

            <section className="the-quest__character-section">
              <span className="the-quest__sidebar-label">Buff</span>
              <strong>적용 효과</strong>
              {buildActiveBuffEntries(consumableEffects, now).length ? (
                <div className="the-quest__effect-list">
                  {buildActiveBuffEntries(consumableEffects, now).map((entry) => (
                    <div key={entry.id} className="the-quest__effect-chip">
                      <strong>{entry.label}</strong>
                      <span>
                        {entry.detail} · {formatSecondsLabel(entry.remainingMs)}
                      </span>
                    </div>
                  ))}
                </div>
              ) : (
                <p className="the-quest__character-empty-copy">현재 활성 버프가 없습니다. 버프 스킬이나 소모품을 사용하면 이곳에 표시됩니다.</p>
              )}
            </section>

            <section className="the-quest__character-section">
              <span className="the-quest__sidebar-label">Skills</span>
              <strong>스킬 상세</strong>
              <div className="the-quest__sidebar-skill-list">
                {skills.map((skill, index) => {
                  const readyAt = skillCooldowns[skill.id] ?? 0;
                  const remainingMs = Math.max(0, readyAt - now);
                  const effectState = resolveSkillEffectState(skill.id, consumableEffects, now);
                  const isEffectActive = effectState.remainingMs > 0;

                  return (
                    <article key={skill.id} className="the-quest__sidebar-skill-card">
                      <div className="the-quest__sidebar-skill-head">
                        <div className="the-quest__sidebar-skill-icon" aria-hidden="true">
                          {skill.icon}
                        </div>
                        <div className="the-quest__sidebar-skill-copy">
                          <div className="the-quest__sidebar-skill-title-row">
                            <strong>{skill.name}</strong>
                            <span className="the-quest__sidebar-skill-tag">{index === 0 ? '기본' : `${index}`}</span>
                          </div>
                          <small>{skill.description}</small>
                        </div>
                      </div>
                      <div className="the-quest__sidebar-skill-meta">
                        <span>MP {skill.manaCost}</span>
                        <span>쿨 {formatCooldownBadge(skill.cooldownMs)}</span>
                        <span>{skill.durationMs ? `지속 ${Math.round(skill.durationMs / 1000)}초` : `사거리 ${Math.round(skill.range)}`}</span>
                      </div>
                      <div className="the-quest__sidebar-skill-state">
                        <em
                          className={
                            isEffectActive
                              ? 'the-quest__sidebar-skill-state--active'
                              : remainingMs > 0
                              ? 'the-quest__sidebar-skill-state--cooldown'
                              : ''
                          }
                        >
                          {isEffectActive
                            ? `${effectState.label ?? '효과 적용'} · ${formatSecondsLabel(effectState.remainingMs)}`
                            : remainingMs > 0
                            ? `재사용 ${formatCooldownBadge(remainingMs)}`
                            : '사용 가능'}
                        </em>
                        <span>{skill.accentLabel}</span>
                      </div>
                    </article>
                  );
                })}
              </div>
            </section>
          </div>
        </div>
      ) : null}

      {overlayPanel === 'inventory' || overlayPanel === 'equipment' ? (
        <div className="the-quest__panel-content the-quest__panel-content--inventory">
          <div className="the-quest__inventory-shell">
            <div className="the-quest__inventory-layout">
              <section className="the-quest__inventory-main">
              <div className="the-quest__inventory-topbar">
                <div>
                  <span className="the-quest__inventory-caption">Royal Vault</span>
                  <strong>{inventoryTab === 'equipment' ? '장비 관리' : '모바일 인벤토리'}</strong>
                </div>
                <div className="the-quest__inventory-gold">
                  <GoldOutlined />
                  <span>{vitals.gold.toLocaleString('ko-KR')}</span>
                  <small>Gold</small>
                </div>
              </div>

              <div className="the-quest__inventory-tabs" role="tablist" aria-label="인벤토리 보기 탭">
                <button
                  type="button"
                  role="tab"
                  aria-selected={inventoryTab === 'equipment'}
                  className={`the-quest__inventory-tab${inventoryTab === 'equipment' ? ' the-quest__inventory-tab--active' : ''}`}
                  onClick={() => setInventoryTab('equipment')}
                >
                  장비
                </button>
                <button
                  type="button"
                  role="tab"
                  aria-selected={inventoryTab === 'inventory'}
                  className={`the-quest__inventory-tab${inventoryTab === 'inventory' ? ' the-quest__inventory-tab--active' : ''}`}
                  onClick={() => setInventoryTab('inventory')}
                >
                  인벤토리
                </button>
              </div>

              {inventoryTab === 'equipment' ? (
                <div className="the-quest__equipment-panel">
                  <div className="the-quest__equipment-hero">
                    <div className="the-quest__portrait-card the-quest__portrait-card--player" aria-hidden="true">
                      <span className="the-quest__portrait-glow" />
                      <span className="the-quest__portrait-frame" />
                      <span className="the-quest__portrait-hair" />
                      <span className="the-quest__portrait-face" />
                      <span className="the-quest__portrait-cloak" />
                      <span className="the-quest__portrait-armor" />
                      <span className="the-quest__portrait-weapon" />
                    </div>
                    <div className="the-quest__equipment-hero-copy">
                      <span>Character Render</span>
                      <strong>문가드 메이지</strong>
                    </div>
                  </div>
                  <div className="the-quest__equipment-column">
                    {EQUIPMENT_SLOT_ORDER.map((slot) => {
                      const equippedItem = inventory.find((candidate) => candidate.id === equipment[slot]) ?? null;
                      return (
                        <div
                          key={slot}
                          className={`the-quest__equipment-slot the-quest__equipment-slot--${slot}${draggedItem?.slot === slot ? ' the-quest__equipment-slot--drop-ready' : ''}`}
                          data-equip-slot={slot}
                          onDragOver={(event) => {
                            if (draggedItem?.slot === slot) {
                              event.preventDefault();
                            }
                          }}
                          onDrop={(event) => {
                            event.preventDefault();
                            const itemId = event.dataTransfer.getData('text/plain');
                            handleEquipDrop(itemId, slot);
                          }}
                        >
                          <div className="the-quest__equipment-slot-head">
                            <span>{formatEquipmentSlot(slot)}</span>
                          </div>
                          <button
                            type="button"
                            className={`the-quest__equipment-slot-card${equippedItem ? '' : ' the-quest__equipment-slot-card--empty'}${
                              equippedItem?.id === openDetailItem?.id ? ' the-quest__equipment-slot-card--active' : ''
                            }`}
                            onClick={() => {
                              if (equippedItem) {
                                setSelectedInventoryId(equippedItem.id);
                                setDetailItemId(equippedItem.id);
                              }
                            }}
                            aria-label={equippedItem ? `${equippedItem.name} 상세 보기` : `${formatEquipmentSlot(slot)} 빈 슬롯`}
                          >
                            <span className="the-quest__item-icon">{equippedItem?.icon ?? '✧'}</span>
                            <div className="the-quest__equipment-slot-copy">
                              <strong>{equippedItem?.name ?? '빈 슬롯'}</strong>
                              {equippedItem ? (
                                <span className={`the-quest__item-rarity-chip the-quest__item-rarity-chip--${equippedItem.rarity}`}>
                                  {ITEM_RARITY_META[equippedItem.rarity].label}
                                </span>
                              ) : null}
                            </div>
                          </button>
                          <button type="button" onClick={() => unequipItem(slot)} disabled={!equippedItem}>
                            해제
                          </button>
                        </div>
                      );
                    })}
                  </div>
                </div>
              ) : (
                <div className="the-quest__inventory-grid-panel">
                  <div className="the-quest__inventory-grid-head">
                    <div>
                      <strong>소지품</strong>
                      <span>
                        {filteredInventory.length}/{inventory.length}칸
                      </span>
                    </div>
                    <div className="the-quest__inventory-grid-actions">
                      <button
                        type="button"
                        onClick={() => {
                          if (selectedItem) {
                            setDetailItemId(selectedItem.id);
                          }
                        }}
                        disabled={!selectedItem}
                      >
                        상세 보기
                      </button>
                    </div>
                  </div>
                  <div className="the-quest__inventory-filters" role="tablist" aria-label="아이템 분류 필터">
                    {INVENTORY_CATEGORY_FILTERS.map((filter) => {
                      const filterCount =
                        filter.key === 'all' ? inventory.length : inventory.filter((item) => item.category === filter.key).length;
                      return (
                        <button
                          key={filter.key}
                          type="button"
                          role="tab"
                          aria-selected={inventoryFilter === filter.key}
                          className={`the-quest__inventory-filter${inventoryFilter === filter.key ? ' the-quest__inventory-filter--active' : ''}`}
                          onClick={() => setInventoryFilter(filter.key)}
                        >
                          <span>{filter.label}</span>
                          <em>{filterCount}</em>
                        </button>
                      );
                    })}
                  </div>
                  <div className="the-quest__item-grid">
                    {filteredInventory.map((item) => (
                      <button
                        key={item.id}
                        type="button"
                        draggable
                        className={[
                          'the-quest__item-card',
                          ITEM_RARITY_META[item.rarity].className,
                          item.id === selectedItem?.id ? 'the-quest__item-card--active' : '',
                          draggedItemId === item.id ? 'the-quest__item-card--dragging' : '',
                        ]
                          .filter(Boolean)
                          .join(' ')}
                        onClick={() => {
                          setSelectedInventoryId(item.id);
                        }}
                        aria-label={`${item.name}${item.quantity ? ` ${item.quantity}개` : ''}`}
                        title={item.name}
                        onPointerDown={handleItemPointerDown(item.id)}
                        onPointerMove={handleItemPointerMove}
                        onPointerUp={finishTouchDrag}
                        onPointerCancel={finishTouchDrag}
                        onDragStart={(event) => {
                          event.dataTransfer.effectAllowed = 'move';
                          event.dataTransfer.setData('text/plain', item.id);
                          setDraggedItemId(item.id);
                        }}
                        onDragEnd={() => {
                          setDraggedItemId(null);
                          setDragPointer(null);
                        }}
                      >
                        <span className="the-quest__item-rarity-frame" />
                        <span className={`the-quest__item-rarity-chip the-quest__item-rarity-chip--${item.rarity}`}>
                          {ITEM_RARITY_META[item.rarity].label}
                        </span>
                        <span className="the-quest__item-icon">{item.icon}</span>
                        <span className="the-quest__sr-only">{item.name}</span>
                        {item.slot && equipment[item.slot] === item.id ? (
                          <span className="the-quest__item-equipped-mark" aria-hidden="true">
                            E
                          </span>
                        ) : null}
                        {item.quantity ? <em className="the-quest__item-stack">{item.quantity}</em> : null}
                      </button>
                    ))}
                  </div>
                </div>
              )}
              </section>
            </div>
          </div>
          {openDetailItem
            ? createPortal(
                <div
                  className="the-quest__item-detail-modal-backdrop"
                  role="presentation"
                  onClick={() => setDetailItemId(null)}
                >
                  <section
                    className="the-quest__item-detail-modal"
                    aria-label={`${openDetailItem.name} 상세 정보`}
                    role="dialog"
                    aria-modal="true"
                    onClick={(event) => event.stopPropagation()}
                  >
                    <div className="the-quest__item-detail-modal-head">
                      <div className="the-quest__item-tooltip-headline">
                        <strong>상세 정보</strong>
                        <span>{inventoryTab === 'equipment' ? '착용 슬롯 기준' : '선택한 아이템 기준'}</span>
                      </div>
                      <button
                        type="button"
                        className="the-quest__item-detail-close"
                        aria-label="상세 정보 닫기"
                        onClick={() => setDetailItemId(null)}
                      >
                        <CloseOutlined />
                      </button>
                    </div>
                    <div className={`the-quest__item-tooltip-badge the-quest__item-tooltip-badge--${openDetailItem.rarity}`}>
                      {ITEM_RARITY_META[openDetailItem.rarity].label}
                    </div>
                    <div className="the-quest__item-tooltip-header">
                      <span className="the-quest__item-icon">{openDetailItem.icon}</span>
                      <div className="the-quest__item-tooltip-copy">
                        <strong>{openDetailItem.name}</strong>
                        <span>{openDetailItem.slot ? formatEquipmentSlot(openDetailItem.slot) : buildItemCategoryLabel(openDetailItem.category)}</span>
                      </div>
                    </div>
                    {isDetailItemEquipped ? <div className="the-quest__item-equipped-chip">현재 착용 중</div> : null}
                    <div className="the-quest__item-stats">
                      {buildItemStats(openDetailItem).map((stat) => (
                        <span key={stat}>{stat}</span>
                      ))}
                    </div>
                    {equippedComparisonItem && comparisonRows.length ? (
                      <div className="the-quest__item-compare">
                        <div className="the-quest__item-compare-head">
                          <strong>현재 장착 비교</strong>
                          <small>
                            현재 착용: {equippedComparisonItem.icon} {equippedComparisonItem.name}
                          </small>
                        </div>
                        <div className="the-quest__item-compare-list">
                          {comparisonRows.map((row) => (
                            <div key={row.key} className="the-quest__item-compare-row">
                              <span>{row.label}</span>
                              <strong>
                                {row.currentValue}
                                {row.unit ?? ''} → {row.nextValue}
                                {row.unit ?? ''}
                              </strong>
                              <em className={row.delta >= 0 ? 'the-quest__item-compare-delta--up' : 'the-quest__item-compare-delta--down'}>
                                {row.delta >= 0 ? '+' : ''}
                                {row.delta}
                                {row.unit ?? ''}
                              </em>
                            </div>
                          ))}
                        </div>
                      </div>
                    ) : null}
                    <div className="the-quest__item-actions the-quest__item-actions--detail">
                      {openDetailItem.slot && !isDetailItemEquipped ? (
                        <button type="button" onClick={() => equipItem(openDetailItem.id)}>
                          장착
                        </button>
                      ) : null}
                      {isDetailItemEquipped && openDetailItem.slot ? (
                        <button type="button" onClick={() => unequipItem(openDetailItem.slot!)}>
                          해제
                        </button>
                      ) : null}
                      {openDetailItem.category === 'consumable' ? (
                        <button type="button" onClick={() => useConsumable(openDetailItem.id)}>
                          사용
                        </button>
                      ) : null}
                    </div>
                  </section>
                </div>,
                document.body,
              )
            : null}
          {draggedItem && dragPointer ? (
            <div
              className="the-quest__drag-preview"
              style={{
                left: dragPointer.x,
                top: dragPointer.y,
              }}
            >
              <span className="the-quest__item-icon">{draggedItem.icon}</span>
              <strong>{draggedItem.name}</strong>
            </div>
          ) : null}
        </div>
      ) : null}

      {overlayPanel === 'quests' ? (
        <div className="the-quest__panel-content the-quest__panel-content--quests">
          <div className="the-quest__quest-list">
            {quests.map((quest) => (
              <button
                key={quest.id}
                type="button"
                className={`the-quest__quest-card${quest.id === selectedQuest?.id ? ' the-quest__quest-card--active' : ''}`}
                onClick={() => setSelectedQuestId(quest.id)}
              >
                <div className="the-quest__quest-card-head">
                  <span className={`the-quest__quest-tag${buildQuestStatusClassName(quest)}`}>
                    {buildQuestStatusLabel(quest)}
                  </span>
                  <small>
                    진행 {quest.currentCount}/{quest.requiredCount}
                  </small>
                </div>
                <strong>{quest.title}</strong>
                <p>{quest.description}</p>
                <span className="the-quest__quest-next-step">{buildQuestNextStep(quest)}</span>
                <div className="the-quest__quest-card-foot">
                  <span>EXP {quest.rewardExp}</span>
                  <span>Gold {quest.rewardGold}</span>
                  {findRewardItemLabel(quest.rewardItemId) ? <span>{findRewardItemLabel(quest.rewardItemId)}</span> : null}
                </div>
              </button>
            ))}
          </div>
          {selectedQuest ? (
            <div className="the-quest__quest-detail">
              <div className="the-quest__quest-detail-head">
                <span className={`the-quest__quest-tag${buildQuestStatusClassName(selectedQuest)}`}>
                  {buildQuestStatusLabel(selectedQuest)}
                </span>
                <strong>{selectedQuest.title}</strong>
              </div>
              <p>{selectedQuest.description}</p>
              <div className="the-quest__quest-guide">
                <span>{buildQuestObjectiveLabel(selectedQuest)}</span>
                <strong>{buildQuestNextStep(selectedQuest)}</strong>
              </div>
              <div className="the-quest__quest-progress">
                <div style={{ width: `${(selectedQuest.currentCount / selectedQuest.requiredCount) * 100}%` }} />
              </div>
              <div className="the-quest__quest-detail-metrics">
                <span>진행 {selectedQuest.currentCount}/{selectedQuest.requiredCount}</span>
                <span>보고 NPC {selectedQuestNpcName}</span>
                <span>보상 {selectedQuest.rewardExp} EXP</span>
                <span>{selectedQuest.rewardGold} 골드</span>
                {findRewardItemLabel(selectedQuest.rewardItemId) ? <span>{findRewardItemLabel(selectedQuest.rewardItemId)}</span> : null}
              </div>
              <div className="the-quest__quest-actions">
                {canTurnInSelectedQuest ? (
                  <button type="button" className="the-quest__quest-action-button" onClick={() => turnInQuest(selectedQuest.id)}>
                    보상 받기
                  </button>
                ) : (
                  <button type="button" className="the-quest__quest-action-button" onClick={() => openQuestTurnInNpc(selectedQuest)}>
                    {selectedQuest.completed ? `${selectedQuestNpcName} 보기` : `${selectedQuestNpcName}에게 가기`}
                  </button>
                )}
              </div>
            </div>
          ) : null}
        </div>
      ) : null}

      {overlayPanel === 'npcs' && activeNpc ? (
        <div className="the-quest__panel-content the-quest__panel-content--npc">
          <div className="the-quest__npc-layout">
            <div className="the-quest__npc-list">
              {npcs.map((npc) => (
                <button
                  key={npc.id}
                  type="button"
                  className={`the-quest__npc-list-button${npc.id === activeNpc.id ? ' the-quest__npc-list-button--active' : ''}`}
                  onClick={() => setActiveNpc(npc.id)}
                >
                  <span className={`the-quest__role-badge ${NPC_ROLE_META[npc.role].className}`}>{NPC_ROLE_META[npc.role].badge}</span>
                  <strong>{npc.name}</strong>
                  <small>{NPC_ROLE_META[npc.role].label}</small>
                </button>
              ))}
            </div>
            <div className="the-quest__npc-stage">
              <div className="the-quest__npc-card the-quest__npc-card--hero">
                <div className="the-quest__npc-hero-head">
                  <span className={`the-quest__role-badge ${NPC_ROLE_META[activeNpc.role].className}`}>
                    {NPC_ROLE_META[activeNpc.role].badge}
                  </span>
                  <strong>{activeNpc.name}</strong>
                </div>
                <span>{activeNpc.description}</span>
                <div className="the-quest__npc-meta">
                  <span>{NPC_ROLE_META[activeNpc.role].label}</span>
                  <span>{buildNpcSupportLabel(activeNpc)}</span>
                </div>
              </div>
              <div className="the-quest__npc-card">
                <strong>현재 안내</strong>
                <p>{buildNpcDialogue(activeNpc)}</p>
              </div>
              {reportableLinkedQuests.length ? (
                <div className="the-quest__npc-card the-quest__npc-card--report">
                  <strong>즉시 처리 가능</strong>
                  <div className="the-quest__npc-report-list">
                    {reportableLinkedQuests.map((quest) => (
                      <div key={quest.id} className="the-quest__npc-report-row">
                        <div className="the-quest__npc-report-copy">
                          <span className={`the-quest__quest-tag${buildQuestStatusClassName(quest)}`}>{buildQuestStatusLabel(quest)}</span>
                          <strong>{quest.title}</strong>
                        </div>
                        <button type="button" className="the-quest__quest-action-button" onClick={() => turnInQuest(quest.id)}>
                          보상 받기
                        </button>
                      </div>
                    ))}
                  </div>
                </div>
              ) : null}
              {linkedQuests.length ? (
                <div className="the-quest__npc-card">
                  <strong>연결 퀘스트</strong>
                  <div className="the-quest__npc-quest-list">
                    {linkedQuests.map((quest) => (
                      <button
                        key={quest.id}
                        type="button"
                        className={`the-quest__npc-quest-card${quest.id === selectedQuest?.id ? ' the-quest__npc-quest-card--active' : ''}`}
                        onClick={() => {
                          setSelectedQuestId(quest.id);
                          setOverlayPanel('quests');
                        }}
                      >
                        <div className="the-quest__npc-quest-head">
                          <span className={`the-quest__quest-tag${buildQuestStatusClassName(quest)}`}>
                            {buildQuestStatusLabel(quest)}
                          </span>
                          <small>
                            {quest.currentCount}/{quest.requiredCount}
                          </small>
                        </div>
                        <strong>{quest.title}</strong>
                        <p>{quest.description}</p>
                        <span className="the-quest__quest-next-step">{buildQuestNextStep(quest)}</span>
                        {quest.readyToTurnIn && !quest.completed && activeNpc.id === quest.turnInNpcId ? (
                          <span className="the-quest__npc-quest-cta">이 NPC에게 보고 가능</span>
                        ) : null}
                      </button>
                    ))}
                  </div>
                </div>
              ) : null}
            </div>
          </div>
        </div>
      ) : null}

      {overlayPanel === 'settings' ? (
        <div className="the-quest__panel-content">
          <section className="the-quest__settings-card">
            <span className="the-quest__sidebar-label">앱 설정</span>
            <strong>The Quest 메뉴</strong>
            <p>상단 헤더를 제거한 대신 여기서 앱 종료를 처리합니다.</p>
            <button type="button" className="the-quest__settings-exit" onClick={onBack}>
              앱 나가기
            </button>
          </section>
        </div>
      ) : null}
    </div>
  );
}

export function TheQuestAppView({ onBack, launchContext = 'direct' }: TheQuestAppViewProps) {
  const mountRef = useRef<HTMLDivElement | null>(null);
  const gameHandleRef = useRef<{ destroy: () => void; requestManualSkillCast: (skillId: string) => void } | null>(null);
  const pressedMovementKeysRef = useRef<Set<string>>(new Set());
  const [isReady, setIsReady] = useState(false);
  const [initError, setInitError] = useState<string | null>(null);
  const [consumablePage, setConsumablePage] = useState(0);
  const [gestureSkillPage, setGestureSkillPage] = useState(0);
  const isEmbeddedLaunch = launchContext === 'embedded';
  const now = useViewportClock();
  const {
    vitals,
    equipment,
    inventory,
    quests,
    skills,
    skillCooldowns,
    autoCombat,
    enemies,
    npcs,
    overlayPanel,
    activeNpcId,
    performance,
    playerLocation,
    consumableEffects,
    setOverlayPanel,
    setAutoCombat,
    setActiveNpc,
    setJoystick,
    setSelectedQuestId,
    useConsumable,
    requestManualSkillCast,
    stage,
    advanceStage,
    setLastCombatLog,
  } = useTheQuestSnapshot();

  useEffect(() => {
    let isMounted = true;
    let handle: { destroy: () => void; requestManualSkillCast: (skillId: string) => void } | null = null;

    if (!mountRef.current) {
      return undefined;
    }

    setInitError(null);
    void createTheQuestGame({ mountElement: mountRef.current })
      .then((nextHandle) => {
        if (!isMounted) {
          nextHandle.destroy();
          return;
        }

        handle = nextHandle;
        gameHandleRef.current = nextHandle;
        setIsReady(true);
      })
      .catch((error) => {
        if (!isMounted) {
          return;
        }
        setInitError(error instanceof Error ? error.message : '게임 엔진 초기화에 실패했습니다.');
      });

    return () => {
      isMounted = false;
      setIsReady(false);
      gameHandleRef.current = null;
      handle?.destroy();
    };
  }, []);

  const activeNpc = useMemo(
    () => npcs.find((npc) => npc.id === activeNpcId) ?? null,
    [activeNpcId, npcs],
  );
  const bossEnemy = useMemo(
    () => enemies.find((enemy) => enemy.role === 'boss') ?? null,
    [enemies],
  );
  const portalUnlocked = bossEnemy ? !bossEnemy.alive : false;
  const activePortal = useMemo(
    () => Math.hypot(playerLocation.x - THE_QUEST_STAGE_PORTAL.x, playerLocation.y - THE_QUEST_STAGE_PORTAL.y) <= THE_QUEST_STAGE_PORTAL.radius,
    [playerLocation.x, playerLocation.y],
  );
  const nearestEnemyDistance = useMemo(
    () => findNearestAliveEnemyDistance(playerLocation, enemies),
    [enemies, playerLocation],
  );
  const shouldPrioritizeCombat = nearestEnemyDistance <= 244;
  const quickConsumables = useMemo(
    () =>
      QUICK_CONSUMABLE_ORDER.map((itemId) => inventory.find((item) => item.id === itemId && item.category === 'consumable') ?? null),
    [inventory],
  );
  const availableQuickConsumables = useMemo(() => quickConsumables.filter(Boolean), [quickConsumables]);
  const consumablePageCount = Math.max(1, Math.ceil(availableQuickConsumables.length / QUICK_CONSUMABLE_SLOTS));
  const gestureSkillPageCount = Math.max(
    1,
    Math.ceil(skills.filter((skill) => skill.id !== PRIMARY_ACTION_SKILL).length / GESTURE_SKILL_PAGE_SIZE),
  );
  const visibleGestureSkills = useMemo(() => getPagedGestureSkills(skills, gestureSkillPage), [gestureSkillPage, skills]);
  const visibleConsumableSlots = useMemo(() => {
    const startIndex = consumablePage * QUICK_CONSUMABLE_SLOTS;
    const visibleItems = availableQuickConsumables.slice(startIndex, startIndex + QUICK_CONSUMABLE_SLOTS);

    return Array.from({ length: QUICK_CONSUMABLE_SLOTS }, (_, index) => visibleItems[index] ?? null);
  }, [availableQuickConsumables, consumablePage]);
  const hpGaugePercent = buildGaugePercent(vitals.hp, vitals.maxHp);
  const mpGaugePercent = buildGaugePercent(vitals.mp, vitals.maxMp);
  const activeQuestCount = quests.filter((quest) => !quest.completed).length;
  const activeBuffEntries = useMemo(() => buildActiveBuffEntries(consumableEffects, now), [consumableEffects, now]);
  const maxHudBuffEntries = performance.isMobile ? 1 : 2;
  const hudBuffEntries = activeBuffEntries.slice(0, maxHudBuffEntries);
  const hiddenHudBuffCount = Math.max(0, activeBuffEntries.length - hudBuffEntries.length);
  const toolbarButtons = [
    { key: 'character' },
    { key: 'inventory', panel: 'inventory', count: inventory.length > 0 ? inventory.length : null },
    { key: 'quests', count: activeQuestCount > 0 ? activeQuestCount : null },
    { key: 'npcs', count: npcs.length > 0 ? npcs.length : null },
  ] as const;
  useEffect(() => {
    setConsumablePage((currentPage) => Math.min(currentPage, consumablePageCount - 1));
  }, [consumablePageCount]);

  useEffect(() => {
    setGestureSkillPage((currentPage) => Math.min(currentPage, gestureSkillPageCount - 1));
  }, [gestureSkillPageCount]);

  const emitSkillCast = useEffectEvent((skillId: ManualSkillCastCommand['skillId']) => {
    requestManualSkillCast(skillId);
  });
  const handleNpcInteraction = (npc: NpcDefinition) => {
    setActiveNpc(npc.id);
    const linkedQuestId = buildNpcLinkedQuestIds(npc)[0] ?? null;
    if (linkedQuestId) {
      setSelectedQuestId(linkedQuestId);
    }
    setOverlayPanel('npcs');
  };
  const handlePortalInteraction = () => {
    if (!activePortal) {
      return;
    }
    if (!portalUnlocked) {
      setLastCombatLog('포탈이 봉인되어 있습니다. 스테이지 보스를 먼저 처치하세요.');
      return;
    }
    advanceStage();
  };

  const syncKeyboardJoystick = useEffectEvent((pressedMovementKeys: Set<string>) => {
    if (overlayPanel) {
      setJoystick({ active: false, x: 0, y: 0 });
      return;
    }

    const hasLeft = pressedMovementKeys.has('left');
    const hasRight = pressedMovementKeys.has('right');
    const hasUp = pressedMovementKeys.has('up');
    const hasDown = pressedMovementKeys.has('down');
    const x = (hasRight ? 1 : 0) - (hasLeft ? 1 : 0);
    const y = (hasDown ? 1 : 0) - (hasUp ? 1 : 0);
    const length = Math.hypot(x, y);

    if (length <= 0) {
      setJoystick({ active: false, x: 0, y: 0 });
      return;
    }

    setJoystick({
      active: true,
      x: x / length,
      y: y / length,
    });
  });

  const resetKeyboardJoystick = useEffectEvent((pressedMovementKeys: Set<string>) => {
    pressedMovementKeys.clear();
    setJoystick({ active: false, x: 0, y: 0 });
  });

  const triggerKeyboardPrimaryAction = useEffectEvent(() => {
    if (shouldPrioritizeCombat) {
      emitSkillCast(PRIMARY_ACTION_SKILL);
      return;
    }

    if (activePortal) {
      handlePortalInteraction();
      return;
    }

    if (activeNpc) {
      handleNpcInteraction(activeNpc);
      return;
    }

    emitSkillCast(PRIMARY_ACTION_SKILL);
  });

  const handleKeyboardVisibilityChange = useEffectEvent((pressedMovementKeys: Set<string>) => {
    if (document.visibilityState !== 'visible') {
      resetKeyboardJoystick(pressedMovementKeys);
    }
  });

  const handleKeyboardWindowBlur = useEffectEvent((pressedMovementKeys: Set<string>) => {
    resetKeyboardJoystick(pressedMovementKeys);
  });

  const handleKeyboardKeyDown = useEffectEvent((event: KeyboardEvent, pressedMovementKeys: Set<string>) => {
    const target = event.target;
    if (target instanceof HTMLElement) {
      const isEditable =
        target.isContentEditable || target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT';
      if (isEditable) {
        return;
      }
    }

    const movementDirection = MOVEMENT_KEY_ALIASES[event.code as keyof typeof MOVEMENT_KEY_ALIASES];
    if (movementDirection) {
      event.preventDefault();
      pressedMovementKeys.add(movementDirection);
      syncKeyboardJoystick(pressedMovementKeys);
      return;
    }

    if (event.code === 'Escape') {
      if (overlayPanel) {
        event.preventDefault();
        setOverlayPanel(null);
      }
      return;
    }

    const panel = PANEL_HOTKEYS[event.code as keyof typeof PANEL_HOTKEYS];
    if (panel && !event.repeat) {
      event.preventDefault();
      setOverlayPanel(overlayPanel === panel ? null : panel);
      return;
    }

    if (event.code === 'KeyB' && !event.repeat) {
      event.preventDefault();
      setAutoCombat(!autoCombat);
      return;
    }

    if (overlayPanel) {
      return;
    }

    if ((event.code === 'Space' || event.code === 'Enter') && !event.repeat) {
      event.preventDefault();
      triggerKeyboardPrimaryAction();
      return;
    }

    const skillHotkeyIndex = SKILL_HOTKEY_ORDER.indexOf(event.code as (typeof SKILL_HOTKEY_ORDER)[number]);
    if (skillHotkeyIndex >= 0 && !event.repeat) {
      event.preventDefault();
      const hotkeySkill = visibleGestureSkills[skillHotkeyIndex];
      if (hotkeySkill) {
        emitSkillCast(hotkeySkill.id);
      }
      return;
    }

    if ((event.code === 'KeyZ' || event.code === 'KeyX' || event.code === 'KeyC') && !event.repeat) {
      event.preventDefault();
      const hotkeyIndex = event.code === 'KeyZ' ? 0 : event.code === 'KeyX' ? 1 : 2;
      const consumable = quickConsumables[hotkeyIndex];
      if (consumable?.quantity && consumable.quantity > 0) {
        useConsumable(consumable.id);
      }
    }
  });

  const handleKeyboardKeyUp = useEffectEvent((event: KeyboardEvent, pressedMovementKeys: Set<string>) => {
    const movementDirection = MOVEMENT_KEY_ALIASES[event.code as keyof typeof MOVEMENT_KEY_ALIASES];
    if (!movementDirection) {
      return;
    }

    event.preventDefault();
    pressedMovementKeys.delete(movementDirection);
    syncKeyboardJoystick(pressedMovementKeys);
  });

  useEffect(() => {
    const pressedMovementKeys = pressedMovementKeysRef.current;
    const handleVisibilityChange = () => handleKeyboardVisibilityChange(pressedMovementKeys);
    const handleWindowBlur = () => handleKeyboardWindowBlur(pressedMovementKeys);
    const handleKeyDown = (event: KeyboardEvent) => handleKeyboardKeyDown(event, pressedMovementKeys);
    const handleKeyUp = (event: KeyboardEvent) => handleKeyboardKeyUp(event, pressedMovementKeys);

    window.addEventListener('keydown', handleKeyDown);
    window.addEventListener('keyup', handleKeyUp);
    window.addEventListener('blur', handleWindowBlur);
    document.addEventListener('visibilitychange', handleVisibilityChange);

    return () => {
      window.removeEventListener('keydown', handleKeyDown);
      window.removeEventListener('keyup', handleKeyUp);
      window.removeEventListener('blur', handleWindowBlur);
      document.removeEventListener('visibilitychange', handleVisibilityChange);
      resetKeyboardJoystick(pressedMovementKeys);
    };
  }, []);

  return (
    <section
      className={`the-quest${performance.isMobile ? ' the-quest--mobile-optimized' : ''}${performance.lowEffects ? ' the-quest--low-effects' : ''}`}
      data-testid="apps-library-open-the-quest"
    >
      <div className="the-quest__shell">
        <div className="the-quest__stage">
          <div ref={mountRef} className="the-quest__game-surface" />
          {!isReady ? (
            <div className={`the-quest__loading${initError ? ' the-quest__loading--error' : ''}`}>
              <div className="the-quest__loading-card">
                <strong>{initError ? 'The Quest 실행에 실패했습니다.' : '모험 지도를 불러오는 중...'}</strong>
                <p>
                  {initError
                    ? '렌더러 초기화에 실패해 계속 멈춘 것처럼 보이지 않도록 즉시 실패 상태를 표시합니다. 새로고침 후 다시 열어 주세요.'
                    : '전투 맵과 조작 HUD를 초기화하고 있습니다.'}
                </p>
              </div>
            </div>
          ) : null}
          <div className="the-quest__stage-vignette" />
          <div className="the-quest__stage-glow the-quest__stage-glow--left" />
          <div className="the-quest__stage-glow the-quest__stage-glow--right" />

          <div className="the-quest__hud">
            <div className="the-quest__hud-table-wrap">
              <table className="the-quest__hud-table" aria-label="캐릭터 HUD">
                <tbody>
                  <tr>
                    <td colSpan={4} className="the-quest__hud-toolbar-cell">
                      <div className="the-quest__hud-toolbar" role="toolbar" aria-label="퀘스트 상단 메뉴">
                        <button
                          type="button"
                          className="the-quest__hud-toolbar-button the-quest__hud-toolbar-button--menu"
                          onClick={onBack}
                          aria-label="앱 목록으로 돌아가기"
                          title="앱 목록"
                        >
                          <span className="the-quest__hud-toolbar-icon" aria-hidden="true">
                            <MenuOutlined />
                          </span>
                        </button>
                        {isEmbeddedLaunch ? (
                          <button
                            type="button"
                            className="the-quest__hud-toolbar-button the-quest__hud-toolbar-button--exit"
                            onClick={onBack}
                            aria-label="부모 앱으로 종료"
                            title="부모 앱 종료"
                          >
                            <span className="the-quest__hud-toolbar-icon" aria-hidden="true">
                              <CloseOutlined />
                            </span>
                          </button>
                        ) : null}
                        <div className="the-quest__hud-toolbar-group">
                          {toolbarButtons.map(({ key, panel, count }) => {
                            const meta = QUICK_ACTION_META[key];
                            const targetPanel = panel ?? key;
                            const isActive =
                              targetPanel === 'inventory'
                                ? overlayPanel === 'inventory' || overlayPanel === 'equipment'
                                : overlayPanel === targetPanel;

                            return (
                              <button
                                key={key}
                                type="button"
                                className={`the-quest__hud-toolbar-button${isActive ? ' the-quest__hud-toolbar-button--active' : ''}`}
                                onClick={() => setOverlayPanel(targetPanel)}
                                aria-label={`${meta.title} 열기`}
                                title={meta.title}
                              >
                                <span className="the-quest__hud-toolbar-icon" aria-hidden="true">
                                  {meta.icon}
                                </span>
                                <span className="the-quest__hud-toolbar-label">{meta.label}</span>
                                {count ? <span className="the-quest__hud-toolbar-badge">{count}</span> : null}
                              </button>
                            );
                          })}
                        </div>
                      </div>
                    </td>
                  </tr>
                  <tr>
                    <th scope="row" className="the-quest__hud-stack-label">
                      HP
                    </th>
                    <td className="the-quest__hud-gauge-cell the-quest__hud-gauge-cell--stack">
                      <div className="the-quest__bar the-quest__bar--compact the-quest__hud-gauge" aria-label={`HP ${formatRatio(vitals.hp, vitals.maxHp)}`}>
                        <div className="the-quest__bar-fill the-quest__bar-fill--hp" style={{ width: `${hpGaugePercent}%` }} />
                        <span>{formatRatio(vitals.hp, vitals.maxHp)}</span>
                      </div>
                    </td>
                    <th scope="row" className="the-quest__hud-stack-label">
                      MP
                    </th>
                    <td className="the-quest__hud-gauge-cell the-quest__hud-gauge-cell--stack">
                      <div className="the-quest__bar the-quest__bar--compact the-quest__hud-gauge" aria-label={`MP ${formatRatio(vitals.mp, vitals.maxMp)}`}>
                        <div className="the-quest__bar-fill the-quest__bar-fill--mp" style={{ width: `${mpGaugePercent}%` }} />
                        <span>{formatRatio(vitals.mp, vitals.maxMp)}</span>
                      </div>
                    </td>
                  </tr>
                  <tr>
                    <th scope="row">LV</th>
                    <td>{vitals.level}</td>
                    <th scope="row">STAGE</th>
                    <td>
                      {stage.current}
                      {portalUnlocked ? ' / 포탈 개방' : ''}
                    </td>
                  </tr>
                  <tr>
                    <th scope="row">EXP</th>
                    <td>
                      {vitals.level} / EXP {vitals.exp}/{vitals.expToNext}
                    </td>
                    <th scope="row">BUFF</th>
                    <td className="the-quest__hud-buff-cell">
                      {hudBuffEntries.length ? (
                        <div className="the-quest__hud-buff-list" aria-label="활성 버프 상태">
                          {hudBuffEntries.map((entry) => (
                            <div key={entry.id} className="the-quest__hud-buff-pill">
                              <strong>{entry.label}</strong>
                              <span>{entry.detail}</span>
                              <em>{formatSecondsLabel(entry.remainingMs)}</em>
                            </div>
                          ))}
                          {hiddenHudBuffCount > 0 ? <span className="the-quest__hud-buff-more">+{hiddenHudBuffCount}</span> : null}
                        </div>
                      ) : (
                        <span className="the-quest__hud-buff-empty">활성 효과 없음</span>
                      )}
                    </td>
                  </tr>
                </tbody>
              </table>
            </div>
          </div>

          <div className="the-quest__bottom-ui">
            <div className="the-quest__left-controls">
              <div className="the-quest__movement-dock">
                <div className="the-quest__dock-head">
                  <span>MOVE</span>
                  <small className="the-quest__dock-hotkey">WASD / 방향키</small>
                  <div className="the-quest__auto-toggle">
                    <span className="the-quest__auto-toggle-icon" aria-hidden="true">
                      <ThunderboltOutlined />
                    </span>
                    <small>AUTO</small>
                    <Switch size="small" checked={autoCombat} onChange={setAutoCombat} />
                  </div>
                </div>
                <TheQuestJoystick />
              </div>
            </div>

            <div className="the-quest__right-controls">
              <div className="the-quest__consumable-strip" role="group" aria-label="전투 소모품">
                <div className="the-quest__consumable-strip-rail">
                  <button
                    type="button"
                    className="the-quest__consumable-nav"
                    onClick={() => setConsumablePage((page) => Math.max(0, page - 1))}
                    disabled={consumablePage <= 0}
                    aria-label="이전 소모품 페이지"
                    title="이전 소모품"
                  >
                    <LeftOutlined />
                  </button>
                  <div className="the-quest__consumable-strip-list">
                    {visibleConsumableSlots.map((item, index) => {
                      if (!item) {
                        return (
                          <div
                            key={`empty-slot-${index}`}
                            className="the-quest__consumable-slot the-quest__consumable-slot--empty"
                            aria-hidden="true"
                          />
                        );
                      }

                      const quantity = item.quantity ?? 0;
                      const variantClass =
                        item.id === 'hp-potion' ? 'hp' : item.id === 'auto-heal-elixir' ? 'special' : 'speed';

                      return (
                        <button
                          key={item.id}
                          type="button"
                          className={`the-quest__consumable-slot the-quest__consumable-slot--${variantClass}`}
                          onClick={() => useConsumable(item.id)}
                          disabled={quantity <= 0}
                          aria-label={`${item.name} 사용`}
                          title={item.name}
                        >
                          <span className="the-quest__consumable-slot-icon" aria-hidden="true">
                            {item.icon}
                          </span>
                          <em>{quantity}</em>
                        </button>
                      );
                    })}
                  </div>
                  <button
                    type="button"
                    className="the-quest__consumable-nav"
                    onClick={() => setConsumablePage((page) => Math.min(consumablePageCount - 1, page + 1))}
                    disabled={consumablePage >= consumablePageCount - 1}
                    aria-label="다음 소모품 페이지"
                    title="다음 소모품"
                  >
                    <RightOutlined />
                  </button>
                </div>
              </div>
              <div className="the-quest__combat-stack">
                <div className="the-quest__dock-head">
                  <span>ARCANE / SPELL</span>
                  <small className="the-quest__dock-hotkey">Space·Enter / 1-8 / ZXC / B</small>
                  <div className="the-quest__page-nav" role="group" aria-label="스킬 페이지 전환">
                    <button
                      type="button"
                      className="the-quest__page-nav-button"
                      onClick={() => setGestureSkillPage((page) => Math.max(0, page - 1))}
                      disabled={gestureSkillPage <= 0}
                      aria-label="이전 스킬 페이지"
                      title="이전 스킬"
                    >
                      <LeftOutlined />
                    </button>
                    <strong className="the-quest__page-nav-label">
                      SKILL {gestureSkillPage + 1}/{gestureSkillPageCount}
                    </strong>
                    <button
                      type="button"
                      className="the-quest__page-nav-button"
                      onClick={() => setGestureSkillPage((page) => Math.min(gestureSkillPageCount - 1, page + 1))}
                      disabled={gestureSkillPage >= gestureSkillPageCount - 1}
                      aria-label="다음 스킬 페이지"
                      title="다음 스킬"
                    >
                      <RightOutlined />
                    </button>
                  </div>
                </div>
                <div className="the-quest__combat-cluster">
                  <TheQuestGesturePad
                    now={now}
                    skills={skills}
                    gesturePage={gestureSkillPage}
                    skillCooldowns={skillCooldowns}
                    consumableEffects={consumableEffects}
                    emitSkillCast={emitSkillCast}
                    activeNpc={activeNpc}
                    activePortal={activePortal}
                    prioritizeCombat={shouldPrioritizeCombat}
                    portalUnlocked={portalUnlocked}
                    onInteractNpc={handleNpcInteraction}
                    onInteractPortal={handlePortalInteraction}
                  />
                </div>
              </div>
            </div>
          </div>
        </div>
      </div>

      {createPortal(<TheQuestPanel onBack={onBack} />, document.body)}
    </section>
  );
}
