#!/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,
    save_point_candidate_menu_state_script,
    start_save_point_candidate_menu_script,
    wait_for_driver,
    wait_for_map_runtime,
    wait_for_page,
    wait_for_save_point_candidate_menu_state,
)

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

EXPECTED_SAVE_POINT_VM_REPLAY = {
    "event-dialogue-block-033": {
        "traceCommandCount": 12,
        "separatorEventCount": 9,
        "renderEventCount": 2,
        "literalTextEventCount": 2,
    },
}
EXPECTED_SAVE_POINT_CANDIDATE = {
    "source": "event_dialogue_blocks",
    "blockId": "event-dialogue-block-033",
    "rangeHex": "0x004ff47c..0x004ff69c",
    "category": "statue-shrine-context",
    "strength": "weak-location-only",
    "keywords": ["신의 산", "신의 사자"],
    "fieldMaps": ["map2_07e", "map2_08e"],
    "resources": ["map2_07e.cns", "map_e1.cns", "map_e2.cns", "face_01.cns", "map2_08e.cns"],
    "matchedLines": ["여기 신의 산에는 당신과 같은", "이 산에는 신의 사자가"],
}


def save_point_vm_replay_for(replays: list[dict], block_id: str) -> dict:
    return next(
        (
            row
            for row in replays
            if isinstance(row, dict) and row.get("blockId") == block_id
        ),
        {},
    )


def verify_save_point_vm_replay(vm_replay: dict, expected_block_id: str = "event-dialogue-block-033") -> None:
    expected = EXPECTED_SAVE_POINT_VM_REPLAY[expected_block_id]
    if (
        not isinstance(vm_replay, dict)
        or vm_replay.get("blockId") != expected_block_id
        or vm_replay.get("source") != "partial-opcode-replay"
        or vm_replay.get("traceCommandCount") != expected["traceCommandCount"]
        or vm_replay.get("executedOpcodeCount") != expected["traceCommandCount"]
        or vm_replay.get("separatorCommandCount") != expected["separatorEventCount"]
        or vm_replay.get("separatorEventCount") != expected["separatorEventCount"]
        or vm_replay.get("renderEventCount") != expected["renderEventCount"]
        or vm_replay.get("vmDrivenLineCount") != expected["renderEventCount"]
        or vm_replay.get("literalTextCommandCount") != expected["literalTextEventCount"]
        or vm_replay.get("literalTextEventCount") != expected["literalTextEventCount"]
        or vm_replay.get("renderCoverageStatus") != "partial-render-prefix"
        or vm_replay.get("firstRenderTextSourceValueHex") != "0x00030000"
        or vm_replay.get("routeLinkedEventVmExecution") is not False
        or vm_replay.get("browserEventVmPartialReplayImplemented") is not True
        or vm_replay.get("browserEventVmFullImplementation") is not False
        or vm_replay.get("originalEventVmRuntimeImplemented") is not False
        or vm_replay.get("originalStoryFlagRuntimeImplemented") is not False
    ):
        raise WebDriverError(f"save-point VM replay is incomplete for {expected_block_id}: {vm_replay!r}")


def verify_save_point_vm_replays(replays: list[dict], expected_block_id: str = "event-dialogue-block-033") -> None:
    verify_save_point_vm_replay(save_point_vm_replay_for(replays or [], expected_block_id), expected_block_id)


def save_point_candidate_evidence_for(rows: list[dict], block_id: str = "event-dialogue-block-033") -> dict:
    return next(
        (
            row
            for row in rows or []
            if isinstance(row, dict) and row.get("blockId") == block_id
        ),
        {},
    )


def verify_save_point_candidate_evidence(rows: list[dict], expected_block_id: str = "event-dialogue-block-033") -> None:
    row = save_point_candidate_evidence_for(rows, expected_block_id)
    expected = EXPECTED_SAVE_POINT_CANDIDATE
    if (
        row.get("source") != expected["source"]
        or row.get("blockId") != expected["blockId"]
        or row.get("rangeHex") != expected["rangeHex"]
        or row.get("category") != expected["category"]
        or row.get("strength") != expected["strength"]
        or row.get("keywords") != expected["keywords"]
        or row.get("fieldMaps") != expected["fieldMaps"]
        or row.get("resources") != expected["resources"]
        or row.get("matchedLines") != expected["matchedLines"]
        or "exact statue text" not in str(row.get("reason") or "")
    ):
        raise WebDriverError(f"save-point candidate evidence mismatch for {expected_block_id}: {rows!r}")


def save_point_vm_report(replays: list[dict]) -> str:
    rows = [row for row in replays if isinstance(row, dict)]
    return (
        f"vmBlocks={','.join(str(row.get('blockId') or '') for row in rows)} "
        f"vmPartial={','.join(str(row.get('browserEventVmPartialReplayImplemented')) for row in rows)} "
        f"vmFull={','.join(str(row.get('browserEventVmFullImplementation')) for row in rows)} "
        f"vmRender={','.join(str(row.get('renderEventCount')) for row in rows)} "
        f"vmSeparator={','.join(str(row.get('separatorEventCount')) for row in rows)} "
        f"vmLiteral={','.join(str(row.get('literalTextEventCount')) for row in rows)} "
        f"vmSource={','.join(str(row.get('firstRenderTextSourceValueHex')) for row in rows)}"
    )


def load_map(base: str, port: int, session_id: str, map_name: str, tile: str = "11,11") -> str:
    params = urlencode({
        "map": map_name,
        "startTile": tile,
        "moveMs": "80",
        "_": str(time.time_ns()),
    })
    url = urljoin(base.rstrip("/") + "/", f"/web/game.html?{params}")
    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, map_name)
    return url


def start_save_point_menu(port: int, session_id: str) -> tuple[dict, dict]:
    start = execute_js(port, session_id, start_save_point_candidate_menu_script())
    notice = str(start.get("notice") or "") if isinstance(start, dict) else ""
    if (
        not isinstance(start, dict)
        or not start.get("ok")
        or start.get("index", -1) < 0
        or start.get("directIndex") != -1
        or not any("세이브 선택" in str(label) for label in start.get("labels") or [])
        or not start.get("commandResult")
        or not notice.startswith("세이브 후보")
    ):
        raise WebDriverError(f"save-point candidate command did not start: {start!r}")
    state = wait_for_save_point_candidate_menu_state(port, session_id)
    return start, state


def inject_save_point_recovery_state_script() -> str:
    return """
runtimeState = {
  money: 5,
  items: [],
  characters: [{
    name: 'Ataho',
    level: 1,
    hp: 5,
    hpMax: 36,
    mp: 1,
    mpMax: 8,
    exp: 0,
    expMax: 100,
    baseStats: { atk: 1, def: 1 },
    enhanceStats: {},
    statuses: ['poison', 'paralysis'],
  }],
};
loadedSaveSummary = null;
publishSavedatDebugSummary(null);
updateRuntimeSaveControls();
return runtimeState;
"""


def title_state_script() -> str:
    return """
const titleMenu = typeof titleMenuItems === 'function' ? titleMenuItems() : [];
return {
  readyState: document.readyState,
  href: location.href,
  search: location.search,
  hasScreen: !!document.getElementById('screen'),
  scene: typeof scene === 'undefined' ? '' : scene,
  mapName: typeof map === 'undefined' || !map ? '' : map.name,
  titleLoaded: !!images?.title?.complete && (images.title.naturalWidth || 0) > 0,
  quickLoadHidden: document.getElementById('quickLoadButton')?.hidden ?? null,
  quickLoadText: document.getElementById('quickLoadButton')?.textContent || '',
  titleMenuMode: typeof titleMenuMode === 'undefined' ? '' : titleMenuMode,
  titleMenuKeys: titleMenu.map((item) => item.key),
  titleMenuLabels: titleMenu.map((item) => item.label),
  selectedTitleMenuIndex: typeof selectedTitleMenuIndex === 'undefined' ? null : selectedTitleMenuIndex,
  bootError: window.HWANSE_BOOT_ERROR || null,
};
"""


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,
  title: button.title,
  titleMenuKeys: items.map((item) => item.key),
  titleMenuLabels: items.map((item) => item.label),
  selectedTitleMenuIndex,
};
"""


def start_save_point_action_script() -> str:
    return """
