#!/usr/bin/env python3
from __future__ import annotations

import argparse
import json
import shutil
import subprocess
import time
from pathlib import Path
from urllib.parse import urlencode, urljoin

from verify_mobile_browser_controls import (
    WebDriverError,
    execute_js,
    free_port,
    request_json,
    wait_for_driver,
    wait_for_map_runtime,
    wait_for_page,
)

ROOT = Path(__file__).resolve().parents[1]


def load_map(base: str, port: int, session_id: str, params: dict[str, str]) -> str:
    query = urlencode({**params, "_": str(time.time_ns())})
    url = urljoin(base.rstrip("/") + "/", f"/web/game.html?{query}")
    request_json(port, "POST", f"/session/{session_id}/url", {"url": url}, timeout=30)
    wait_for_page(port, session_id)
    wait_for_map_runtime(port, session_id, params["map"])
    return url


def title_state_script() -> str:
    return """
const titleMenu = typeof titleMenuItems === 'function' ? titleMenuItems() : [];
return {
  readyState: document.readyState,
  href: location.href,
  search: location.search,
  scene: typeof scene === 'undefined' ? '' : scene,
  quickLoadHidden: document.getElementById('quickLoadButton')?.hidden ?? null,
  quickLoadText: document.getElementById('quickLoadButton')?.textContent || '',
  titleMenuKeys: titleMenu.map((item) => item.key),
  titleMenuLabels: titleMenu.map((item) => item.label),
};
"""


def click_title_continue_script() -> str:
    return """
if (typeof updateRuntimeSaveControls === 'function') updateRuntimeSaveControls();
const items = typeof titleMenuItems === 'function' ? titleMenuItems() : [];
const button = document.getElementById('quickLoadButton');
if (!button || button.hidden || button.textContent !== '이어하기' || !items.some((item) => item.key === 'continue')) {
  return {
    ok: false,
    hidden: button?.hidden ?? null,
    text: button?.textContent || '',
    titleMenuKeys: items.map((item) => item.key),
    titleMenuLabels: items.map((item) => item.label),
  };
}
const index = items.findIndex((item) => item.key === 'continue');
selectedTitleMenuIndex = index;
activateSelectedTitleMenuItem();
return {
  ok: index >= 0,
  text: button.textContent,
  titleMenuKeys: items.map((item) => item.key),
  titleMenuLabels: items.map((item) => item.label),
  selectedTitleMenuIndex,
};
"""


def skill_menu_start_script() -> str:
    return """
window.__hwanseSkillMenuStart = null;
const resetMenuReviewFeedback = () => {
  window.HWANSE_MENU_REVIEW_FEEDBACK_LOG = [];
  window.HWANSE_MENU_REVIEW_FEEDBACK_RENDER = [];
  window.HWANSE_LAST_MENU_REVIEW_FEEDBACK = null;
  window.HWANSE_LAST_MENU_REVIEW_FEEDBACK_RENDER = null;
  if (typeof activeMenuReviewFeedbacks !== 'undefined') activeMenuReviewFeedbacks = [];
};
const captureMenuReviewFeedback = () => ({
  log: Array.isArray(window.HWANSE_MENU_REVIEW_FEEDBACK_LOG)
    ? [...window.HWANSE_MENU_REVIEW_FEEDBACK_LOG]
    : [],
  render: Array.isArray(window.HWANSE_MENU_REVIEW_FEEDBACK_RENDER)
    ? [...window.HWANSE_MENU_REVIEW_FEEDBACK_RENDER]
    : [],
  last: window.HWANSE_LAST_MENU_REVIEW_FEEDBACK || null,
  lastRender: window.HWANSE_LAST_MENU_REVIEW_FEEDBACK_RENDER || null,
});
const finish = () => {
  const items = menuItems();
  const index = items.findIndex((item) => item.command === 'showSkillMenu');
  const labels = items.map((item) => menuItemLabel(item));
  if (index < 0) {
    window.__hwanseSkillMenuStart = { ok: false, reason: 'missing skill menu command', labels };
    return;
  }
  selectedMenuItemIndex = index;
  resetMenuReviewFeedback();
  const commandResult = useSelectedMenuItem();
  window.setTimeout(() => {
    const activeId = activeDialogue?.block?.blockId || '';
    const lines = activeDialogue?.lines || [];
    const completion = publishPrototypeCompletionState();
    if (typeof render === 'function') render();
    const menuReviewFeedback = captureMenuReviewFeedback();
    resetMenuReviewFeedback();
    window.HWANSE_LAST_OBJECTIVE_ACTION = null;
    window.HWANSE_LAST_MENU_REVIEW_COMPLETION_NOTICE = null;
    const objectiveBefore = prototypeObjectiveState();
    const objectiveResult = activatePrototypeObjectiveAction();
    const objectiveAction = window.HWANSE_LAST_OBJECTIVE_ACTION || null;
    const objectiveCompletionNotice = window.HWANSE_LAST_MENU_REVIEW_COMPLETION_NOTICE || null;
    const objectiveActiveDialogueBlock = activeDialogue?.block || null;
    if (typeof render === 'function') render();
    const objectiveMenuReviewFeedback = captureMenuReviewFeedback();
    window.__hwanseSkillMenuStart = {
      ok: true,
      index,
      labels,
      commandResult,
      notice: menuNotice,
      activeId,
      lines,
      skillMenu: window.HWANSE_LAST_SKILL_MENU || null,
      skillProgress: window.HWANSE_LAST_SKILL_MENU_PROGRESS || null,
      progress: window.HWANSE_LAST_PROTOTYPE_PROGRESS || null,
      completion,
      objectiveBefore,
      objectiveResult,
      objectiveAction,
      objectiveCompletionNotice,
      objectiveActiveDialogueBlock,
      menuReviewFeedbackLog: menuReviewFeedback.log,
      menuReviewFeedbackRender: menuReviewFeedback.render,
      menuReviewFeedbackLast: menuReviewFeedback.last,
      menuReviewFeedbackLastRender: menuReviewFeedback.lastRender,
      objectiveMenuReviewFeedbackLog: objectiveMenuReviewFeedback.log,
      objectiveMenuReviewFeedbackRender: objectiveMenuReviewFeedback.render,
      objectiveMenuReviewFeedbackLast: objectiveMenuReviewFeedback.last,
      objectiveMenuReviewFeedbackLastRender: objectiveMenuReviewFeedback.lastRender,
      runtimeCharacters: (runtimeState?.characters || []).map((character) => ({
        name: character.name,
        level: character.level,
        learned: (character.skillSummary?.individual?.learned || 0) + (character.skillSummary?.group?.learned || 0),
        total: (character.skillSummary?.individual?.total || 0) + (character.skillSummary?.group?.total || 0),
      })),
    };
  }, 0);
};
if (runtimeState && loadedSaveSummary) {
  finish();
} else {
  window.setTimeout(() => {
    if (runtimeState && loadedSaveSummary) finish();
    else window.__hwanseSkillMenuStart = {
      ok: false,
      reason: 'savedat not loaded',
      labels: menuItems().map((item) => menuItemLabel(item)),
      loadedSaveSummary: loadedSaveSummary || null,
      runtimeState: runtimeState || null,
    };
  }, 300);
}
return true;
"""


def skill_menu_state_script() -> str:
    return "return window.__hwanseSkillMenuStart || null;"