window.__hwanseSavePointAction = null;
localStorage.removeItem(RUNTIME_SAVE_KEY);
window.HWANSE_SAVE_POINT_FEEDBACK_LOG = [];
window.HWANSE_SAVE_POINT_FEEDBACK_RENDER = [];
window.HWANSE_LAST_SAVE_POINT_FEEDBACK = null;
window.HWANSE_LAST_SAVE_POINT_FEEDBACK_RENDER = null;
if (typeof activeSavePointFeedbacks !== 'undefined') activeSavePointFeedbacks = [];
Promise.all([ensureSceneEvents(), ensureSavePointCandidates(), ensureEventDialogueBlocks()])
  .then(() => {
    runtimeState = {
      money: 5,
      items: [],
      characters: [{
        name: 'Ataho',
        level: 1,
        hp: 5,
        hpMax: 36,
        mp: 1,
        mpMax: 8,
        exp: 0,
        expMax: 100,
        baseStats: { atk: 1, def: 1 },
        enhanceStats: {},
        statuses: ['poison', 'paralysis'],
      }],
    };
    loadedSaveSummary = null;
    const tileX = 11;
    const tileY = 11;
    setPlayerPosition(tileX * map.tileSize - 16, tileY * map.tileSize - 31);
    publishPrototypeCompletionState();
    drawActionPrompt();
    const promptBefore = window.HWANSE_LAST_ACTION_PROMPT || null;
    const actionResult = activateHotspotAtFoot();
    const deadline = performance.now() + 3000;
    function finishWhenReady() {
      const text = localStorage.getItem(RUNTIME_SAVE_KEY) || '';
      let savedPayload = null;
      try {
        savedPayload = text ? JSON.parse(text) : null;
      } catch (error) {
        savedPayload = { error: String(error && error.message || error) };
      }
      const completion = publishPrototypeCompletionState();
      const savePoint = completion?.savePoint || null;
      const runtimeSave = window.HWANSE_LAST_SAVE_POINT_RUNTIME_SAVE || null;
      if (typeof render === 'function') render();
      if (
        performance.now() < deadline
        && (!runtimeSave?.saved || !savedPayload || savePoint?.completed !== true)
      ) {
        requestAnimationFrame(finishWhenReady);
        return;
      }
      window.__hwanseSavePointAction = {
        actionResult,
        promptBefore,
        map: map?.name || '',
        activeId: activeDialogue?.block?.blockId || '',
        activeLines: activeDialogue?.lines || [],
        last: window.HWANSE_LAST_SAVE_POINT_CANDIDATES || null,
        runtimeSave,
        completion,
        progress: window.HWANSE_LAST_PROTOTYPE_PROGRESS || null,
        savePointFeedbackLog: window.HWANSE_SAVE_POINT_FEEDBACK_LOG || [],
        savePointFeedbackRender: window.HWANSE_SAVE_POINT_FEEDBACK_RENDER || [],
        savePointFeedbackLast: window.HWANSE_LAST_SAVE_POINT_FEEDBACK || null,
        savePointFeedbackLastRender: window.HWANSE_LAST_SAVE_POINT_FEEDBACK_RENDER || null,
        savedPayload,
        runtimeState,
        labels: menuItems().map((item) => menuItemLabel(item)),
      };
    }
    finishWhenReady();
  })
  .catch((error) => {
    window.__hwanseSavePointAction = { error: String(error && error.message || error) };
  });
return true;
"""


def save_point_action_state_script() -> str:
    return "return window.__hwanseSavePointAction || null;"


def save_point_candidate_menu_selection_script() -> str:
    return """
window.__hwanseSavePointCandidateMenuSelection = null;
localStorage.removeItem(RUNTIME_SAVE_KEY);
if (typeof createPrototypeProgressState === 'function') {
  prototypeProgress = createPrototypeProgressState();
  publishPrototypeProgress();
}
window.HWANSE_SAVE_POINT_FEEDBACK_LOG = [];
window.HWANSE_SAVE_POINT_FEEDBACK_RENDER = [];
window.HWANSE_LAST_SAVE_POINT_FEEDBACK = null;
window.HWANSE_LAST_SAVE_POINT_FEEDBACK_RENDER = null;
window.HWANSE_LAST_SAVE_POINT_CANDIDATE_MENU = null;
window.HWANSE_LAST_SAVE_POINT_CANDIDATE_MENU_SELECTION = null;
if (typeof activeSavePointFeedbacks !== 'undefined') activeSavePointFeedbacks = [];
Promise.all([ensureSceneEvents(), ensureSavePointReviewData()])
  .then(() => {
    runtimeState = {
      money: 5,
      items: [],
      characters: [{
        name: 'Ataho',
        level: 1,
        hp: 5,
        hpMax: 36,
        mp: 1,
        mpMax: 8,
        exp: 0,
        expMax: 100,
        baseStats: { atk: 1, def: 1 },
        enhanceStats: {},
        statuses: ['poison', 'paralysis'],
      }],
    };
    loadedSaveSummary = null;
    activeDialogue = null;
    menuOpen = true;
    menuMode = 'main';
    selectedMenuItemIndex = 0;
    const beforeMenuItems = menuItems();
    const beforeLabels = beforeMenuItems.map((item) => menuItemLabel(item));
    const directIndex = beforeMenuItems.findIndex((item) => item.command === 'showSavePointCandidates');
    const openIndex = beforeMenuItems.findIndex((item) => item.command === 'openSavePointCandidateMenu');
    selectedMenuItemIndex = openIndex;
    const commandResult = useSelectedMenuItem();
    const afterOpenMenuMode = menuMode;
    const candidateItems = menuItems();
    const targetIndex = candidateItems.findIndex(
      (item) => item.command === 'selectSavePointCandidate' && item.savePointCandidate?.blockId === 'event-dialogue-block-033',
    );
    const targetName = candidateItems[targetIndex]?.name || '';
    selectedMenuItemIndex = targetIndex;
    const selectResult = useSelectedMenuItem();
    if (typeof render === 'function') render();
    let savedPayload = null;
    try {
      const text = localStorage.getItem(RUNTIME_SAVE_KEY) || '';
      savedPayload = text ? JSON.parse(text) : null;
    } catch (error) {
      savedPayload = { error: String(error && error.message || error) };
    }
    const completion = publishPrototypeCompletionState();
    window.__hwanseSavePointCandidateMenuSelection = {
      commandResult,
      selectResult,
      before: {
        labels: beforeLabels,
        directIndex,
        openIndex,
        afterOpenMenuMode,
        candidateLabels: candidateItems.map((item) => menuItemLabel(item)),
        targetIndex,
        targetName,
      },
      marker: window.HWANSE_LAST_SAVE_POINT_CANDIDATE_MENU || null,
      selection: window.HWANSE_LAST_SAVE_POINT_CANDIDATE_MENU_SELECTION || null,
      scene,
      map: map?.name || '',
      menuOpen,
      menuMode,
      activeId: activeDialogue?.block?.blockId || '',
      activeLines: activeDialogue?.lines || [],
      last: window.HWANSE_LAST_SAVE_POINT_CANDIDATES || null,
      runtimeSave: window.HWANSE_LAST_SAVE_POINT_RUNTIME_SAVE || null,
      completion,
      progress: window.HWANSE_LAST_PROTOTYPE_PROGRESS || null,
      savePointFeedbackLog: window.HWANSE_SAVE_POINT_FEEDBACK_LOG || [],
      savePointFeedbackRender: window.HWANSE_SAVE_POINT_FEEDBACK_RENDER || [],
      savePointFeedbackLast: window.HWANSE_LAST_SAVE_POINT_FEEDBACK || null,
      savePointFeedbackLastRender: window.HWANSE_LAST_SAVE_POINT_FEEDBACK_RENDER || null,
      savedPayload,
      runtimeState,
      labels: menuItems().map((item) => menuItemLabel(item)),
    };
  })
  .catch((error) => {
    window.__hwanseSavePointCandidateMenuSelection = { error: String(error && error.message || error) };
  });
return true;
"""


def save_point_candidate_menu_selection_state_script() -> str:
    return "return window.__hwanseSavePointCandidateMenuSelection || null;"


def reset_save_point_runtime_progress_script() -> str:
    return """
localStorage.removeItem(RUNTIME_SAVE_KEY);
if (typeof createPrototypeProgressState === 'function') {
  prototypeProgress = createPrototypeProgressState();
  publishPrototypeProgress();
}
activeDialogue = null;
menuOpen = false;
menuMode = 'main';
selectedMenuItemIndex = 0;
window.HWANSE_SAVE_POINT_FEEDBACK_LOG = [];
window.HWANSE_SAVE_POINT_FEEDBACK_RENDER = [];
window.HWANSE_LAST_SAVE_POINT_FEEDBACK = null;
window.HWANSE_LAST_SAVE_POINT_FEEDBACK_RENDER = null;
window.HWANSE_LAST_SAVE_POINT_CANDIDATE_MENU = null;
window.HWANSE_LAST_SAVE_POINT_CANDIDATE_MENU_SELECTION = null;
window.HWANSE_LAST_SAVE_POINT_CANDIDATES = null;
window.HWANSE_LAST_SAVE_POINT_RUNTIME_SAVE = null;
window.HWANSE_LAST_SAVE_POINT_AUTO_SAVE = null;
if (typeof activeSavePointFeedbacks !== 'undefined') activeSavePointFeedbacks = [];
return true;
"""


def wait_for_save_point_candidate_menu_selection(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, save_point_candidate_menu_selection_state_script(), timeout=3)
        state = value if isinstance(value, dict) else {}
        save_point = ((state.get("completion") or {}).get("savePoint") or {})
        if state and state.get("error") is None and state.get("selectResult") is True and save_point.get("completed") is True:
            return state
        time.sleep(0.2)
    raise WebDriverError(f"save-point candidate menu selection did not finish: {state!r}")


def wait_for_save_point_action_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, save_point_action_state_script(), timeout=3)
        state = value if isinstance(value, dict) else {}
        save_point = ((state.get("completion") or {}).get("savePoint") or {})
        if state and state.get("error") is None and state.get("actionResult") is True and save_point.get("completed") is True:
            return state
        time.sleep(0.2)
    raise WebDriverError(f"save-point action did not finish: {state!r}")


def capture_save_point_completion_after_title_continue_script() -> str:
    return """
window.__hwanseSavePointTitleRestore = null;
function resetSavePointFeedback() {
  window.HWANSE_SAVE_POINT_FEEDBACK_LOG = [];
  window.HWANSE_SAVE_POINT_FEEDBACK_RENDER = [];
  window.HWANSE_LAST_SAVE_POINT_FEEDBACK = null;
  window.HWANSE_LAST_SAVE_POINT_FEEDBACK_RENDER = null;
  if (typeof activeSavePointFeedbacks !== 'undefined') activeSavePointFeedbacks = [];
}
quickLoadRuntime()
  .then((loaded) => ensureSavePointCandidates().then(() => {
    const completion = publishPrototypeCompletionState();
    drawActionPrompt();
    const actionPrompt = window.HWANSE_LAST_ACTION_PROMPT || null;
    const text = localStorage.getItem(RUNTIME_SAVE_KEY) || '';
    let savedPayload = null;
    try {
      savedPayload = text ? JSON.parse(text) : null;
    } catch (error) {
      savedPayload = { error: String(error && error.message || error) };
    }
    window.__hwanseSavePointTitleRestore = {
      loaded,
      titleContinue: true,
      scene,
      map: map?.name || '',
      search: location.search,
      actionPrompt,
      completion,
      progress: window.HWANSE_LAST_PROTOTYPE_PROGRESS || null,
      savedPayload,
      runtimeState,
      labels: menuItems().map((item) => menuItemLabel(item)),
      quickLoadHidden: document.getElementById('quickLoadButton')?.hidden ?? null,
      quickLoadText: document.getElementById('quickLoadButton')?.textContent || '',
    };
    const objectiveBefore = prototypeObjectiveState();
    window.HWANSE_LAST_OBJECTIVE_ACTION = null;
    window.HWANSE_LAST_SAVE_POINT_COMPLETION_NOTICE = null;
    resetSavePointFeedback();
    const objectiveResult = activatePrototypeObjectiveAction();
    render();
    const objectiveAction = window.HWANSE_LAST_OBJECTIVE_ACTION || null;
    const objectiveCompletionNotice = window.HWANSE_LAST_SAVE_POINT_COMPLETION_NOTICE || null;
    const objectiveSavePointFeedbackLog = window.HWANSE_SAVE_POINT_FEEDBACK_LOG || [];
    const objectiveSavePointFeedbackRender = window.HWANSE_SAVE_POINT_FEEDBACK_RENDER || [];
    const objectiveSavePointFeedbackLast = window.HWANSE_LAST_SAVE_POINT_FEEDBACK || null;
    const objectiveSavePointFeedbackLastRender = window.HWANSE_LAST_SAVE_POINT_FEEDBACK_RENDER || null;
    const objectiveActiveDialogueBlock = activeDialogue ? {
      blockId: activeDialogue.block?.blockId || '',
      line: activeDialogue.lines?.[activeDialogue.index] || '',
      lineCount: activeDialogue.lines?.length || 0,
    } : null;
    activeDialogue = null;
    window.__hwanseSavePointTitleRestore.objectiveBefore = objectiveBefore;
    window.__hwanseSavePointTitleRestore.objectiveResult = objectiveResult;
    window.__hwanseSavePointTitleRestore.objectiveAction = objectiveAction;
    window.__hwanseSavePointTitleRestore.objectiveCompletionNotice = objectiveCompletionNotice;
    window.__hwanseSavePointTitleRestore.objectiveSavePointFeedbackLog = objectiveSavePointFeedbackLog;
    window.__hwanseSavePointTitleRestore.objectiveSavePointFeedbackRender = objectiveSavePointFeedbackRender;
    window.__hwanseSavePointTitleRestore.objectiveSavePointFeedbackLast = objectiveSavePointFeedbackLast;
    window.__hwanseSavePointTitleRestore.objectiveSavePointFeedbackLastRender = objectiveSavePointFeedbackLastRender;
    window.__hwanseSavePointTitleRestore.objectiveActiveDialogueBlock = objectiveActiveDialogueBlock;
  }))
  .catch((error) => {
    window.__hwanseSavePointTitleRestore = { error: String(error && error.message || error) };
  });
return true;
"""


def restore_save_point_completion_script() -> str:
    return """