def skill_member_menu_start_script() -> str:
    return """
window.__hwanseSkillMemberMenu = null;
const resetMenuReviewFeedback = () => {
  window.HWANSE_MENU_REVIEW_FEEDBACK_LOG = [];
  window.HWANSE_MENU_REVIEW_FEEDBACK_RENDER = [];
  window.HWANSE_LAST_MENU_REVIEW_FEEDBACK = null;
  window.HWANSE_LAST_MENU_REVIEW_FEEDBACK_RENDER = null;
  if (typeof activeMenuReviewFeedbacks !== 'undefined') activeMenuReviewFeedbacks = [];
};
const captureMenuReviewFeedback = () => ({
  log: Array.isArray(window.HWANSE_MENU_REVIEW_FEEDBACK_LOG)
    ? [...window.HWANSE_MENU_REVIEW_FEEDBACK_LOG]
    : [],
  render: Array.isArray(window.HWANSE_MENU_REVIEW_FEEDBACK_RENDER)
    ? [...window.HWANSE_MENU_REVIEW_FEEDBACK_RENDER]
    : [],
  last: window.HWANSE_LAST_MENU_REVIEW_FEEDBACK || null,
  lastRender: window.HWANSE_LAST_MENU_REVIEW_FEEDBACK_RENDER || null,
});
const fail = (reason, extra = {}) => {
  window.__hwanseSkillMemberMenu = {
    ok: false,
    reason,
    labels: menuItems().map((item) => menuItemLabel(item)),
    menuMode,
    map: map?.name || '',
    loadedSaveSummary: loadedSaveSummary || null,
    runtimeState: runtimeState || null,
    ...extra,
  };
};
const finish = () => {
  activeDialogue = null;
  menuMode = 'main';
  menuOpen = true;
  selectedMenuItemIndex = 0;
  prototypeProgress = createPrototypeProgressState();
  publishPrototypeProgress();
  window.HWANSE_LAST_SKILL_MEMBER_SELECT_MENU = null;
  window.HWANSE_LAST_SKILL_MEMBER_MENU_SELECTION = null;
  const beforeItems = menuItems();
  const commandIndex = beforeItems.findIndex((item) => item.command === 'openSkillMemberMenu');
  const beforeLabels = beforeItems.map((item) => menuItemLabel(item));
  if (commandIndex < 0) {
    fail('missing skill member menu command', { beforeLabels });
    return;
  }
  selectedMenuItemIndex = commandIndex;
  const commandResult = useSelectedMenuItem();
  const afterOpenMenuMode = menuMode;
  const afterOpenMarker = window.HWANSE_LAST_SKILL_MEMBER_SELECT_MENU || null;
  const afterOpenItems = menuItems();
  const afterOpenLabels = afterOpenItems.map((item) => menuItemLabel(item));
  const selectIndex = afterOpenItems.findIndex(
    (item) => item.command === 'selectSkillMember' && item.memberName === 'Ataho'
  );
  if (selectIndex < 0) {
    fail('missing Ataho skill member menu item', {
      beforeLabels,
      commandResult,
      afterOpenMenuMode,
      afterOpenLabels,
      afterOpenMarker,
    });
    return;
  }
  selectedMenuItemIndex = selectIndex;
  resetMenuReviewFeedback();
  const selectResult = useSelectedMenuItem();
  window.setTimeout(() => {
    if (typeof render === 'function') render();
    const activeId = activeDialogue?.block?.blockId || '';
    const lines = activeDialogue?.lines || [];
    const selection = window.HWANSE_LAST_SKILL_MEMBER_MENU_SELECTION || null;
    const progress = window.HWANSE_LAST_PROTOTYPE_PROGRESS || null;
    const menuReviewFeedback = captureMenuReviewFeedback();
    const completion = publishPrototypeCompletionState();
    resetMenuReviewFeedback();
    window.HWANSE_LAST_OBJECTIVE_ACTION = null;
    window.HWANSE_LAST_MENU_REVIEW_COMPLETION_NOTICE = null;
    activeDialogue = null;
    const objectiveBefore = prototypeObjectiveState();
    const objectiveResult = activatePrototypeObjectiveAction();
    const objectiveAction = window.HWANSE_LAST_OBJECTIVE_ACTION || null;
    const objectiveCompletionNotice = window.HWANSE_LAST_MENU_REVIEW_COMPLETION_NOTICE || null;
    const objectiveActiveDialogueBlock = activeDialogue?.block || null;
    if (typeof render === 'function') render();
    const objectiveMenuReviewFeedback = captureMenuReviewFeedback();
    window.__hwanseSkillMemberMenu = {
      ok: true,
      beforeLabels,
      commandIndex,
      commandResult,
      afterOpenMenuMode,
      afterOpenLabels,
      afterOpenMarker,
      selectIndex,
      selectResult,
      afterMenuMode: menuMode,
      activeId,
      lines,
      selection,
      progress,
      completion,
      objectiveBefore,
      objectiveResult,
      objectiveAction,
      objectiveCompletionNotice,
      objectiveActiveDialogueBlock,
      menuReviewFeedbackLog: menuReviewFeedback.log,
      menuReviewFeedbackRender: menuReviewFeedback.render,
      menuReviewFeedbackLast: menuReviewFeedback.last,
      menuReviewFeedbackLastRender: menuReviewFeedback.lastRender,
      objectiveMenuReviewFeedbackLog: objectiveMenuReviewFeedback.log,
      objectiveMenuReviewFeedbackRender: objectiveMenuReviewFeedback.render,
      objectiveMenuReviewFeedbackLast: objectiveMenuReviewFeedback.last,
      objectiveMenuReviewFeedbackLastRender: objectiveMenuReviewFeedback.lastRender,
    };
  }, 0);
};
if (runtimeState && loadedSaveSummary) {
  finish();
} else {
  window.setTimeout(() => {
    if (runtimeState && loadedSaveSummary) finish();
    else fail('savedat not loaded');
  }, 300);
}
return true;
"""


def skill_member_menu_state_script() -> str:
    return "return window.__hwanseSkillMemberMenu || null;"


def new_game_skill_menu_start_script() -> str:
    return """
window.__hwanseNewGameSkillMenu = null;
const resetMenuReviewFeedback = () => {
  window.HWANSE_MENU_REVIEW_FEEDBACK_LOG = [];
  window.HWANSE_MENU_REVIEW_FEEDBACK_RENDER = [];
  window.HWANSE_LAST_MENU_REVIEW_FEEDBACK = null;
  window.HWANSE_LAST_MENU_REVIEW_FEEDBACK_RENDER = null;
  if (typeof activeMenuReviewFeedbacks !== 'undefined') activeMenuReviewFeedbacks = [];
};
const captureMenuReviewFeedback = () => ({
  log: Array.isArray(window.HWANSE_MENU_REVIEW_FEEDBACK_LOG)
    ? [...window.HWANSE_MENU_REVIEW_FEEDBACK_LOG]
    : [],
  render: Array.isArray(window.HWANSE_MENU_REVIEW_FEEDBACK_RENDER)
    ? [...window.HWANSE_MENU_REVIEW_FEEDBACK_RENDER]
    : [],
  last: window.HWANSE_LAST_MENU_REVIEW_FEEDBACK || null,
  lastRender: window.HWANSE_LAST_MENU_REVIEW_FEEDBACK_RENDER || null,
});
const fail = (reason, extra = {}) => {
  window.__hwanseNewGameSkillMenu = {
    ok: false,
    reason,
    labels: menuItems().map((item) => menuItemLabel(item)),
    map: map?.name || '',
    runtimeState: runtimeState || null,
    loadedSaveSummary: loadedSaveSummary || null,
    ...extra,
  };
};
if (!loadedSaveSummary && typeof ensurePrototypeRuntimeState === 'function') ensurePrototypeRuntimeState();
if (!runtimeState || loadedSaveSummary) {
  fail('expected prototype runtime without loaded savedat');
  return true;
}
menuMode = 'main';
menuOpen = true;
const items = menuItems();
const index = items.findIndex((item) => item.command === 'showSkillMenu');
const labels = items.map((item) => menuItemLabel(item));
if (index < 0) {
  fail('missing new-game skill menu command', { labels });
  return true;
}
selectedMenuItemIndex = index;
resetMenuReviewFeedback();
const commandResult = useSelectedMenuItem();
window.setTimeout(() => {
  const activeId = activeDialogue?.block?.blockId || '';
  const lines = activeDialogue?.lines || [];
  const completion = publishPrototypeCompletionState();
  if (typeof render === 'function') render();
  const menuReviewFeedback = captureMenuReviewFeedback();
  resetMenuReviewFeedback();
  window.HWANSE_LAST_OBJECTIVE_ACTION = null;
  window.HWANSE_LAST_MENU_REVIEW_COMPLETION_NOTICE = null;
  const objectiveBefore = prototypeObjectiveState();
  const objectiveResult = activatePrototypeObjectiveAction();
  const objectiveAction = window.HWANSE_LAST_OBJECTIVE_ACTION || null;
  const objectiveCompletionNotice = window.HWANSE_LAST_MENU_REVIEW_COMPLETION_NOTICE || null;
  const objectiveActiveDialogueBlock = activeDialogue?.block || null;
  if (typeof render === 'function') render();
  const objectiveMenuReviewFeedback = captureMenuReviewFeedback();
  window.__hwanseNewGameSkillMenu = {
    ok: true,
    index,
    labels,
    commandResult,
    notice: menuNotice,
    activeId,
    lines,
    skillMenu: window.HWANSE_LAST_SKILL_MENU || null,
    skillProgress: window.HWANSE_LAST_SKILL_MENU_PROGRESS || null,
    progress: window.HWANSE_LAST_PROTOTYPE_PROGRESS || null,
    completion,
    objectiveBefore,
    objectiveResult,
    objectiveAction,
    objectiveCompletionNotice,
    objectiveActiveDialogueBlock,
    menuReviewFeedbackLog: menuReviewFeedback.log,
    menuReviewFeedbackRender: menuReviewFeedback.render,
    menuReviewFeedbackLast: menuReviewFeedback.last,
    menuReviewFeedbackLastRender: menuReviewFeedback.lastRender,
    objectiveMenuReviewFeedbackLog: objectiveMenuReviewFeedback.log,
    objectiveMenuReviewFeedbackRender: objectiveMenuReviewFeedback.render,
    objectiveMenuReviewFeedbackLast: objectiveMenuReviewFeedback.last,
    objectiveMenuReviewFeedbackLastRender: objectiveMenuReviewFeedback.lastRender,
    loadedSaveSummary: loadedSaveSummary || null,
    runtimeCharacters: (runtimeState?.characters || []).map((character) => ({
      name: character.name,
      level: character.level,
      learned: (character.skillSummary?.individual?.learned || 0) + (character.skillSummary?.group?.learned || 0),
      total: (character.skillSummary?.individual?.total || 0) + (character.skillSummary?.group?.total || 0),
    })),
  };
}, 0);
return true;
"""


def new_game_skill_menu_state_script() -> str:
    return "return window.__hwanseNewGameSkillMenu || null;"


def capture_skill_title_continue_script() -> str:
    return """
window.__hwanseSkillMenuTitleContinue = null;
const resetMenuReviewFeedback = () => {
  window.HWANSE_MENU_REVIEW_FEEDBACK_LOG = [];
  window.HWANSE_MENU_REVIEW_FEEDBACK_RENDER = [];
  window.HWANSE_LAST_MENU_REVIEW_FEEDBACK = null;
  window.HWANSE_LAST_MENU_REVIEW_FEEDBACK_RENDER = null;
  if (typeof activeMenuReviewFeedbacks !== 'undefined') activeMenuReviewFeedbacks = [];
};
const captureMenuReviewFeedback = () => ({
  log: Array.isArray(window.HWANSE_MENU_REVIEW_FEEDBACK_LOG)
    ? [...window.HWANSE_MENU_REVIEW_FEEDBACK_LOG]
    : [],
  render: Array.isArray(window.HWANSE_MENU_REVIEW_FEEDBACK_RENDER)
    ? [...window.HWANSE_MENU_REVIEW_FEEDBACK_RENDER]
    : [],
  last: window.HWANSE_LAST_MENU_REVIEW_FEEDBACK || null,
  lastRender: window.HWANSE_LAST_MENU_REVIEW_FEEDBACK_RENDER || null,
});
Promise.resolve()
  .then(() => {
    const completion = publishPrototypeCompletionState();
    resetMenuReviewFeedback();
    window.HWANSE_LAST_OBJECTIVE_ACTION = null;
    window.HWANSE_LAST_MENU_REVIEW_COMPLETION_NOTICE = null;
    const objectiveBefore = prototypeObjectiveState();
    const objectiveResult = activatePrototypeObjectiveAction();
    const objectiveAction = window.HWANSE_LAST_OBJECTIVE_ACTION || null;
    const objectiveCompletionNotice = window.HWANSE_LAST_MENU_REVIEW_COMPLETION_NOTICE || null;
    const objectiveActiveDialogueBlock = activeDialogue?.block || null;
    if (typeof render === 'function') render();
    const objectiveMenuReviewFeedback = captureMenuReviewFeedback();
    window.__hwanseSkillMenuTitleContinue = {
      titleContinue: true,
      scene,
      map: map?.name || '',
      search: location.search,
      progress: window.HWANSE_LAST_PROTOTYPE_PROGRESS || null,
      completion,
      objectiveBefore,
      objectiveResult,
      objectiveAction,
      objectiveCompletionNotice,
      objectiveActiveDialogueBlock,
      objectiveMenuReviewFeedbackLog: objectiveMenuReviewFeedback.log,
      objectiveMenuReviewFeedbackRender: objectiveMenuReviewFeedback.render,
      objectiveMenuReviewFeedbackLast: objectiveMenuReviewFeedback.last,
      objectiveMenuReviewFeedbackLastRender: objectiveMenuReviewFeedback.lastRender,
      quickLoadHidden: document.getElementById('quickLoadButton')?.hidden ?? null,
      quickLoadText: document.getElementById('quickLoadButton')?.textContent || '',
      loadedSaveSummary: loadedSaveSummary || null,
    };
  })
  .catch((error) => {
    window.__hwanseSkillMenuTitleContinue = { error: String(error && error.message || error) };
  });
return true;
"""


def skill_title_continue_state_script() -> str:
    return "return window.__hwanseSkillMenuTitleContinue || null;"


def wait_for_skill_menu_state(port: int, session_id: str, timeout: float = 8) -> dict:
    deadline = time.monotonic() + timeout
    state: dict = {}
    while time.monotonic() < deadline:
        value = execute_js(port, session_id, skill_menu_state_script(), timeout=3)
        state = value if isinstance(value, dict) else {}
        if state and state.get("ok") is True and state.get("activeId", "").startswith("skill-menu:"):
            return state
        time.sleep(0.2)
    raise WebDriverError(f"candidate skill menu did not become ready: {state!r}")


def wait_for_skill_member_menu_state(port: int, session_id: str, timeout: float = 8) -> dict:
    deadline = time.monotonic() + timeout
    state: dict = {}
    while time.monotonic() < deadline:
        value = execute_js(port, session_id, skill_member_menu_state_script(), timeout=3)
        state = value if isinstance(value, dict) else {}
        if (
            state
            and state.get("ok") is True
            and state.get("activeId", "").startswith("skill-member:")
            and ((state.get("progress") or {}).get("counts") or {}).get("skill-member-menu-prototype") == 1
        ):
            return state
        time.sleep(0.2)
    raise WebDriverError(f"skill member menu did not become ready: {state!r}")


def wait_for_new_game_skill_menu_state(port: int, session_id: str, timeout: float = 8) -> dict:
    deadline = time.monotonic() + timeout
    state: dict = {}
    while time.monotonic() < deadline:
        value = execute_js(port, session_id, new_game_skill_menu_state_script(), timeout=3)
        state = value if isinstance(value, dict) else {}
        if state and state.get("ok") is True and state.get("activeId", "").startswith("skill-menu:"):
            return state
        time.sleep(0.2)
    raise WebDriverError(f"new-game skill menu did not become ready: {state!r}")