window.__hwanseSavePointCompletionRestore = null;
function resetSavePointFeedback() {
  window.HWANSE_SAVE_POINT_FEEDBACK_LOG = [];
  window.HWANSE_SAVE_POINT_FEEDBACK_RENDER = [];
  window.HWANSE_LAST_SAVE_POINT_FEEDBACK = null;
  window.HWANSE_LAST_SAVE_POINT_FEEDBACK_RENDER = null;
  if (typeof activeSavePointFeedbacks !== 'undefined') activeSavePointFeedbacks = [];
}
quickLoadRuntime()
  .then((loaded) => ensureSavePointCandidates().then(() => {
    const completion = publishPrototypeCompletionState();
    drawActionPrompt();
    const actionPrompt = window.HWANSE_LAST_ACTION_PROMPT || null;
    const text = localStorage.getItem(RUNTIME_SAVE_KEY) || '';
    let savedPayload = null;
    try {
      savedPayload = text ? JSON.parse(text) : null;
    } catch (error) {
      savedPayload = { error: String(error && error.message || error) };
    }
    window.__hwanseSavePointCompletionRestore = {
      loaded,
      scene,
      map: map?.name || '',
      actionPrompt,
      completion,
      progress: window.HWANSE_LAST_PROTOTYPE_PROGRESS || null,
      savedPayload,
      runtimeState,
      labels: menuItems().map((item) => menuItemLabel(item)),
    };
    const objectiveBefore = prototypeObjectiveState();
    window.HWANSE_LAST_OBJECTIVE_ACTION = null;
    window.HWANSE_LAST_SAVE_POINT_COMPLETION_NOTICE = null;
    resetSavePointFeedback();
    const objectiveResult = activatePrototypeObjectiveAction();
    render();
    const objectiveAction = window.HWANSE_LAST_OBJECTIVE_ACTION || null;
    const objectiveCompletionNotice = window.HWANSE_LAST_SAVE_POINT_COMPLETION_NOTICE || null;
    const objectiveSavePointFeedbackLog = window.HWANSE_SAVE_POINT_FEEDBACK_LOG || [];
    const objectiveSavePointFeedbackRender = window.HWANSE_SAVE_POINT_FEEDBACK_RENDER || [];
    const objectiveSavePointFeedbackLast = window.HWANSE_LAST_SAVE_POINT_FEEDBACK || null;
    const objectiveSavePointFeedbackLastRender = window.HWANSE_LAST_SAVE_POINT_FEEDBACK_RENDER || null;
    const objectiveActiveDialogueBlock = activeDialogue ? {
      blockId: activeDialogue.block?.blockId || '',
      line: activeDialogue.lines?.[activeDialogue.index] || '',
      lineCount: activeDialogue.lines?.length || 0,
    } : null;
    activeDialogue = null;
    window.__hwanseSavePointCompletionRestore.objectiveBefore = objectiveBefore;
    window.__hwanseSavePointCompletionRestore.objectiveResult = objectiveResult;
    window.__hwanseSavePointCompletionRestore.objectiveAction = objectiveAction;
    window.__hwanseSavePointCompletionRestore.objectiveCompletionNotice = objectiveCompletionNotice;
    window.__hwanseSavePointCompletionRestore.objectiveSavePointFeedbackLog = objectiveSavePointFeedbackLog;
    window.__hwanseSavePointCompletionRestore.objectiveSavePointFeedbackRender = objectiveSavePointFeedbackRender;
    window.__hwanseSavePointCompletionRestore.objectiveSavePointFeedbackLast = objectiveSavePointFeedbackLast;
    window.__hwanseSavePointCompletionRestore.objectiveSavePointFeedbackLastRender = objectiveSavePointFeedbackLastRender;
    window.__hwanseSavePointCompletionRestore.objectiveActiveDialogueBlock = objectiveActiveDialogueBlock;
  }))
  .catch((error) => {
    window.__hwanseSavePointCompletionRestore = { error: String(error && error.message || error) };
  });