def wait_for_skill_title_ready(port: int, session_id: str, timeout: float = 8) -> dict:
    deadline = time.monotonic() + timeout
    state: dict = {}
    while time.monotonic() < deadline:
        value = execute_js(port, session_id, title_state_script(), timeout=3)
        state = value if isinstance(value, dict) else {}
        labels = [str(label) for label in state.get("titleMenuLabels") or []]
        if (
            state.get("scene") == "title"
            and state.get("quickLoadHidden") is False
            and state.get("quickLoadText") == "이어하기"
            and any("이어하기 map1_02b 11,12" in label for label in labels)
        ):
            return state
        time.sleep(0.2)
    raise WebDriverError(f"skill title continue did not become ready: {state!r}")


def wait_for_skill_title_continue_state(port: int, session_id: str, timeout: float = 8) -> dict:
    deadline = time.monotonic() + timeout
    state: dict = {}
    while time.monotonic() < deadline:
        value = execute_js(port, session_id, skill_title_continue_state_script(), timeout=3)
        state = value if isinstance(value, dict) else {}
        if state and state.get("titleContinue") is True:
            return state
        time.sleep(0.2)
    raise WebDriverError(f"skill title continue state did not become ready: {state!r}")


def menu_review_feedback_entries(state: dict, key: str) -> list[dict]:
    return [entry for entry in (state.get(key) or []) if isinstance(entry, dict)]


def menu_review_feedback_summary(state: dict, key: str) -> str:
    return ",".join(
        f"{entry.get('source')}:{entry.get('kind')}:{entry.get('id')}:{entry.get('text')}"
        for entry in menu_review_feedback_entries(state, key)
    )


def menu_review_feedback_rendered(state: dict, key: str) -> bool:
    return any(
        entry.get("browserMenuReviewFeedbackImplemented") is True
        for entry in menu_review_feedback_entries(state, key)
    )


def verify_skill_menu_review_feedback(
    state: dict,
    key: str,
    *,
    menu_id: str,
    learned_count: int,
    total_slots: int,
    menu_source: str,
    rendered: bool = False,
) -> None:
    entries = menu_review_feedback_entries(state, key)
    for entry in entries:
        if (
            entry.get("source") == "skill-menu-review-feedback"
            and entry.get("kind") == "skill-menu-prototype"
            and entry.get("id") == menu_id
            and entry.get("label") == "기술"
            and entry.get("text") == f"기술 확인 {learned_count}/{total_slots}"
            and entry.get("learnedCount") == learned_count
            and entry.get("totalSlots") == total_slots
            and entry.get("menuSource") == menu_source
            and entry.get("durationMs") == 1000
            and entry.get("active") is True
            and entry.get("menuReviewSound") == "menuConfirm"
            and str(entry.get("menuReviewSoundSrc") or "").endswith("/extract_wlk/04.wav")
            and entry.get("menuReviewSoundPlayed") is True
            and entry.get("browserMenuReviewFeedbackImplemented") is True
            and entry.get("originalEquipmentOffsetsMapped") is False
            and entry.get("originalStoryFlagRuntimeImplemented") is False
            and (not rendered or (entry.get("x", 0) > 0 and entry.get("y", 0) > 0 and entry.get("alpha", 0) > 0))
        ):
            return
    raise WebDriverError(f"missing skill menu review feedback {key}: {entries!r}")


def verify_skill_member_menu_review_feedback(state: dict, key: str, *, rendered: bool = False) -> None:
    entries = menu_review_feedback_entries(state, key)
    for entry in entries:
        if (
            entry.get("source") == "skill-member-menu-review-feedback"
            and entry.get("kind") == "skill-member-menu-prototype"
            and entry.get("id") == "skill-member:map2_02d:Ataho"
            and entry.get("label") == "기술 선택 Ataho"
            and entry.get("text") == "기술 선택 Ataho 2/10"
            and entry.get("characterCount") == 1
            and entry.get("learnedCount") == 2
            and entry.get("totalSlots") == 10
            and entry.get("menuSource") == "savedat-skill-bytes-and-exe-text-tables"
            and entry.get("durationMs") == 1000
            and entry.get("active") is True
            and entry.get("menuReviewSound") == "menuConfirm"
            and str(entry.get("menuReviewSoundSrc") or "").endswith("/extract_wlk/04.wav")
            and entry.get("menuReviewSoundPlayed") is True
            and entry.get("browserMenuReviewFeedbackImplemented") is True
            and entry.get("originalEquipmentOffsetsMapped") is False
            and entry.get("originalStoryFlagRuntimeImplemented") is False
            and (not rendered or (entry.get("x", 0) > 0 and entry.get("y", 0) > 0 and entry.get("alpha", 0) > 0))
        ):
            return
    raise WebDriverError(f"missing skill member menu review feedback {key}: {entries!r}")


def verify_skill_completion_notice_feedback(
    state: dict,
    *,
    menu_id: str,
    learned_count: int,
    total_slots: int,
    menu_source: str,
) -> None:
    feedback = state.get("objectiveMenuReviewFeedbackLast") or {}
    rendered = state.get("objectiveMenuReviewFeedbackLastRender") or {}
    log = menu_review_feedback_entries(state, "objectiveMenuReviewFeedbackLog")
    render_log = menu_review_feedback_entries(state, "objectiveMenuReviewFeedbackRender")
    if (
        len(log) < 1
        or len(render_log) < 1
        or feedback.get("source") != "skill-menu-completion-notice-feedback"
        or feedback.get("kind") != "skill-menu-prototype"
        or feedback.get("id") != menu_id
        or feedback.get("label") != "기술"
        or feedback.get("text") != "기술 확인 완료 1/1"
        or feedback.get("learnedCount") != learned_count
        or feedback.get("totalSlots") != total_slots
        or feedback.get("menuSource") != menu_source
        or feedback.get("durationMs") != 1000
        or feedback.get("menuReviewSound") != "menuConfirm"
        or not str(feedback.get("menuReviewSoundSrc") or "").endswith("/extract_wlk/04.wav")
        or feedback.get("menuReviewSoundPlayed") is not True
        or feedback.get("browserMenuReviewFeedbackImplemented") is not True
        or feedback.get("originalEquipmentOffsetsMapped") is not False
        or feedback.get("originalStoryFlagRuntimeImplemented") is not False
        or rendered.get("active") is not True
        or rendered.get("source") != "skill-menu-completion-notice-feedback"
        or rendered.get("kind") != "skill-menu-prototype"
        or rendered.get("id") != menu_id
        or rendered.get("label") != "기술"
        or rendered.get("text") != "기술 확인 완료 1/1"
        or rendered.get("learnedCount") != learned_count
        or rendered.get("totalSlots") != total_slots
        or rendered.get("menuSource") != menu_source
        or rendered.get("durationMs") != 1000
        or rendered.get("menuReviewSound") != "menuConfirm"
        or not str(rendered.get("menuReviewSoundSrc") or "").endswith("/extract_wlk/04.wav")
        or rendered.get("menuReviewSoundPlayed") is not True
        or rendered.get("browserMenuReviewFeedbackImplemented") is not True
        or rendered.get("originalEquipmentOffsetsMapped") is not False
        or rendered.get("originalStoryFlagRuntimeImplemented") is not False
    ):
        raise WebDriverError(f"skill completion notice feedback is incomplete: {state!r}")


def verify_skill_member_completion_notice_feedback(state: dict) -> None:
    feedback = state.get("objectiveMenuReviewFeedbackLast") or {}
    rendered = state.get("objectiveMenuReviewFeedbackLastRender") or {}
    log = menu_review_feedback_entries(state, "objectiveMenuReviewFeedbackLog")
    render_log = menu_review_feedback_entries(state, "objectiveMenuReviewFeedbackRender")
    if (
        len(log) < 1
        or len(render_log) < 1
        or feedback.get("source") != "skill-member-menu-completion-notice-feedback"
        or feedback.get("kind") != "skill-member-menu-prototype"
        or feedback.get("id") != "skill-member:map2_02d:Ataho"
        or feedback.get("label") != "기술 선택"
        or feedback.get("text") != "기술 선택 확인 완료 1/1"
        or feedback.get("characterCount") != 1
        or feedback.get("learnedCount") != 2
        or feedback.get("totalSlots") != 10
        or feedback.get("selectedMemberName") != "Ataho"
        or feedback.get("menuSource") != "savedat-skill-bytes-and-exe-text-tables"
        or feedback.get("durationMs") != 1000
        or feedback.get("menuReviewSound") != "menuConfirm"
        or not str(feedback.get("menuReviewSoundSrc") or "").endswith("/extract_wlk/04.wav")
        or feedback.get("menuReviewSoundPlayed") is not True
        or feedback.get("browserMenuReviewFeedbackImplemented") is not True
        or feedback.get("originalEquipmentOffsetsMapped") is not False
        or feedback.get("originalStoryFlagRuntimeImplemented") is not False
        or rendered.get("active") is not True
        or rendered.get("source") != "skill-member-menu-completion-notice-feedback"
        or rendered.get("kind") != "skill-member-menu-prototype"
        or rendered.get("id") != "skill-member:map2_02d:Ataho"
        or rendered.get("label") != "기술 선택"
        or rendered.get("text") != "기술 선택 확인 완료 1/1"
        or rendered.get("characterCount") != 1
        or rendered.get("learnedCount") != 2
        or rendered.get("totalSlots") != 10
        or rendered.get("selectedMemberName") != "Ataho"
        or rendered.get("menuSource") != "savedat-skill-bytes-and-exe-text-tables"
        or rendered.get("durationMs") != 1000
        or rendered.get("menuReviewSound") != "menuConfirm"
        or not str(rendered.get("menuReviewSoundSrc") or "").endswith("/extract_wlk/04.wav")
        or rendered.get("menuReviewSoundPlayed") is not True
        or rendered.get("browserMenuReviewFeedbackImplemented") is not True
        or rendered.get("originalEquipmentOffsetsMapped") is not False
        or rendered.get("originalStoryFlagRuntimeImplemented") is not False
    ):
        raise WebDriverError(f"skill member completion notice feedback is incomplete: {state!r}")


def skill_completion_notice_feedback_summary(state: dict) -> str:
    feedback = state.get("objectiveMenuReviewFeedbackLast") or {}
    rendered = state.get("objectiveMenuReviewFeedbackLastRender") or {}
    return (
        f"skillNoticeFeedback={feedback.get('source')}:{feedback.get('text')} "
        f"skillNoticeFeedbackRender={rendered.get('active')} "
        f"skillNoticeSound={feedback.get('menuReviewSound')}:"
        f"{feedback.get('menuReviewSoundSrc')}:{feedback.get('menuReviewSoundPlayed')}"
    )


def verify_skill_menu_state(state: dict) -> None:
    labels = state.get("labels") or []
    lines = "\n".join(state.get("lines") or [])
    skill_menu = state.get("skillMenu") or {}
    progress = state.get("progress") or {}
    counts = progress.get("counts") or {}
    skill_progress = state.get("skillProgress") or {}
    skill_auto_save = skill_progress.get("autoSave") or {}
    completion = state.get("completion") or {}
    menu_review = completion.get("menuReview") or {}
    objective_before = state.get("objectiveBefore") or {}
    objective_action = state.get("objectiveAction") or {}
    objective_notice = state.get("objectiveCompletionNotice") or {}
    objective_dialogue = state.get("objectiveActiveDialogueBlock") or {}
    objective_notice_lines = "\n".join(objective_notice.get("lines") or [])
    menu_feedback = skill_progress.get("menuReviewFeedback") or {}
    rows = skill_menu.get("rows") or []
    names = [row.get("name") for row in rows]
    verify_skill_menu_review_feedback(
        state,
        "menuReviewFeedbackLog",
        menu_id="skill-menu:map2_02d",
        learned_count=skill_menu.get("learnedCount"),
        total_slots=skill_menu.get("totalSlots"),
        menu_source="savedat-skill-bytes-and-exe-text-tables",
    )
    verify_skill_menu_review_feedback(
        state,
        "menuReviewFeedbackRender",
        menu_id="skill-menu:map2_02d",
        learned_count=skill_menu.get("learnedCount"),
        total_slots=skill_menu.get("totalSlots"),
        menu_source="savedat-skill-bytes-and-exe-text-tables",
        rendered=True,
    )
    verify_skill_completion_notice_feedback(
        state,
        menu_id="skill-menu:map2_02d",
        learned_count=skill_menu.get("learnedCount"),
        total_slots=skill_menu.get("totalSlots"),
        menu_source="savedat-skill-bytes-and-exe-text-tables",
    )
    if (
        not any(str(label).startswith("기술 ") for label in labels)
        or state.get("commandResult") is not True
        or state.get("activeId") != "skill-menu:map2_02d"
        or skill_menu.get("source") != "savedat-skill-bytes-and-exe-text-tables"
        or skill_menu.get("originalEquipmentOffsetsMapped") is not False
        or skill_menu.get("originalStoryFlagRuntimeImplemented") is not False
        or skill_menu.get("learnedCount", 0) < 2
        or "돌려차기" not in names
        or "던지기" not in names
        or "기술 savedat:" not in lines
        or "Ataho" not in lines
        or "돌려차기 Lv" not in lines
        or "던지기 Lv" not in lines
        or "장비 offset" not in lines
        or counts.get("skill-menu-prototype") != 1
        or skill_progress.get("id") != "skill-menu:map2_02d"
        or skill_progress.get("duplicateProgress") is not False
        or menu_feedback.get("text") != f"기술 확인 {skill_menu.get('learnedCount')}/{skill_menu.get('totalSlots')}"
        or menu_feedback.get("menuReviewSound") != "menuConfirm"
        or not str(menu_feedback.get("menuReviewSoundSrc") or "").endswith("/extract_wlk/04.wav")
        or menu_feedback.get("menuReviewSoundPlayed") is not True
        or skill_auto_save.get("saved") is not True
        or skill_auto_save.get("source") != "skill-menu-prototype"
        or skill_auto_save.get("payloadMap") != "map2_02d"
        or menu_review.get("id") != "skill-menu:map2_02d"
        or menu_review.get("kind") != "skill-menu-prototype"
        or menu_review.get("label") != "기술"
        or menu_review.get("completed") is not True
        or menu_review.get("learnedCount", 0) < 2
        or objective_before.get("title") != "후보 기술 완료 map2_02d"
        or objective_before.get("nextAction") != "완료 알림 확인"
        or objective_before.get("source") != "prototype-menu-review-completion"
        or state.get("objectiveResult") is not True
        or objective_action.get("action") != "menu-review-completion-notice"
        or objective_action.get("activeId") != "skill-review-complete:map2_02d"
        or objective_notice.get("blockId") != "skill-review-complete:map2_02d"
        or "기술 확인 완료 1/1" not in objective_notice_lines
        or "source savedat-skill-bytes-and-exe-text-tables" not in objective_notice_lines
        or objective_dialogue.get("blockId") != "skill-review-complete:map2_02d"
    ):
        raise WebDriverError(f"candidate skill menu state is incomplete: {state!r}")