return true;
"""


def save_point_completion_restore_state_script() -> str:
    return "return window.__hwanseSavePointCompletionRestore || null;"


def save_point_title_restore_state_script() -> str:
    return "return window.__hwanseSavePointTitleRestore || null;"


def wait_for_save_point_completion_restore(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, save_point_completion_restore_state_script(), timeout=3)
        state = value if isinstance(value, dict) else {}
        save_point = ((state.get("completion") or {}).get("savePoint") or {})
        if state and state.get("error") is None and state.get("loaded") is True and save_point.get("completed") is True:
            return state
        time.sleep(0.2)
    raise WebDriverError(f"save-point completion restore did not finish: {state!r}")


def wait_for_save_point_title_ready(port: int, session_id: str, timeout: float = 10) -> 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("readyState") == "complete"
            and state.get("hasScreen") is True
            and state.get("scene") == "title"
            and state.get("titleLoaded") is True
            and state.get("bootError") is None
            and state.get("quickLoadHidden") is False
            and state.get("quickLoadText") == "이어하기"
            and "continue" in (state.get("titleMenuKeys") or [])
            and any("이어하기 map2_07e" in label for label in labels)
        ):
            return state
        time.sleep(0.2)
    raise WebDriverError(f"save-point title continue did not become ready: {state!r}")


def wait_for_save_point_title_restore(port: int, session_id: str, timeout: float = 10) -> dict:
    deadline = time.monotonic() + timeout
    state: dict = {}
    while time.monotonic() < deadline:
        value = execute_js(port, session_id, save_point_title_restore_state_script(), timeout=3)
        state = value if isinstance(value, dict) else {}
        save_point = ((state.get("completion") or {}).get("savePoint") or {})
        if (
            state
            and state.get("error") is None
            and state.get("titleContinue") is True
            and state.get("map") == "map2_07e"
            and save_point.get("completed") is True
        ):
            return state
        time.sleep(0.2)
    raise WebDriverError(f"save-point title continue restore did not finish: {state!r}")


def verify_global_review_state(state: dict) -> None:
    lines = [str(line) for line in state.get("activeLines") or []]
    last = state.get("last") or {}
    runtime = state.get("runtimeSave") or {}
    if (
        state.get("map") != "map1_01a"
        or state.get("activeId") != "save-point-candidates:map1_01a"
        or last.get("scope") != "global-review"
        or last.get("count") != 6
        or runtime.get("eligible") is not False
        or runtime.get("saved") is not False
        or runtime.get("originalSavePointRuntimeImplemented") is not False
        or "세이브 후보 map1_01a: 6개 (전체 후보 검토)" not in lines
        or "원본 저장 실행이 아니라 후보 텍스트 검토입니다." not in lines
    ):
        raise WebDriverError(f"global save-point review should stay read-only: {state!r}")


def verify_current_map_save_state(state: dict) -> None:
    lines = [str(line) for line in state.get("activeLines") or []]
    labels_text = " ".join(str(label) for label in state.get("labels") or [])
    last = state.get("last") or {}
    runtime = state.get("runtimeSave") or {}
    payload = state.get("savedPayload") or {}
    completion = state.get("completion") or {}
    save_point = completion.get("savePoint") or {}
    progress = state.get("progress") or {}
    progress_record = progress.get("lastEvent") or {}
    progress_detail = progress_record.get("detail") or {}
    runtime_tile = runtime.get("tile") or {}
    auto_save = runtime.get("autoSave") or {}
    auto_save_counts = ((auto_save.get("progress") or {}).get("counts") or {})
    payload_tile = payload.get("tile") or {}
    payload_events = ((payload.get("prototypeProgress") or {}).get("events") or [])
    payload_save_event = next(
        (
            event
            for event in payload_events
            if event.get("kind") == "save-point-candidate" and event.get("id") == "save-point-candidates:map2_07e"
        ),
        {},
    )
    payload_save_detail = payload_save_event.get("detail") or {}
    rest = runtime.get("rest") or {}
    payload_character = (((payload.get("runtimeState") or {}).get("characters") or [{}])[0])
    feedback_log = state.get("savePointFeedbackLog") or []
    feedback = state.get("savePointFeedbackLast") or {}
    feedback_render = state.get("savePointFeedbackLastRender") or {}
    verify_save_point_vm_replays(runtime.get("vmReplays") or [])
    verify_save_point_vm_replays(progress_detail.get("vmReplays") or [])
    verify_save_point_vm_replays(auto_save.get("vmReplays") or [])
    verify_save_point_vm_replays(payload_save_detail.get("vmReplays") or [])
    verify_save_point_candidate_evidence(runtime.get("candidateEvidence") or [])
    verify_save_point_candidate_evidence(progress_detail.get("candidateEvidence") or [])
    verify_save_point_candidate_evidence(auto_save.get("candidateEvidence") or [])
    verify_save_point_candidate_evidence(payload_save_detail.get("candidateEvidence") or [])
    verify_save_point_candidate_evidence(feedback.get("candidateEvidence") or [])
    verify_save_point_candidate_evidence(feedback_render.get("candidateEvidence") or [])
    if (
        state.get("map") != "map2_07e"
        or state.get("activeId") != "save-point-candidates:map2_07e"
        or last.get("scope") != "current-map"
        or last.get("count") != 1
        or runtime.get("eligible") is not True
        or runtime.get("saved") is not True
        or auto_save.get("saved") is not True
        or auto_save.get("source") != "save-point-candidate"
        or auto_save.get("payloadMap") != "map2_07e"
        or auto_save_counts.get("save-point-candidate") != 1
        or rest.get("applied") is not True
        or rest.get("recoveredCount") != 1
        or rest.get("curedStatusCount") != 2
        or rest.get("originalRecoveryEventImplemented") is not False
        or runtime.get("originalSavePointRuntimeImplemented") is not False
        or save_point.get("id") != "save-point-candidates:map2_07e"
        or save_point.get("completed") is not True
        or save_point.get("completedCount") != 1
        or save_point.get("remainingCount") != 0
        or save_point.get("originalSavePointRuntimeImplemented") is not False
        or completion.get("originalStoryFlagRuntimeImplemented") is not False
        or (progress.get("counts") or {}).get("save-point-candidate") != 1
        or progress_detail.get("recovered") is not True
        or progress_detail.get("recoveredCount") != 1
        or progress_detail.get("curedStatusCount") != 2
        or progress_detail.get("originalRecoveryEventImplemented") is not False
        or payload_save_detail.get("saved") is not True
        or payload_save_detail.get("recovered") is not True
        or payload_save_detail.get("originalSavePointRuntimeImplemented") is not False
        or len(feedback_log) < 1
        or feedback.get("source") != "save-point-feedback"
        or feedback.get("saveSource") != "save-point-candidate"
        or feedback.get("text") != "세이브 완료 1"
        or feedback.get("map") != "map2_07e"
        or feedback.get("scope") != "current-map"
        or feedback.get("count") != 1
        or "event-dialogue-block-033" not in (feedback.get("blockIds") or [])
        or feedback.get("recovered") is not True
        or feedback.get("recoveredCount") != 1
        or feedback.get("curedStatusCount") != 2
        or feedback.get("savePointSound") != "menuConfirm"
        or not str(feedback.get("savePointSoundSrc") or "").endswith("/extract_wlk/04.wav")
        or feedback.get("savePointSoundPlayed") is not True
        or feedback.get("durationMs") != 1100
        or feedback.get("browserSavePointFeedbackImplemented") is not True
        or feedback.get("prototypeSavePointImplemented") is not True
        or feedback.get("originalSavePointRuntimeImplemented") is not False
        or feedback.get("originalRecoveryEventImplemented") is not False
        or feedback.get("originalStoryFlagRuntimeImplemented") is not False
        or feedback_render.get("active") is not True
        or feedback_render.get("source") != "save-point-feedback"
        or feedback_render.get("text") != "세이브 완료 1"
        or feedback_render.get("scope") != "current-map"
        or feedback_render.get("recoveredCount") != 1
        or feedback_render.get("curedStatusCount") != 2
        or feedback_render.get("savePointSound") != "menuConfirm"
        or not str(feedback_render.get("savePointSoundSrc") or "").endswith("/extract_wlk/04.wav")
        or feedback_render.get("savePointSoundPlayed") is not True
        or feedback_render.get("durationMs") != 1100
        or feedback_render.get("browserSavePointFeedbackImplemented") is not True
        or "세이브 선택" not in labels_text
        or "event-dialogue-block-033" not in (runtime.get("blockIds") or [])
        or payload.get("version") != 1
        or payload.get("map") != "map2_07e"
        or payload_tile != runtime_tile
        or payload_character.get("hp") != 36
        or payload_character.get("mp") != 8
        or payload_character.get("statuses") != []
        or "후보 저장 map2_07e: 완료 (현재 맵 직접 연결)" not in lines
        or "원본 저장 실행 증명이 아니라 프로토타입 저장입니다." not in lines
        or "HP/MP 상태 회복 1명 상태해제 2개" not in lines
        or "현재 위치와 상태를 임시 저장 슬롯에 보존했습니다." not in lines
    ):
        raise WebDriverError(f"current-map save-point candidate should auto-save: {state!r}")


def verify_save_point_candidate_menu_selection(state: dict) -> None:
    verify_current_map_save_state(state)
    before = state.get("before") or {}
    marker = state.get("marker") or {}
    selection = state.get("selection") or {}
    runtime = state.get("runtimeSave") or {}
    progress_detail = (((state.get("progress") or {}).get("lastEvent") or {}).get("detail") or {})
    auto_save = runtime.get("autoSave") or {}
    feedback = state.get("savePointFeedbackLast") or {}
    feedback_render = state.get("savePointFeedbackLastRender") or {}
    payload_events = (((state.get("savedPayload") or {}).get("prototypeProgress") or {}).get("events") or [])
    payload_save_event = next(
        (
            event
            for event in payload_events
            if event.get("kind") == "save-point-candidate" and event.get("id") == "save-point-candidates:map2_07e"
        ),
        {},
    )
    payload_detail = payload_save_event.get("detail") or {}
    if (
        state.get("commandResult") is not True
        or state.get("selectResult") is not True
        or before.get("directIndex") != -1
        or before.get("openIndex", -1) < 0
        or before.get("afterOpenMenuMode") != "save-point-candidate"
        or before.get("targetIndex", -1) < 0
        or "세이브 선택" not in " ".join(str(label) for label in before.get("labels") or [])
        or "세이브 1/1 신의 산" not in " ".join(str(label) for label in before.get("candidateLabels") or [])
        or before.get("targetName") != "세이브 1/1 신의 산"
        or state.get("menuMode") != "main"
        or state.get("menuOpen") is not False
        or marker.get("source") != "prototype-save-point-candidate-menu"
        or marker.get("map") != "map2_07e"
        or marker.get("scope") != "current-map"
        or marker.get("count") != 1
        or marker.get("originalSavePointRuntimeImplemented") is not False
        or marker.get("originalStoryFlagRuntimeImplemented") is not False
        or (marker.get("choices") or [{}])[0].get("blockId") != "event-dialogue-block-033"
        or selection.get("source") != "prototype-save-point-candidate-menu"
        or selection.get("map") != "map2_07e"
        or selection.get("scope") != "current-map"
        or selection.get("blockId") != "event-dialogue-block-033"
        or selection.get("index") != 0
        or selection.get("count") != 1
        or selection.get("saved") is not True
        or selection.get("eligible") is not True
        or selection.get("originalSavePointRuntimeImplemented") is not False
        or selection.get("originalStoryFlagRuntimeImplemented") is not False
        or (state.get("last") or {}).get("selectionSource") != "prototype-save-point-candidate-menu"
        or (state.get("last") or {}).get("menuCandidateIndex") != 0
        or (state.get("last") or {}).get("menuCandidateCount") != 1
        or runtime.get("selectionSource") != "prototype-save-point-candidate-menu"
        or runtime.get("menuCandidateIndex") != 0
        or runtime.get("menuCandidateCount") != 1
        or progress_detail.get("selectionSource") != "prototype-save-point-candidate-menu"
        or progress_detail.get("menuCandidateIndex") != 0
        or progress_detail.get("menuCandidateCount") != 1
        or auto_save.get("selectionSource") != "prototype-save-point-candidate-menu"
        or auto_save.get("menuCandidateIndex") != 0
        or auto_save.get("menuCandidateCount") != 1
        or payload_detail.get("selectionSource") != "prototype-save-point-candidate-menu"
        or payload_detail.get("menuCandidateIndex") != 0
        or payload_detail.get("menuCandidateCount") != 1
        or feedback.get("selectionSource") != "prototype-save-point-candidate-menu"
        or feedback.get("menuCandidateIndex") != 0
        or feedback.get("menuCandidateCount") != 1
        or feedback_render.get("selectionSource") != "prototype-save-point-candidate-menu"
        or feedback_render.get("menuCandidateIndex") != 0
        or feedback_render.get("menuCandidateCount") != 1
    ):
        raise WebDriverError(f"save-point candidate menu selection should auto-save selected current-map candidate: {state!r}")


def verify_save_point_action_state(state: dict) -> None:
    verify_current_map_save_state(state)
    prompt = state.get("promptBefore") or {}
    if (
        state.get("actionResult") is not True
        or prompt.get("kind") != "save-point-candidate"
        or prompt.get("id") != "save-point-candidates:map2_07e"
        or prompt.get("scope") != "current-map"
        or prompt.get("completed") is not False
        or prompt.get("count") != 1
        or "Enter -> 세이브 후보 1" != prompt.get("text")
    ):
        raise WebDriverError(f"save-point action prompt/path should auto-save current-map candidate: {state!r}")


def verify_save_point_completion_notice_feedback(state: dict) -> None:
    feedback_log = state.get("objectiveSavePointFeedbackLog") or []
    feedback_render_log = state.get("objectiveSavePointFeedbackRender") or []
    feedback = state.get("objectiveSavePointFeedbackLast") or {}
    feedback_render = state.get("objectiveSavePointFeedbackLastRender") or {}
    verify_save_point_candidate_evidence(feedback.get("candidateEvidence") or [])
    verify_save_point_candidate_evidence(feedback_render.get("candidateEvidence") or [])
    if (
        len(feedback_log) < 1
        or len(feedback_render_log) < 1
        or feedback.get("source") != "save-point-completion-notice-feedback"
        or feedback.get("saveSource") != "save-point-completion-notice"
        or feedback.get("text") != "세이브 완료 1"
        or feedback.get("map") != "map2_07e"
        or feedback.get("scope") != "current-map"
        or feedback.get("count") != 1
        or "event-dialogue-block-033" not in (feedback.get("blockIds") or [])
        or feedback.get("recovered") is not False
        or feedback.get("recoveredCount") != 0
        or feedback.get("curedStatusCount") != 0
        or feedback.get("savePointSound") != "menuConfirm"
        or not str(feedback.get("savePointSoundSrc") or "").endswith("/extract_wlk/04.wav")
        or feedback.get("savePointSoundPlayed") is not True
        or feedback.get("durationMs") != 1100
        or feedback.get("browserSavePointFeedbackImplemented") is not True
        or feedback.get("prototypeSavePointImplemented") is not True
        or feedback.get("originalSavePointRuntimeImplemented") is not False
        or feedback.get("originalRecoveryEventImplemented") is not False
        or feedback.get("originalStoryFlagRuntimeImplemented") is not False
        or feedback_render.get("active") is not True
        or feedback_render.get("source") != "save-point-completion-notice-feedback"
        or feedback_render.get("saveSource") != "save-point-completion-notice"
        or feedback_render.get("text") != "세이브 완료 1"
        or feedback_render.get("map") != "map2_07e"
        or feedback_render.get("scope") != "current-map"
        or feedback_render.get("count") != 1
        or "event-dialogue-block-033" not in (feedback_render.get("blockIds") or [])
        or feedback_render.get("recovered") is not False
        or feedback_render.get("recoveredCount") != 0
        or feedback_render.get("curedStatusCount") != 0
        or feedback_render.get("savePointSound") != "menuConfirm"
        or not str(feedback_render.get("savePointSoundSrc") or "").endswith("/extract_wlk/04.wav")
        or feedback_render.get("savePointSoundPlayed") is not True
        or feedback_render.get("durationMs") != 1100
        or feedback_render.get("browserSavePointFeedbackImplemented") is not True
        or feedback_render.get("prototypeSavePointImplemented") is not True
        or feedback_render.get("originalSavePointRuntimeImplemented") is not False
        or feedback_render.get("originalRecoveryEventImplemented") is not False
        or feedback_render.get("originalStoryFlagRuntimeImplemented") is not False
    ):
        raise WebDriverError(f"save-point completion notice feedback is incomplete: {state!r}")


def save_point_completion_notice_feedback_summary(state: dict) -> str:
    feedback = state.get("objectiveSavePointFeedbackLast") or {}
    rendered = state.get("objectiveSavePointFeedbackLastRender") or {}
    return (
        f"saveNoticeFeedback={feedback.get('source')}:{feedback.get('text')} "
        f"saveNoticeFeedbackRender={rendered.get('active')} "
        f"saveNoticeSound={feedback.get('savePointSound')}:"
        f"{feedback.get('savePointSoundSrc')}:{feedback.get('savePointSoundPlayed')}"
    )


def verify_save_point_completion_restore(state: dict) -> None:
    completion = state.get("completion") or {}
    save_point = completion.get("savePoint") or {}
    progress = state.get("progress") or {}
    saved_payload = state.get("savedPayload") or {}
    saved_progress = saved_payload.get("prototypeProgress") or {}
    saved_events = saved_progress.get("events") or []
    saved_save_event = next(
        (
            event
            for event in saved_events
            if event.get("kind") == "save-point-candidate" and event.get("id") == "save-point-candidates:map2_07e"
        ),
        {},
    )
    verify_save_point_vm_replays((saved_save_event.get("detail") or {}).get("vmReplays") or [])
    verify_save_point_candidate_evidence((saved_save_event.get("detail") or {}).get("candidateEvidence") or [])
    runtime_character = (((state.get("runtimeState") or {}).get("characters") or [{}])[0])
    labels_text = " ".join(str(label) for label in state.get("labels") or [])
    prompt = state.get("actionPrompt") 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 {}
    if (
        state.get("loaded") is not True
        or state.get("scene") != "map"
        or state.get("map") != "map2_07e"
        or prompt.get("kind") != "save-point-candidate"
        or prompt.get("text") != "Enter -> 세이브 완료 1"
        or prompt.get("completed") is not True
        or prompt.get("id") != "save-point-candidates:map2_07e"
        or prompt.get("scope") != "current-map"
        or save_point.get("id") != "save-point-candidates:map2_07e"
        or save_point.get("completed") is not True
        or save_point.get("completedCount") != 1
        or save_point.get("remainingCount") != 0
        or save_point.get("savedEventCount") != 1
        or save_point.get("originalSavePointRuntimeImplemented") is not False
        or completion.get("originalStoryFlagRuntimeImplemented") is not False
        or (progress.get("counts") or {}).get("save-point-candidate") != 1
        or (saved_progress.get("counts") or {}).get("save-point-candidate") != 1
        or runtime_character.get("hp") != 36
        or runtime_character.get("mp") != 8
        or runtime_character.get("statuses") != []
        or "세이브 선택" not in labels_text
        or objective_before.get("title") != "후보 세이브 완료 map2_07e"
        or objective_before.get("nextAction") != "완료 알림 확인"
        or objective_before.get("detail") != "세이브 완료 1/1"
        or objective_before.get("source") != "prototype-save-point-completion"
        or state.get("objectiveResult") is not True
        or objective_action.get("action") != "save-point-completion-notice"
        or objective_action.get("activeId") != "save-point-complete:map2_07e"
        or objective_action.get("originalSavePointRuntimeImplemented") is not False
        or objective_action.get("originalStoryFlagRuntimeImplemented") is not False
        or objective_notice.get("blockId") != "save-point-complete:map2_07e"
        or objective_notice.get("map") != "map2_07e"
        or "세이브 완료 1/1" not in " ".join(objective_notice.get("lines") or [])
        or (objective_notice.get("completion") or {}).get("completed") is not True
        or objective_notice.get("originalSavePointRuntimeImplemented") is not False
        or objective_notice.get("originalStoryFlagRuntimeImplemented") is not False
        or objective_dialogue.get("blockId") != "save-point-complete:map2_07e"
        or objective_dialogue.get("line") != "세이브 완료 1/1"
        or objective_dialogue.get("lineCount", 0) < 3
    ):
        raise WebDriverError(f"save-point completion should restore from save-point auto-save: {state!r}")
    verify_save_point_completion_notice_feedback(state)


def verify_save_point_title_continue(title_state: dict, click_state: dict, restore_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 [])]
    verify_save_point_completion_restore(restore_state)
    if (
        title_state.get("scene") != "title"
        or title_state.get("quickLoadHidden") is not False
        or title_state.get("quickLoadText") != "이어하기"
        or "continue" not in (title_state.get("titleMenuKeys") or [])
        or not any("이어하기 map2_07e" in label for label in title_labels)
        or not isinstance(click_state, dict)
        or click_state.get("ok") is not True
        or click_state.get("text") != "이어하기"
        or not any("이어하기 map2_07e" in label for label in click_labels)
        or restore_state.get("titleContinue") is not True
        or "map=map2_07e" not in str(restore_state.get("search") or "")
        or restore_state.get("quickLoadHidden") is not False
        or restore_state.get("quickLoadText") != "임시 불러오기"
    ):
        raise WebDriverError(
            "save-point title continue did not restore the completed save-point state: "
            f"title={title_state!r} click={click_state!r} restored={restore_state!r}"
        )


def write_report(report: dict) -> None:
    out_dir = ROOT / "out"
    out_dir.mkdir(parents=True, exist_ok=True)
    (out_dir / "candidate_save_point_browser_smoke.json").write_text(
        json.dumps(report, ensure_ascii=False, separators=(",", ":")) + "\n",
        encoding="utf-8",
    )
    lines = [
        "# Candidate Save Point Browser Smoke",
        "",
        f"- status: `{report.get('status')}`",
        f"- global review: `{report.get('globalReview')}`",
        f"- save-point candidate menu: `{report.get('savePointCandidateMenu')}`",
        f"- current-map save: `{report.get('currentMapSave')}`",
        f"- action save: `{report.get('actionSave')}`",
        f"- title restored save point: `{report.get('titleRestoredSavePoint')}`",
        f"- restored save point: `{report.get('restoredSavePoint')}`",
        "",
    ]


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_save_point_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,
            )

            global_url = load_map(base, port, session_id, "map1_01a")
            global_start, global_state = start_save_point_menu(port, session_id)
            verify_global_review_state(global_state)

            current_url = load_map(base, port, session_id, "map2_07e")
            execute_js(port, session_id, "localStorage.removeItem(RUNTIME_SAVE_KEY); return true;")
            execute_js(port, session_id, inject_save_point_recovery_state_script(), timeout=3)
            execute_js(port, session_id, save_point_candidate_menu_selection_script(), timeout=3)
            menu_selection_state = wait_for_save_point_candidate_menu_selection(port, session_id)
            verify_save_point_candidate_menu_selection(menu_selection_state)

            execute_js(port, session_id, reset_save_point_runtime_progress_script(), timeout=3)
            execute_js(port, session_id, inject_save_point_recovery_state_script(), timeout=3)
            current_start, current_state = start_save_point_menu(port, session_id)
            verify_current_map_save_state(current_state)

            action_url = load_map(base, port, session_id, "map2_07e")
            execute_js(port, session_id, start_save_point_action_script(), timeout=3)
            action_state = wait_for_save_point_action_state(port, session_id)
            verify_save_point_action_state(action_state)

            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_save_point_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"save-point title continue button was not usable: {title_click!r}")
            wait_for_map_runtime(port, session_id, "map2_07e")
            execute_js(port, session_id, capture_save_point_completion_after_title_continue_script(), timeout=3)
            title_restore_state = wait_for_save_point_title_restore(port, session_id)
            verify_save_point_title_continue(title_state, title_click, title_restore_state)

            restore_url = load_map(base, port, session_id, "map2_07e")
            execute_js(port, session_id, restore_save_point_completion_script(), timeout=3)
            restore_state = wait_for_save_point_completion_restore(port, session_id)
            verify_save_point_completion_restore(restore_state)

            report = {
                "status": "passed",
                "base": base,
                "globalUrl": global_url,
                "currentUrl": current_url,
                "actionUrl": action_url,
                "titleUrl": title_url,
                "restoreUrl": restore_url,
                "globalReview": (
                    f"{global_state.get('activeId')} "
                    f"scope={(global_state.get('last') or {}).get('scope')} "
                    f"saved={(global_state.get('runtimeSave') or {}).get('saved')}"
                ),
                "savePointCandidateMenu": (
                    f"menuCount={(menu_selection_state.get('marker') or {}).get('count')} "
                    f"selected={(menu_selection_state.get('selection') or {}).get('blockId')} "
                    f"scope={(menu_selection_state.get('selection') or {}).get('scope')} "
                    f"saved={(menu_selection_state.get('selection') or {}).get('saved')} "
                    f"eligible={(menu_selection_state.get('selection') or {}).get('eligible')} "
                    f"selectionSource={(menu_selection_state.get('selection') or {}).get('source')} "
                    f"commandResult={menu_selection_state.get('commandResult')} "
                    f"selectResult={menu_selection_state.get('selectResult')} "
                    f"directMenuIndex={(menu_selection_state.get('before') or {}).get('directIndex')} "
                    f"afterMenuOpen={menu_selection_state.get('menuOpen')} "
                    f"saveFeedback={((menu_selection_state.get('savePointFeedbackLast') or {}).get('source') or '')}:"
                    f"{((menu_selection_state.get('savePointFeedbackLast') or {}).get('text') or '')} "
                    f"savePointSound={((menu_selection_state.get('savePointFeedbackLast') or {}).get('savePointSound') or '')}:"
                    f"{((menu_selection_state.get('savePointFeedbackLast') or {}).get('savePointSoundSrc') or '')}:"
                    f"{((menu_selection_state.get('savePointFeedbackLast') or {}).get('savePointSoundPlayed'))} "
                    f"{save_point_vm_report((menu_selection_state.get('runtimeSave') or {}).get('vmReplays') or [])} "
                    "originalSavePointRuntimeImplemented=False originalStoryFlagRuntimeImplemented=False"
                ),
                "currentMapSave": (
                    f"{current_state.get('activeId')} "
                    f"scope={(current_state.get('last') or {}).get('scope')} "
                    f"saved={(current_state.get('runtimeSave') or {}).get('saved')} "
                    f"recovered={((current_state.get('runtimeSave') or {}).get('rest') or {}).get('applied')} "
                    f"recoveredCount={((current_state.get('runtimeSave') or {}).get('rest') or {}).get('recoveredCount')} "
                    f"curedStatusCount={((current_state.get('runtimeSave') or {}).get('rest') or {}).get('curedStatusCount')} "
                    f"autoSource={((current_state.get('runtimeSave') or {}).get('autoSave') or {}).get('source')} "
                    f"selectMenuLabel={any('세이브 선택' in str(label) for label in (current_state.get('labels') or []))} "
                    f"payloadMap={(current_state.get('savedPayload') or {}).get('map')} "
                    f"blockIds={','.join((current_state.get('runtimeSave') or {}).get('blockIds') or [])} "
                    f"candidateEvidence={','.join(row.get('blockId') or '' for row in ((current_state.get('runtimeSave') or {}).get('candidateEvidence') or []))} "
                    f"candidateCategories={','.join(row.get('category') or '' for row in ((current_state.get('runtimeSave') or {}).get('candidateEvidence') or []))} "
                    f"candidateStrengths={','.join(row.get('strength') or '' for row in ((current_state.get('runtimeSave') or {}).get('candidateEvidence') or []))} "
                    f"saveFeedback={((current_state.get('savePointFeedbackLast') or {}).get('source') or '')}:"
                    f"{((current_state.get('savePointFeedbackLast') or {}).get('text') or '')} "
                    f"saveFeedbackRender={bool(current_state.get('savePointFeedbackRender') or [])} "
                    f"savePointSound={((current_state.get('savePointFeedbackLast') or {}).get('savePointSound') or '')} "
                    f"savePointSoundSrc={((current_state.get('savePointFeedbackLast') or {}).get('savePointSoundSrc') or '')} "
                    f"savePointSoundPlayed={((current_state.get('savePointFeedbackLast') or {}).get('savePointSoundPlayed'))} "
                    f"{save_point_vm_report((current_state.get('runtimeSave') or {}).get('vmReplays') or [])}"
                ),
                "actionSave": (
                    f"{action_state.get('activeId')} "
                    f"prompt={(action_state.get('promptBefore') or {}).get('text')} "
                    f"saved={(action_state.get('runtimeSave') or {}).get('saved')} "
                    f"recovered={((action_state.get('runtimeSave') or {}).get('rest') or {}).get('applied')} "
                    f"recoveredCount={((action_state.get('runtimeSave') or {}).get('rest') or {}).get('recoveredCount')} "
                    f"curedStatusCount={((action_state.get('runtimeSave') or {}).get('rest') or {}).get('curedStatusCount')} "
                    f"autoSource={((action_state.get('runtimeSave') or {}).get('autoSave') or {}).get('source')} "
                    f"payloadMap={(action_state.get('savedPayload') or {}).get('map')} "
                    f"blockIds={','.join((action_state.get('runtimeSave') or {}).get('blockIds') or [])} "
                    f"candidateEvidence={','.join(row.get('blockId') or '' for row in ((action_state.get('runtimeSave') or {}).get('candidateEvidence') or []))} "
                    f"candidateCategories={','.join(row.get('category') or '' for row in ((action_state.get('runtimeSave') or {}).get('candidateEvidence') or []))} "
                    f"candidateStrengths={','.join(row.get('strength') or '' for row in ((action_state.get('runtimeSave') or {}).get('candidateEvidence') or []))} "
                    f"saveFeedback={((action_state.get('savePointFeedbackLast') or {}).get('source') or '')}:"
                    f"{((action_state.get('savePointFeedbackLast') or {}).get('text') or '')} "
                    f"saveFeedbackRender={bool(action_state.get('savePointFeedbackRender') or [])} "
                    f"savePointSound={((action_state.get('savePointFeedbackLast') or {}).get('savePointSound') or '')} "
                    f"savePointSoundSrc={((action_state.get('savePointFeedbackLast') or {}).get('savePointSoundSrc') or '')} "
                    f"savePointSoundPlayed={((action_state.get('savePointFeedbackLast') or {}).get('savePointSoundPlayed'))} "
                    f"{save_point_vm_report((action_state.get('runtimeSave') or {}).get('vmReplays') or [])}"
                ),
                "titleRestoredSavePoint": (
                    "save-point-candidates:map2_07e "
                    "titleContinue=True "
                    f"label={next((label for label in (title_state.get('titleMenuLabels') or []) if '이어하기 map2_07e' in str(label)), '')} "
                    f"completed={((title_restore_state.get('completion') or {}).get('savePoint') or {}).get('completed')} "
                    f"prompt={(title_restore_state.get('actionPrompt') or {}).get('text')} "
                    f"selectMenuLabel={any('세이브 선택' in str(label) for label in (title_restore_state.get('labels') or []))} "
                    f"restoredHp={(((title_restore_state.get('runtimeState') or {}).get('characters') or [{}])[0]).get('hp')} "
                    f"restoredMp={(((title_restore_state.get('runtimeState') or {}).get('characters') or [{}])[0]).get('mp')} "
                    f"restoredStatuses={','.join((((title_restore_state.get('runtimeState') or {}).get('characters') or [{}])[0]).get('statuses') or []) or 'none'} "
                    f"quickLoadText={title_restore_state.get('quickLoadText')} "
                    f"objective={(title_restore_state.get('objectiveBefore') or {}).get('title')} "
                    f"objectiveNext={(title_restore_state.get('objectiveBefore') or {}).get('nextAction')} "
                    f"objectiveAction={(title_restore_state.get('objectiveAction') or {}).get('action')} "
                    f"objectiveActiveId={(title_restore_state.get('objectiveAction') or {}).get('activeId')} "
                    f"{save_point_completion_notice_feedback_summary(title_restore_state)} "
                    "progressRestored=True "
                    "originalStoryFlagRuntimeImplemented=False"
                ),
                "restoredSavePoint": (
                    f"save-point-candidates:map2_07e "
                    f"completed={((restore_state.get('completion') or {}).get('savePoint') or {}).get('completed')} "
                    f"prompt={(restore_state.get('actionPrompt') or {}).get('text')} "
                    f"selectMenuLabel={any('세이브 선택' in str(label) for label in (restore_state.get('labels') or []))} "
                    f"restoredHp={(((restore_state.get('runtimeState') or {}).get('characters') or [{}])[0]).get('hp')} "
                    f"restoredMp={(((restore_state.get('runtimeState') or {}).get('characters') or [{}])[0]).get('mp')} "
                    f"restoredStatuses={','.join((((restore_state.get('runtimeState') or {}).get('characters') or [{}])[0]).get('statuses') or []) or 'none'} "
                    f"objective={(restore_state.get('objectiveBefore') or {}).get('title')} "
                    f"objectiveNext={(restore_state.get('objectiveBefore') or {}).get('nextAction')} "
                    f"objectiveAction={(restore_state.get('objectiveAction') or {}).get('action')} "
                    f"objectiveActiveId={(restore_state.get('objectiveAction') or {}).get('activeId')} "
                    f"{save_point_completion_notice_feedback_summary(restore_state)} "
                    f"progressRestored={restore_state.get('loaded')} "
                    "originalStoryFlagRuntimeImplemented=False"
                ),
                "snapshots": {
                    "globalStart": global_start,
                    "globalState": global_state,
                    "savePointCandidateMenu": menu_selection_state,
                    "currentStart": current_start,
                    "currentState": current_state,
                    "actionState": action_state,
                    "title": title_state,
                    "titleClick": title_click,
                    "titleRestore": title_restore_state,
                    "restoreState": restore_state,
                },
            }
            write_report(report)
            print(
                "ok candidate save point browser "
                f"global={report['globalReview']} menu={report['savePointCandidateMenu']} current={report['currentMapSave']} "
                f"action={report['actionSave']} title={report['titleRestoredSavePoint']}"
            )
        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()