def verify_skill_member_menu_state(state: dict) -> None:
    before_labels = state.get("beforeLabels") or []
    after_open_labels = state.get("afterOpenLabels") or []
    lines = "\n".join(state.get("lines") or [])
    marker = state.get("afterOpenMarker") or {}
    marker_choices = marker.get("choices") or []
    selection = state.get("selection") or {}
    progress = state.get("progress") or {}
    counts = progress.get("counts") or {}
    progress_event = selection.get("progressEvent") or {}
    auto_save = selection.get("autoSave") or {}
    menu_feedback = selection.get("menuReviewFeedback") or {}
    completion = state.get("completion") or {}
    menu_review = completion.get("menuReview") or {}
    objective_before = state.get("objectiveBefore") or {}
    objective_action = state.get("objectiveAction") or {}
    objective_notice = state.get("objectiveCompletionNotice") or {}
    objective_dialogue = state.get("objectiveActiveDialogueBlock") or {}
    objective_notice_lines = "\n".join(objective_notice.get("lines") or [])
    verify_skill_member_menu_review_feedback(state, "menuReviewFeedbackLog")
    verify_skill_member_menu_review_feedback(state, "menuReviewFeedbackRender", rendered=True)
    verify_skill_member_completion_notice_feedback(state)
    if (
        "기술 선택 3" not in before_labels
        or not any(str(label).startswith("기술 6") for label in before_labels)
        or state.get("commandResult") is not True
        or state.get("selectResult") is not True
        or state.get("afterOpenMenuMode") != "skill-member"
        or state.get("afterMenuMode") != "main"
        or state.get("activeId") != "skill-member:map2_02d:Ataho"
        or marker.get("source") != "prototype-skill-member-menu"
        or marker.get("map") != "map2_02d"
        or marker.get("count") != 3
        or marker.get("learnedCount") != 6
        or marker.get("totalSlots") != 24
        or marker.get("menuSource") != "savedat-skill-bytes-and-exe-text-tables"
        or [choice.get("memberName") for choice in marker_choices] != ["Ataho", "Rinshan", "Smashu"]
        or "기술 1/3 Ataho 2/10" not in after_open_labels
        or "기술 2/3 Rinshan 2/8" not in after_open_labels
        or "기술 3/3 Smashu 2/6" not in after_open_labels
        or "기술 선택 닫기" not in after_open_labels
        or selection.get("source") != "prototype-skill-member-menu"
        or selection.get("map") != "map2_02d"
        or selection.get("memberName") != "Ataho"
        or selection.get("learnedCount") != 2
        or selection.get("totalSlots") != 10
        or selection.get("skillNames") != ["돌려차기", "던지기"]
        or selection.get("menuCandidateIndex") != 0
        or selection.get("menuCandidateCount") != 3
        or selection.get("menuSource") != "savedat-skill-bytes-and-exe-text-tables"
        or selection.get("started") is not True
        or selection.get("activeId") != "skill-member:map2_02d:Ataho"
        or selection.get("lineCount", 0) < 6
        or "기술 Ataho" not in lines
        or "savedat 기술 byte와 EXE text table을 캐릭터별로 표시합니다." not in lines
        or "Ataho Lv999 기술 2/10" not in lines
        or "돌려차기 Lv1 MP2 individual/#0 atahoActions" not in lines
        or "던지기 Lv1 MP3 group/#0 atahoActions" not in lines
        or "장비 offset" not in lines
        or counts.get("skill-member-menu-prototype") != 1
        or selection.get("progressCount") != 1
        or progress_event.get("kind") != "skill-member-menu-prototype"
        or progress_event.get("id") != "skill-member:map2_02d:Ataho"
        or (progress_event.get("detail") or {}).get("selectedMemberName") != "Ataho"
        or (progress_event.get("detail") or {}).get("learnedCount") != 2
        or (progress_event.get("detail") or {}).get("totalSlots") != 10
        or selection.get("autoSaved") is not True
        or selection.get("autoSource") != "skill-member-menu-prototype"
        or auto_save.get("saved") is not True
        or auto_save.get("source") != "skill-member-menu-prototype"
        or auto_save.get("payloadMap") != "map2_02d"
        or menu_feedback.get("source") != "skill-member-menu-review-feedback"
        or menu_feedback.get("kind") != "skill-member-menu-prototype"
        or menu_feedback.get("id") != "skill-member:map2_02d:Ataho"
        or menu_feedback.get("label") != "기술 선택 Ataho"
        or menu_feedback.get("text") != "기술 선택 Ataho 2/10"
        or menu_feedback.get("menuReviewSound") != "menuConfirm"
        or not str(menu_feedback.get("menuReviewSoundSrc") or "").endswith("/extract_wlk/04.wav")
        or menu_feedback.get("menuReviewSoundPlayed") is not True
        or menu_review.get("id") != "skill-member:map2_02d:Ataho"
        or menu_review.get("kind") != "skill-member-menu-prototype"
        or menu_review.get("label") != "기술 선택"
        or menu_review.get("completed") is not True
        or menu_review.get("selectedMemberName") != "Ataho"
        or menu_review.get("learnedCount") != 2
        or menu_review.get("totalSlots") != 10
        or objective_before.get("title") != "후보 기술 선택 완료 map2_02d"
        or objective_before.get("nextAction") != "완료 알림 확인"
        or objective_before.get("detail") != "Ataho 기술 2/10"
        or objective_before.get("source") != "prototype-menu-review-completion"
        or state.get("objectiveResult") is not True
        or objective_action.get("action") != "menu-review-completion-notice"
        or objective_action.get("activeId") != "skill-member-review-complete:map2_02d"
        or objective_notice.get("blockId") != "skill-member-review-complete:map2_02d"
        or "기술 선택 확인 완료 1/1" not in objective_notice_lines
        or "기술 2/10" not in objective_notice_lines
        or "member Ataho" not in objective_notice_lines
        or "source savedat-skill-bytes-and-exe-text-tables" not in objective_notice_lines
        or objective_dialogue.get("blockId") != "skill-member-review-complete:map2_02d"
        or marker.get("originalEquipmentOffsetsMapped") is not False
        or marker.get("originalCombatFormulaImplemented") is not False
        or marker.get("originalStoryFlagRuntimeImplemented") is not False
        or selection.get("originalEquipmentOffsetsMapped") is not False
        or selection.get("originalCombatFormulaImplemented") is not False
        or selection.get("originalStoryFlagRuntimeImplemented") is not False
    ):
        raise WebDriverError(f"skill member menu state is incomplete: {state!r}")


def verify_new_game_skill_menu_state(state: dict) -> None:
    labels = state.get("labels") or []
    lines = "\n".join(state.get("lines") or [])
    skill_menu = state.get("skillMenu") or {}
    progress = state.get("progress") or {}
    counts = progress.get("counts") or {}
    skill_progress = state.get("skillProgress") or {}
    skill_auto_save = skill_progress.get("autoSave") or {}
    completion = state.get("completion") or {}
    menu_review = completion.get("menuReview") or {}
    objective_before = state.get("objectiveBefore") or {}
    objective_action = state.get("objectiveAction") or {}
    objective_notice = state.get("objectiveCompletionNotice") or {}
    objective_dialogue = state.get("objectiveActiveDialogueBlock") or {}
    objective_notice_lines = "\n".join(objective_notice.get("lines") or [])
    menu_feedback = skill_progress.get("menuReviewFeedback") or {}
    rows = skill_menu.get("rows") or []
    runtime_characters = state.get("runtimeCharacters") or []
    verify_skill_menu_review_feedback(
        state,
        "menuReviewFeedbackLog",
        menu_id="skill-menu:map1_02b",
        learned_count=0,
        total_slots=0,
        menu_source="prototype-party-defaults-and-exe-text-tables",
    )
    verify_skill_menu_review_feedback(
        state,
        "menuReviewFeedbackRender",
        menu_id="skill-menu:map1_02b",
        learned_count=0,
        total_slots=0,
        menu_source="prototype-party-defaults-and-exe-text-tables",
        rendered=True,
    )
    verify_skill_completion_notice_feedback(
        state,
        menu_id="skill-menu:map1_02b",
        learned_count=0,
        total_slots=0,
        menu_source="prototype-party-defaults-and-exe-text-tables",
    )
    if (
        "기술 0" not in labels
        or state.get("commandResult") is not True
        or state.get("activeId") != "skill-menu:map1_02b"
        or state.get("loadedSaveSummary") is not None
        or skill_menu.get("source") != "prototype-party-defaults-and-exe-text-tables"
        or skill_menu.get("characterCount") != 1
        or skill_menu.get("learnedCount") != 0
        or skill_menu.get("totalSlots") != 0
        or rows
        or skill_menu.get("originalEquipmentOffsetsMapped") is not False
        or skill_menu.get("originalStoryFlagRuntimeImplemented") is not False
        or runtime_characters != [{"name": "Ataho", "level": 1, "learned": 0, "total": 0}]
        or "기술 prototype: 0/0" not in lines
        or "savedat 없이 현재 웹 프로토타입 동료 구성의 기술을 표시합니다." not in lines
        or "Ataho Lv1 기술 0/0" not in lines
        or "배운 기술 없음" not in lines
        or "장비 offset" not in lines
        or counts.get("skill-menu-prototype") != 1
        or skill_progress.get("id") != "skill-menu:map1_02b"
        or skill_progress.get("duplicateProgress") is not False
        or menu_feedback.get("text") != "기술 확인 0/0"
        or menu_feedback.get("menuReviewSound") != "menuConfirm"
        or not str(menu_feedback.get("menuReviewSoundSrc") or "").endswith("/extract_wlk/04.wav")
        or menu_feedback.get("menuReviewSoundPlayed") is not True
        or skill_auto_save.get("saved") is not True
        or skill_auto_save.get("source") != "skill-menu-prototype"
        or skill_auto_save.get("payloadMap") != "map1_02b"
        or menu_review.get("id") != "skill-menu:map1_02b"
        or menu_review.get("kind") != "skill-menu-prototype"
        or menu_review.get("label") != "기술"
        or menu_review.get("completed") is not True
        or menu_review.get("learnedCount") != 0
        or menu_review.get("totalSlots") != 0
        or objective_before.get("title") != "후보 기술 완료 map1_02b"
        or objective_before.get("nextAction") != "완료 알림 확인"
        or objective_before.get("source") != "prototype-menu-review-completion"
        or state.get("objectiveResult") is not True
        or objective_action.get("action") != "menu-review-completion-notice"
        or objective_action.get("activeId") != "skill-review-complete:map1_02b"
        or objective_notice.get("blockId") != "skill-review-complete:map1_02b"
        or "기술 확인 완료 1/1" not in objective_notice_lines
        or "기술 0/0" not in objective_notice_lines
        or "source prototype-party-defaults-and-exe-text-tables" not in objective_notice_lines
        or objective_dialogue.get("blockId") != "skill-review-complete:map1_02b"
    ):
        raise WebDriverError(f"new-game skill menu state is incomplete: {state!r}")


def verify_skill_title_continue_state(title_state: dict, click_state: dict, restored_state: dict) -> None:
    title_labels = [str(label) for label in title_state.get("titleMenuLabels") or []]
    click_labels = [str(label) for label in click_state.get("titleMenuLabels") or []]
    progress = restored_state.get("progress") or {}
    counts = progress.get("counts") or {}
    completion = restored_state.get("completion") or {}
    menu_review = completion.get("menuReview") or {}
    objective_before = restored_state.get("objectiveBefore") or {}
    objective_action = restored_state.get("objectiveAction") or {}
    objective_notice = restored_state.get("objectiveCompletionNotice") or {}
    objective_dialogue = restored_state.get("objectiveActiveDialogueBlock") or {}
    objective_notice_lines = "\n".join(objective_notice.get("lines") or [])
    verify_skill_completion_notice_feedback(
        restored_state,
        menu_id="skill-menu:map1_02b",
        learned_count=0,
        total_slots=0,
        menu_source="prototype-party-defaults-and-exe-text-tables",
    )
    if (
        title_state.get("scene") != "title"
        or title_state.get("quickLoadHidden") is not False
        or title_state.get("quickLoadText") != "이어하기"
        or not any("이어하기 map1_02b 11,12" in label for label in title_labels)
        or click_state.get("ok") is not True
        or click_state.get("text") != "이어하기"
        or not any("이어하기 map1_02b 11,12" in label for label in click_labels)
        or restored_state.get("titleContinue") is not True
        or restored_state.get("scene") != "map"
        or restored_state.get("map") != "map1_02b"
        or restored_state.get("quickLoadText") != "임시 불러오기"
        or restored_state.get("loadedSaveSummary") is not None
        or counts.get("skill-menu-prototype") != 1
        or menu_review.get("id") != "skill-menu:map1_02b"
        or menu_review.get("completed") is not True
        or menu_review.get("learnedCount") != 0
        or menu_review.get("totalSlots") != 0
        or objective_before.get("title") != "후보 기술 완료 map1_02b"
        or objective_before.get("nextAction") != "완료 알림 확인"
        or objective_before.get("source") != "prototype-menu-review-completion"
        or restored_state.get("objectiveResult") is not True
        or objective_action.get("action") != "menu-review-completion-notice"
        or objective_action.get("activeId") != "skill-review-complete:map1_02b"
        or objective_notice.get("blockId") != "skill-review-complete:map1_02b"
        or "기술 확인 완료 1/1" not in objective_notice_lines
        or "기술 0/0" not in objective_notice_lines
        or objective_dialogue.get("blockId") != "skill-review-complete:map1_02b"
    ):
        raise WebDriverError(
            "new-game skill menu title continue did not restore completion objective: "
            f"title={title_state!r} click={click_state!r} restored={restored_state!r}"
        )


def write_report(report: dict) -> None:
    out_dir = ROOT / "out"
    out_dir.mkdir(parents=True, exist_ok=True)
    (out_dir / "candidate_skill_menu_browser_smoke.json").write_text(
        json.dumps(report, ensure_ascii=False, indent=2),
        encoding="utf-8",
    )
    lines = [
        "# Candidate Skill Menu Browser Smoke",
        "",
        f"- status: `{report.get('status')}`",
        f"- savedat skill menu: `{report.get('skillMenu')}`",
        f"- skill member menu: `{report.get('skillMemberMenu')}`",
        f"- new game skill menu: `{report.get('newGameSkillMenu')}`",
        f"- new game skill title continue: `{report.get('newGameSkillTitleContinue')}`",
    ]


def verify_browser(base: str) -> None:
    driver_path = shutil.which("WebKitWebDriver")
    if not driver_path:
        raise WebDriverError("WebKitWebDriver is not installed; install webkit2gtk-driver and run under Xvfb")

    port = free_port()
    log_path = ROOT / "out" / "candidate_skill_menu_webkitdriver.log"
    log_path.parent.mkdir(parents=True, exist_ok=True)
    with log_path.open("wb") as log:
        proc = subprocess.Popen(
            [
                driver_path,
                "--host=127.0.0.1",
                f"--port={port}",
                "--replace-on-new-session",
            ],
            stdout=log,
            stderr=subprocess.STDOUT,
        )
        session_id = ""
        try:
            wait_for_driver(port, proc)
            session = request_json(
                port,
                "POST",
                "/session",
                {"capabilities": {"alwaysMatch": {"browserName": "MiniBrowser"}}},
                timeout=30,
            )
            session_id = str(session["value"]["sessionId"])
            request_json(
                port,
                "POST",
                f"/session/{session_id}/window/rect",
                {"x": 0, "y": 0, "width": 390, "height": 844},
                timeout=8,
            )

            url = load_map(
                base,
                port,
                session_id,
                {
                    "map": "map2_02d",
                    "startTile": "11,12",
                    "publicSave": "flack3r-savedat2",
                },
            )
            execute_js(port, session_id, skill_menu_start_script(), timeout=3)
            state = wait_for_skill_menu_state(port, session_id)
            verify_skill_menu_state(state)
            skill_menu = state.get("skillMenu") or {}
            names = ",".join(row.get("name", "") for row in (skill_menu.get("rows") or [])[:4])
            execute_js(port, session_id, skill_member_menu_start_script(), timeout=3)
            skill_member_state = wait_for_skill_member_menu_state(port, session_id)
            verify_skill_member_menu_state(skill_member_state)
            url_new_game_skill = load_map(
                base,
                port,
                session_id,
                {
                    "map": "map1_02b",
                    "startTile": "11,12",
                },
            )
            execute_js(port, session_id, new_game_skill_menu_start_script(), timeout=3)
            new_game_skill_state = wait_for_new_game_skill_menu_state(port, session_id)
            verify_new_game_skill_menu_state(new_game_skill_state)
            new_game_skill_menu = new_game_skill_state.get("skillMenu") or {}
            title_url = urljoin(base.rstrip("/") + "/", "/web/game.html?game=1")
            request_json(port, "POST", f"/session/{session_id}/url", {"url": title_url}, timeout=30)
            wait_for_page(port, session_id)
            title_state = wait_for_skill_title_ready(port, session_id)
            title_click = execute_js(port, session_id, click_title_continue_script(), timeout=3)
            if not isinstance(title_click, dict) or title_click.get("ok") is not True:
                raise WebDriverError(f"skill menu title continue button was not usable: {title_click!r}")
            wait_for_map_runtime(port, session_id, "map1_02b")
            execute_js(port, session_id, capture_skill_title_continue_script(), timeout=3)
            title_continue_state = wait_for_skill_title_continue_state(port, session_id)
            verify_skill_title_continue_state(title_state, title_click, title_continue_state)
            report = {
                "status": "passed",
                "url": url,
                "newGameSkillUrl": url_new_game_skill,
                "skillMenu": (
                    f"label={(state.get('labels') or [''])[state.get('index', 0)]} "
                    f"learned={skill_menu.get('learnedCount')}/{skill_menu.get('totalSlots')} "
                    f"names={names} "
                    f"source={skill_menu.get('source')} "
                    f"skillFeedback={menu_review_feedback_summary(state, 'menuReviewFeedbackLog')} "
                    f"skillFeedbackRender={menu_review_feedback_rendered(state, 'menuReviewFeedbackRender')} "
                    f"skillSound={((state.get('menuReviewFeedbackLast') or {}).get('menuReviewSound') or '')} "
                    f"skillSoundSrc={((state.get('menuReviewFeedbackLast') or {}).get('menuReviewSoundSrc') or '')} "
                    f"skillSoundPlayed={((state.get('menuReviewFeedbackLast') or {}).get('menuReviewSoundPlayed'))} "
                    f"{skill_completion_notice_feedback_summary(state)} "
                    "originalEquipmentOffsetsMapped=False "
                    "originalStoryFlagRuntimeImplemented=False"
                ),
                "skillMemberMenu": (
                    "menuCount=3 selected=Ataho "
                    "selectionSource=prototype-skill-member-menu "
                    f"learned={(skill_member_state.get('selection') or {}).get('learnedCount')}/"
                    f"{(skill_member_state.get('selection') or {}).get('totalSlots')} "
                    f"names={','.join((skill_member_state.get('selection') or {}).get('skillNames') or [])} "
                    f"commandResult={skill_member_state.get('commandResult')} "
                    f"selectResult={skill_member_state.get('selectResult')} "
                    f"afterOpenMenuMode={skill_member_state.get('afterOpenMenuMode')} "
                    f"afterMenuMode={skill_member_state.get('afterMenuMode')} "
                    f"active={skill_member_state.get('activeId')} "
                    f"progressCount={((skill_member_state.get('progress') or {}).get('counts') or {}).get('skill-member-menu-prototype')} "
                    f"autoSaved={(skill_member_state.get('selection') or {}).get('autoSaved')} "
                    f"autoSource={(skill_member_state.get('selection') or {}).get('autoSource')} "
                    f"skillFeedback={menu_review_feedback_summary(skill_member_state, 'menuReviewFeedbackLog')} "
                    f"skillFeedbackRender={menu_review_feedback_rendered(skill_member_state, 'menuReviewFeedbackRender')} "
                    f"skillSound={((skill_member_state.get('menuReviewFeedbackLast') or {}).get('menuReviewSound') or '')} "
                    f"skillSoundSrc={((skill_member_state.get('menuReviewFeedbackLast') or {}).get('menuReviewSoundSrc') or '')} "
                    f"skillSoundPlayed={((skill_member_state.get('menuReviewFeedbackLast') or {}).get('menuReviewSoundPlayed'))} "
                    f"objective={(skill_member_state.get('objectiveBefore') or {}).get('title')} "
                    f"objectiveAction={(skill_member_state.get('objectiveAction') or {}).get('action')} "
                    f"objectiveActiveId={(skill_member_state.get('objectiveAction') or {}).get('activeId')} "
                    f"{skill_completion_notice_feedback_summary(skill_member_state)} "
                    "originalEquipmentOffsetsMapped=False "
                    "originalCombatFormulaImplemented=False "
                    "originalStoryFlagRuntimeImplemented=False"
                ),
                "newGameSkillMenu": (
                    "label=기술 0 active=skill-menu:map1_02b "
                    f"learned={new_game_skill_menu.get('learnedCount')}/{new_game_skill_menu.get('totalSlots')} "
                    f"source={new_game_skill_menu.get('source')} "
                    f"autoSource={(new_game_skill_state.get('skillProgress') or {}).get('autoSave', {}).get('source')} "
                    f"objective={(new_game_skill_state.get('objectiveBefore') or {}).get('title')} "
                    f"objectiveAction={(new_game_skill_state.get('objectiveAction') or {}).get('action')} "
                    f"objectiveActiveId={(new_game_skill_state.get('objectiveAction') or {}).get('activeId')} "
                    f"skillFeedback={menu_review_feedback_summary(new_game_skill_state, 'menuReviewFeedbackLog')} "
                    f"skillFeedbackRender={menu_review_feedback_rendered(new_game_skill_state, 'menuReviewFeedbackRender')} "
                    f"skillSound={((new_game_skill_state.get('menuReviewFeedbackLast') or {}).get('menuReviewSound') or '')} "
                    f"skillSoundSrc={((new_game_skill_state.get('menuReviewFeedbackLast') or {}).get('menuReviewSoundSrc') or '')} "
                    f"skillSoundPlayed={((new_game_skill_state.get('menuReviewFeedbackLast') or {}).get('menuReviewSoundPlayed'))} "
                    f"{skill_completion_notice_feedback_summary(new_game_skill_state)} "
                    "loadedSaveSummary=False "
                    "originalEquipmentOffsetsMapped=False "
                    "originalStoryFlagRuntimeImplemented=False"
                ),
                "newGameSkillTitleContinue": (
                    f"titleContinue={title_continue_state.get('titleContinue')} "
                    f"label={next((label for label in (title_state.get('titleMenuLabels') or []) if '이어하기 map1_02b 11,12' in str(label)), '')} "
                    f"quickLoadText={title_continue_state.get('quickLoadText')} "
                    f"skillProgressCount={(title_continue_state.get('progress') or {}).get('counts', {}).get('skill-menu-prototype')} "
                    f"objective={(title_continue_state.get('objectiveBefore') or {}).get('title')} "
                    f"objectiveAction={(title_continue_state.get('objectiveAction') or {}).get('action')} "
                    f"objectiveActiveId={(title_continue_state.get('objectiveAction') or {}).get('activeId')} "
                    f"{skill_completion_notice_feedback_summary(title_continue_state)} "
                    "loadedSaveSummary=False"
                ),
                "snapshots": {
                    "skillMenu": state,
                    "skillMemberMenu": skill_member_state,
                    "newGameSkillMenu": new_game_skill_state,
                    "newGameSkillTitle": title_state,
                    "newGameSkillTitleClick": title_click,
                    "newGameSkillTitleContinue": title_continue_state,
                },
            }
            write_report(report)
            print(f"ok candidate skill menu browser {report['skillMenu']}")
        finally:
            if session_id:
                try:
                    request_json(port, "DELETE", f"/session/{session_id}", timeout=5)
                except Exception:
                    pass
            proc.terminate()
            try:
                proc.wait(timeout=5)
            except subprocess.TimeoutExpired:
                proc.kill()
                proc.wait(timeout=5)


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("--base", default="http://127.0.0.1:8013")
    args = parser.parse_args()
    verify_browser(args.base)


if __name__ == "__main__":
    main()
