(() => {
  "use strict";

  const playoutSafety = globalThis.MbnPlayoutSafety;
  if (!playoutSafety) throw new Error("Playout safety module is unavailable.");
  const marketNavReorder = globalThis.MbnMarketNavReorder;
  if (!marketNavReorder) throw new Error("Market-nav reorder module is unavailable.");
  const operatorWorkflow = globalThis.MbnOperatorWorkflow;
  if (!operatorWorkflow) throw new Error("Operator workflow module is unavailable.");
  const legacyBatchSelectionWorkflow = globalThis.MbnLegacyBatchSelectionWorkflow;
  if (!legacyBatchSelectionWorkflow) throw new Error("Legacy batch-selection workflow module is unavailable.");
  const fixedWorkflow = globalThis.MbnLegacyFixedWorkflow;
  if (!fixedWorkflow) throw new Error("Legacy fixed workflow module is unavailable.");
  const industryWorkflow = globalThis.MbnIndustryWorkflow;
  if (!industryWorkflow) throw new Error("Legacy industry workflow module is unavailable.");
  const comparisonWorkflow = globalThis.MbnComparisonWorkflow;
  if (!comparisonWorkflow) throw new Error("Legacy comparison workflow module is unavailable.");
  const comparisonImportWorkflow = globalThis.MbnComparisonImportWorkflow;
  if (!comparisonImportWorkflow) throw new Error("Legacy comparison import workflow module is unavailable.");
  const themeWorkflow = globalThis.MbnThemeWorkflow;
  if (!themeWorkflow) throw new Error("Legacy theme workflow module is unavailable.");
  const overseasWorkflow = globalThis.MbnOverseasWorkflow;
  if (!overseasWorkflow) throw new Error("Legacy overseas workflow module is unavailable.");
  const expertWorkflow = globalThis.MbnExpertWorkflow;
  if (!expertWorkflow) throw new Error("Legacy expert workflow module is unavailable.");
  const tradingHaltWorkflow = globalThis.MbnTradingHaltWorkflow;
  if (!tradingHaltWorkflow) throw new Error("Legacy trading-halt workflow module is unavailable.");
  const candleOptionsWorkflow = globalThis.MbnCandleOptionsWorkflow;
  if (!candleOptionsWorkflow) throw new Error("Global candle-options workflow module is unavailable.");
  const manualListsWorkflow = globalThis.MbnManualListsWorkflow;
  if (!manualListsWorkflow) throw new Error("Manual lists workflow module is unavailable.");
  const manualFinancialWorkflow = globalThis.MbnManualFinancialWorkflow;
  if (!manualFinancialWorkflow) throw new Error("Manual financial workflow module is unavailable.");
  const namedManualRestoreWorkflow = globalThis.MbnNamedManualRestoreWorkflow;
  if (!namedManualRestoreWorkflow) throw new Error("Named manual restore workflow module is unavailable.");
  const namedComparisonRestoreWorkflow = globalThis.MbnNamedComparisonRestoreWorkflow;
  if (!namedComparisonRestoreWorkflow) throw new Error("Named comparison restore workflow module is unavailable.");
  const namedForeignStockRestoreWorkflow = globalThis.MbnNamedForeignStockRestoreWorkflow;
  if (!namedForeignStockRestoreWorkflow) throw new Error("Named foreign-stock restore workflow module is unavailable.");
  const foreignIndexCandleRestoreWorkflow = globalThis.MbnLegacyForeignIndexCandleWorkflow;
  if (!foreignIndexCandleRestoreWorkflow) throw new Error("Legacy foreign-index candle restore workflow module is unavailable.");
  const namedKrxThemeRestoreWorkflow = globalThis.MbnNamedKrxThemeRestoreWorkflow;
  if (!namedKrxThemeRestoreWorkflow) throw new Error("Named KRX-theme restore workflow module is unavailable.");
  const namedNxtThemeRestoreWorkflow = globalThis.MbnNamedNxtThemeRestoreWorkflow;
  if (!namedNxtThemeRestoreWorkflow) throw new Error("Named NXT-theme restore workflow module is unavailable.");
  const legacyNamedRowWorkflow = globalThis.MbnLegacyNamedRowWorkflow;
  if (!legacyNamedRowWorkflow) throw new Error("Legacy named-row workflow module is unavailable.");
  const operatorCatalogWorkflow = globalThis.MbnOperatorCatalogWorkflow;
  if (!operatorCatalogWorkflow) throw new Error("Operator catalog workflow module is unavailable.");
  const manualListsUiModule = globalThis.MbnManualListsUi;
  if (!manualListsUiModule) throw new Error("Manual lists UI module is unavailable.");
  const manualFinancialUiModule = globalThis.MbnManualFinancialUi;
  if (!manualFinancialUiModule) throw new Error("Manual financial UI module is unavailable.");
  const operatorCatalogUiModule = globalThis.MbnOperatorCatalogUi;
  if (!operatorCatalogUiModule) throw new Error("Operator catalog UI module is unavailable.");
  const namedPlaylistWorkflow = globalThis.MbnNamedPlaylistWorkflow;
  if (!namedPlaylistWorkflow) throw new Error("Named playlist workflow module is unavailable.");
  const DEFAULT_FADE_DURATION = 6;
  const namedPagedBuilderKeys = new Set(["s5074", "s5077", "s5088"]);
  const namedWriteOperations = new Set(["create", "replace", "delete"]);
  const namedInvalidBuilderKey = "s0";

  const catalog = [
    { builderKey: "s5001", code: "5001", aliases: ["5001", "N5001"], title: "1열판 · 기본", detail: "국내·NXT·해외 현재가", category: "plate", market: "kospi" },
    { builderKey: "s5006", code: "5006", title: "1열판 · 시가 상세", detail: "현재·시가·고가·저가", category: "plate", market: "kosdaq" },
    { builderKey: "s5011", code: "5011", title: "1열판 · 상세", detail: "52주·시총·순위", category: "plate", market: "kospi" },
    { builderKey: "s5016", code: "5016", title: "3열판", detail: "주요 지수 3열 구성", category: "plate", market: "index" },
    { builderKey: "s50160", code: "50160", title: "2열판 · 보조", detail: "원면·금·국채 2열", category: "plate", market: "overseas" },
    { builderKey: "s5023", code: "5023", title: "매매동향 · 표", detail: "지수별 투자자 동향", category: "market", market: "index" },
    { builderKey: "s5024", code: "5024", title: "매매동향 · 막대", detail: "주체별 매매 동향", category: "chart", market: "kospi" },
    { builderKey: "s5025", code: "5025", title: "순매도 상위", detail: "수동 상위 종목 표", category: "market", market: "kospi" },
    { builderKey: "s5026", code: "5026", title: "비교분석 · 캔들", detail: "두 종목 주간 비교", category: "chart", market: "compare" },
    { builderKey: "s5029", code: "5029", title: "수익률 비교", detail: "두 종목 수익률", category: "chart", market: "compare" },
    { builderKey: "s5032", code: "5032", aliases: ["5032", "8018", "8032"], title: "2열판 · 선물", detail: "지수·선물 2열", category: "plate", market: "index" },
    { builderKey: "s5037", code: "5037", title: "거래원", detail: "매수·매도 거래원", category: "market", market: "kospi" },
    { builderKey: "s5074", code: "5074", title: "5단 표", detail: "5개 단위 · 최대 20페이지", category: "plate", market: "kosdaq" },
    { builderKey: "s5076", code: "5076", title: "주요매출 구성", detail: "매출 비중 원그래프", category: "chart", market: "kospi" },
    { builderKey: "s5077", code: "5077", title: "6종목 현재가", detail: "6개 단위 · 최대 20페이지", category: "plate", market: "kospi" },
    { builderKey: "s5078", code: "5078", title: "섹터 지수", detail: "미국·국내 섹터 막대", category: "chart", market: "overseas" },
    { builderKey: "s5079", code: "5079", title: "성장성 지표", detail: "복수 성장 지표 선그래프", category: "chart", market: "kospi" },
    { builderKey: "s5080", code: "5080", title: "매출액 지표", detail: "분기별 매출 막대", category: "chart", market: "kospi" },
    { builderKey: "s5081", code: "5081", title: "영업이익 지표", detail: "분기별 영업이익", category: "chart", market: "kospi" },
    { builderKey: "s5082", code: "5082", title: "주체별 매매동향", detail: "코스피·코스닥·200", category: "market", market: "index" },
    { builderKey: "s5083", code: "5083", title: "주체별 선그래프", detail: "개인·외국인·기관", category: "chart", market: "kospi" },
    { builderKey: "s5084", code: "5084", title: "매매동향 라인", detail: "코스피·코스닥", category: "chart", market: "index" },
    { builderKey: "s5085", code: "5085", title: "프로그램 매매", detail: "구분별 프로그램 금액", category: "market", market: "index" },
    { builderKey: "s5086", code: "5086", title: "수익률 그래프", detail: "지수·종목 기간 수익률", category: "chart", market: "compare" },
    { builderKey: "s50860", code: "50860", title: "라인 그래프", detail: "지수·종목 기간 추이", category: "chart", market: "compare" },
    { builderKey: "s5087", code: "5087", title: "비교분석 · 라인", detail: "두 종목 라인 비교", category: "chart", market: "compare" },
    { builderKey: "s5088", code: "5088", title: "12종목 현재가", detail: "12개 단위 · 최대 20페이지", category: "plate", market: "theme" },
    { builderKey: "s6001", code: "6001", title: "해외증시 이미지", detail: "해외 지수·원자재", category: "market", market: "overseas" },
    { builderKey: "s6067", code: "6067", title: "기관 순매수", detail: "기관 순매수 현황", category: "market", market: "kospi" },
    { builderKey: "s8001", code: "8001", aliases: ["8001", "8002"], title: "네모 그래프", detail: "업종별 등락 큐브", category: "chart", market: "index" },
    { builderKey: "s8003", code: "8003", title: "호가창", detail: "매수·매도 10단 호가", category: "market", market: "kospi" },
    { builderKey: "s8010", code: "8035", aliases: ["8035", "8061", "8040", "8046", "8051", "8056"], title: "캔들 그래프", detail: "8035·8061·8040·8046·8051·8056", category: "chart", market: "index" },
    { builderKey: "s8018", code: "8018", aliases: ["8018", "8032", "5032"], title: "2열판 · 시장", detail: "국내·NXT·해외 2열", category: "plate", market: "index" },
    { builderKey: "s8067", code: "8067", aliases: ["8067", "5068", "5070", "5072"], title: "글로벌 증시 지도", detail: "8067·5068·5070·5072", category: "market", market: "overseas" },
    { builderKey: "s8086", code: "8086", title: "유가·금값", detail: "원본 MainForm 미도달 장면", category: "market", market: "exchange", reachable: false }
  ];
  if (catalog.length !== 35) throw new Error("Legacy scene catalog must contain exactly 35 builders.");
  const sceneAliases = item => Array.isArray(item?.aliases) ? item.aliases : [String(item?.code || "")];
  const reachableAliases = new Set(catalog
    .filter(item => item.reachable !== false)
    .flatMap(sceneAliases));
  if (reachableAliases.size !== 45) throw new Error("Legacy scene catalog must expose all 45 active aliases.");
  const operatorInputBuilderKeys = new Set([
    "s5001", "s5006", "s5011", "s5025", "s5026", "s5029", "s5037", "s5074",
    "s5076", "s5077", "s5079", "s5080", "s5081", "s5086", "s5087",
    "s5088", "s8003", "s8010"
  ]);

  const sceneSelectionDefaults = Object.freeze({
    s5001:  ["KOSPI", "삼성전자", "", "CURRENT", ""],
    s5006:  ["KOSPI_STOCK", "삼성전자", "", "CURRENT", "005930"],
    s5011:  ["KOSPI_STOCK", "삼성전자", "", "Valuation", "005930"],
    s5016:  ["ALL", "INDEX", "", "UnitedStatesIndices", ""],
    s50160: ["ALL", "INDEX", "", "Cotton", ""],
    s5023:  ["KOSPI", "INDEX", "KOSPI_TRADING_TREND", "TABLE_GRAPH", ""],
    s5024:  ["KOSPI", "INDEX", "KOSPI_TRADING_TREND", "BAR_GRAPH", ""],
    s5025:  ["INDIVIDUAL", "INDEX", "MANUAL_NET_SELL", "MANUAL_NET_SELL", ""],
    s5026:  ["STOCK", "삼성전자,SK하이닉스", "", "", ""],
    s5029:  ["STOCK", "삼성전자,SK하이닉스", "", "5일", ""],
    s5032:  ["INDEX", "FUTURES,Kospi", "", "CURRENT", ""],
    s5037:  ["KOSPI", "삼성전자", "TRADER", "TABLE_GRAPH", ""],
    s5074:  ["PAGED_DOMESTIC_KOSPI", "INDEX", "", "MARKET_CAP", "$TODAY"],
    s5076:  ["KOSPI", "삼성전자", "", "", ""],
    s5077:  ["PAGED_DOMESTIC_KOSPI", "INDEX", "", "MARKET_CAP", "$TODAY"],
    s5078:  ["FOREIGN_INDEX", "DOW", "US_SECTOR_INDEX", "", ""],
    s5079:  ["KOSPI", "삼성전자", "GROWTH_METRICS", "", ""],
    s5080:  ["KOSPI", "삼성전자", "SALES", "", ""],
    s5081:  ["KOSPI", "삼성전자", "", "", ""],
    s5082:  ["ALL", "INDEX", "", "", ""],
    s5083:  ["ALL", "INDEX", "INDIVIDUAL_TRADING_TREND", "LINE_GRAPH", ""],
    s5084:  ["KOSPI", "INDEX", "KOSPI_TRADING_TREND", "LINE_GRAPH", ""],
    s5085:  ["ALL", "INDEX", "PROGRAM_TRADING", "TABLE_GRAPH", ""],
    s5086:  ["EXCHANGE_RATE", "원달러", "", "20일", ""],
    s50860: ["EXCHANGE_RATE", "원달러", "", "20일", ""],
    s5087:  ["STOCK", "삼성전자,SK하이닉스", "", "", ""],
    s5088:  ["PAGED_DOMESTIC_KOSPI", "INDEX", "", "MARKET_CAP", "$TODAY"],
    s6001:  ["FOREIGN_INDEX", "DOW", "", "DowJones", ""],
    s6067:  ["ALL", "INDEX", "INSTITUTION_NET_BUY", "TABLE_GRAPH", ""],
    s8001:  ["ALL", "KOSPI", "", "", ""],
    s8003:  ["KOSPI", "삼성전자", "ORDER_BOOK", "TABLE_GRAPH", "005930"],
    s8010:  ["KOSPI_INDEX", "INDEX", "", "PRICE", ""],
    s8018:  ["INDEX", "Kospi,Kosdaq", "", "CURRENT", ""],
    s8067:  ["ALL", "INDEX", "", "", ""]
  });
  if (Object.keys(sceneSelectionDefaults).length !== 34) {
    throw new Error("Every reachable legacy builder must have a Web selection preset.");
  }

  function localDateText() {
    const now = new Date();
    const year = String(now.getFullYear()).padStart(4, "0");
    const month = String(now.getMonth() + 1).padStart(2, "0");
    const day = String(now.getDate()).padStart(2, "0");
    return `${year}-${month}-${day}`;
  }

  function defaultSceneSelection(builderKey) {
    const values = sceneSelectionDefaults[builderKey];
    if (!values) return undefined;
    return {
      groupCode: values[0],
      subject: values[1],
      graphicType: values[2],
      subtype: values[3],
      dataCode: values[4] === "$TODAY" ? localDateText() : values[4]
    };
  }

  const marketTitles = {
    dashboard: "마이그레이션 대시보드", overseas: "해외 그래픽", exchange: "환율 그래픽",
    index: "지수 그래픽", kospi: "코스피 그래픽", kosdaq: "코스닥 그래픽",
    compare: "종목 비교", theme: "테마 관리", "foreign-stock": "해외종목 그래픽",
    expert: "전문가 추천", halted: "거래 정지"
  };
  const liveDataViews = new Set(["index", "overseas"]);
  const databaseBackedViews = new Set([
    "kospi", "kosdaq", "index", "overseas", "theme", "foreign-stock", "expert", "halted"
  ]);
  const sourceLabels = { oracle: "Oracle", mariaDb: "MariaDB" };

  const storageKey = "mbn-stock-webview.playlist.v1";
  const namedPlaylistBackupStorageKey = "mbn-stock-webview.playlist.before-db-load.v1";
  const comparisonStorageKey = "mbn-stock-webview.comparison-pairs.v1";
  // Read only while migrating the former two-key layout. All new commits use
  // the single comparisonStorageKey envelope so pair data and import state
  // cannot be split by a renderer/process crash.
  const comparisonImportMarkerStorageKey = "mbn-stock-webview.comparison-pairs.legacy-import.v1";
  const state = {
    activeMarket: "overseas",
    activeCategory: "all",
    stockWorkflowCollapsed: false,
    stockCutSelection: legacyBatchSelectionWorkflow.createSelection(operatorWorkflow.stockCuts.length),
    fixedWorkflowCollapsed: false,
    industryWorkflowCollapsed: false,
    fixedSearch: "",
    search: "",
    playlist: [],
    selectedRows: new Set(),
    selectionAnchorIndex: -1,
    currentIndex: -1,
    dragIndex: -1,
    candleOptions: {
      ma5: true,
      ma20: true,
      invalidIds: []
    },
    manualFinancialRestore: {
      entries: new Map()
    },
    namedPlaylist: {
      open: false,
      status: "idle",
      pending: null,
      pagePlanPending: null,
      pagePlanError: null,
      restoration: null,
      definitions: [],
      selectedProgramCode: "",
      suggestedProgramCode: "",
      canMutate: false,
      writeQuarantined: false,
      error: "",
      loadedDefinition: null,
      guardByEntryId: new Map(),
      manualRestoreFinalized: true,
      comparisonRestorePending: null,
      foreignStockRestorePending: null,
      foreignIndexCandleRestorePending: null,
      krxThemeRestorePending: null,
      nxtThemeRestorePending: null,
      selectAfterRefresh: ""
    },
    playout: {
      mode: "unknown",
      engineState: "IDLE",
      connectionState: "unavailable",
      processDetected: false,
      connected: false,
      lastKtapConnectState: "not-attempted",
      ktapConnectAttempted: false,
      ktapConnectAccepted: false,
      ktapHelloObserved: null,
      networkMonitoringRecordExpected: false,
      networkMonitoringCheckRequired: false,
      commandAvailable: false,
      liveTakeInAllowed: false,
      nativeOutcomeUnknown: false,
      browserCorrelationQuarantined: false,
      sessionQuarantined: false,
      operationTimeoutMs: 5000,
      responseTimeoutMs: 10000,
      message: "송출 어댑터 상태를 확인하고 있습니다.",
      preparedCode: null,
      onAirCode: null,
      preparedCue: null,
      currentCueIndex: -1,
      currentEntryId: null,
      builderKey: null,
      pageIndex: 0,
      pageCount: 0,
      pageSize: 0,
      itemCount: 0,
      currentPageItemCount: 0,
      isLastPage: true,
      nextKind: "none",
      previewFields: [],
      refreshActive: false,
      playCompletionPending: false,
      refreshNextAt: null,
      refreshLastSuccessAt: null,
      refreshFaulted: false,
      refreshFaultCode: null,
      refreshFaultMessage: null,
      refreshCompletedCount: 0,
      refreshMaximumCount: null,
      refreshLimitReached: false,
      changedAt: null,
      changedAtMs: 0,
      latestStatusRequestId: null,
      pending: null,
      takeInAfterPrepare: false,
      error: null,
      lastSignature: ""
    },
    background: {
      requestId: null,
      enabled: false,
      kind: "none",
      fileName: "",
      canChange: false,
      pending: false,
      message: "원본 F2/F3 배경 상태를 확인하고 있습니다.",
      error: ""
    },
    database: { sources: [], loading: true, lastSignature: "", latestSequence: 0 },
    liveData: {
      requestId: null,
      view: null,
      status: "idle",
      tables: [],
      retrievedAt: null,
      error: "",
      timeoutId: null
    },
    stockSearch: {
      requestId: null,
      query: "",
      status: "idle",
      results: [],
      selected: null,
      totalRowCount: 0,
      truncated: false,
      error: "",
      timeoutId: null
    },
    industry: {
      requestId: null,
      market: null,
      status: "idle",
      results: [],
      totalRowCount: 0,
      highlighted: null,
      pairFirst: null,
      pairSecond: null,
      search: "",
      error: "",
      timeoutId: null
    },
    comparison: {
      source: "domestic",
      draftFirst: null,
      draftSecond: null,
      pairList: comparisonWorkflow.deleteAllPairs(),
      selectedPairId: null,
      legacyImport: {
        requestId: null,
        status: "idle",
        marker: null,
        recoveryBlocked: false,
        error: "",
        timeoutId: null
      },
      domestic: {
        requestId: null,
        query: "",
        status: "idle",
        results: [],
        totalRowCount: 0,
        error: "",
        timeoutId: null
      },
      world: {
        requestId: null,
        query: "",
        status: "idle",
        results: [],
        totalRowCount: 0,
        error: "",
        timeoutId: null
      }
    },
    theme: {
      nxtSession: "preMarket",
      sort: "INPUT_ORDER",
      search: {
        requestId: null,
        query: "",
        status: "idle",
        results: [],
        totalRowCount: 0,
        truncated: false,
        error: "",
        timeoutId: null
      },
      selected: null,
      preview: {
        requestId: null,
        status: "idle",
        items: [],
        totalRowCount: 0,
        truncated: false,
        error: "",
        timeoutId: null,
        catalogContext: null
      }
    },
    overseas: {
      source: "industry",
      industry: {
        request: null, status: "idle", results: [], selected: null,
        totalRowCount: 0, truncated: false, error: "", timeoutId: null
      },
      stock: {
        request: null, status: "idle", results: [], selected: null,
        totalRowCount: 0, truncated: false, error: "", timeoutId: null
      }
    },
    expert: {
      search: {
        request: null, status: "idle", results: [], totalRowCount: 0,
        truncated: false, error: "", timeoutId: null
      },
      selected: null,
      selectable: null,
      preview: {
        request: null, response: null, status: "idle", items: [],
        totalRowCount: 0, truncated: false, error: "", timeoutId: null,
        catalogContext: null
      }
    },
    tradingHalt: {
      request: null,
      status: "idle",
      results: [],
      selected: null,
      sortDirection: null,
      totalRowCount: 0,
      truncated: false,
      error: "",
      timeoutId: null
    }
  };

  const elements = {
    bridgeState: document.querySelector("#bridgeState"), databaseState: document.querySelector("#databaseState"),
    databaseStateDot: document.querySelector("#databaseStateDot"),
    databaseSummary: document.querySelector("#databaseSummary"),
    databaseSummaryIcon: document.querySelector("#databaseSummaryIcon"),
    databaseSummaryDetail: document.querySelector("#databaseSummaryDetail"),
    databaseSummaryBadge: document.querySelector("#databaseSummaryBadge"),
    runtimeLabel: document.querySelector("#runtimeLabel"), packageLabel: document.querySelector("#packageLabel"),
    workspaceTitle: document.querySelector("#workspaceTitle"), clock: document.querySelector("#clock"),
    catalogSearch: document.querySelector("#catalogSearch"), catalogTabs: document.querySelector("#catalogTabs"),
    catalogList: document.querySelector("#catalogList"), catalogModeBadge: document.querySelector("#catalogModeBadge"),
    stockWorkflow: document.querySelector("#stockWorkflow"),
    stockSearchForm: document.querySelector("#stockSearchForm"),
    stockSearchInput: document.querySelector("#stockSearchInput"),
    stockSearchButton: document.querySelector("#stockSearchButton"),
    stockSearchBadge: document.querySelector("#stockSearchBadge"),
    stockWorkflowToggle: document.querySelector("#stockWorkflowToggle"),
    stockSearchState: document.querySelector("#stockSearchState"),
    stockSearchResults: document.querySelector("#stockSearchResults"),
    selectedStock: document.querySelector("#selectedStock"),
    selectedStockName: document.querySelector("#selectedStockName"),
    selectedStockMeta: document.querySelector("#selectedStockMeta"),
    stockMa5: document.querySelector("#stockMa5"), stockMa20: document.querySelector("#stockMa20"),
    stockCutList: document.querySelector("#stockCutList"),
    stockCutSelectionCount: document.querySelector("#stockCutSelectionCount"),
    stockCutBatchAdd: document.querySelector("#stockCutBatchAdd"),
    stockCutSelectionClear: document.querySelector("#stockCutSelectionClear"),
    manualStockActions: document.querySelector("#manualStockActions"),
    legacyFixedWorkflow: document.querySelector("#legacyFixedWorkflow"),
    legacyFixedWorkflowTitle: document.querySelector("#legacyFixedWorkflowTitle"),
    legacyFixedCount: document.querySelector("#legacyFixedCount"),
    legacyFixedToggle: document.querySelector("#legacyFixedToggle"),
    legacyFixedSearch: document.querySelector("#legacyFixedSearch"),
    legacyFixedState: document.querySelector("#legacyFixedState"),
    legacyFixedSections: document.querySelector("#legacyFixedSections"),
    industryWorkflow: document.querySelector("#industryWorkflow"),
    industryWorkflowTitle: document.querySelector("#industryWorkflowTitle"),
    industryCount: document.querySelector("#industryCount"),
    industryRetry: document.querySelector("#industryRetry"),
    industryWorkflowToggle: document.querySelector("#industryWorkflowToggle"),
    industrySearch: document.querySelector("#industrySearch"),
    industryState: document.querySelector("#industryState"),
    industryResults: document.querySelector("#industryResults"),
    industryPrimary: document.querySelector("#industryPrimary"),
    industrySecondary: document.querySelector("#industrySecondary"),
    industrySwap: document.querySelector("#industrySwap"),
    industryClear: document.querySelector("#industryClear"),
    industryCutList: document.querySelector("#industryCutList"),
    comparisonWorkflow: document.querySelector("#comparisonWorkflow"),
    comparisonImportLegacy: document.querySelector("#comparisonImportLegacy"),
    comparisonPairCount: document.querySelector("#comparisonPairCount"),
    comparisonSourceTabs: document.querySelector("#comparisonSourceTabs"),
    comparisonDomesticPanel: document.querySelector("#comparisonDomesticPanel"),
    comparisonDomesticSearchForm: document.querySelector("#comparisonDomesticSearchForm"),
    comparisonDomesticSearch: document.querySelector("#comparisonDomesticSearch"),
    comparisonDomesticSearchButton: document.querySelector("#comparisonDomesticSearchButton"),
    comparisonDomesticState: document.querySelector("#comparisonDomesticState"),
    comparisonDomesticResults: document.querySelector("#comparisonDomesticResults"),
    comparisonFixedPanel: document.querySelector("#comparisonFixedPanel"),
    comparisonFixedTargets: document.querySelector("#comparisonFixedTargets"),
    comparisonWorldPanel: document.querySelector("#comparisonWorldPanel"),
    comparisonWorldSearchForm: document.querySelector("#comparisonWorldSearchForm"),
    comparisonWorldSearch: document.querySelector("#comparisonWorldSearch"),
    comparisonWorldSearchButton: document.querySelector("#comparisonWorldSearchButton"),
    comparisonWorldState: document.querySelector("#comparisonWorldState"),
    comparisonWorldResults: document.querySelector("#comparisonWorldResults"),
    comparisonFirst: document.querySelector("#comparisonFirst"),
    comparisonSecond: document.querySelector("#comparisonSecond"),
    comparisonSwap: document.querySelector("#comparisonSwap"),
    comparisonClear: document.querySelector("#comparisonClear"),
    comparisonSavePair: document.querySelector("#comparisonSavePair"),
    comparisonMoveUp: document.querySelector("#comparisonMoveUp"),
    comparisonMoveDown: document.querySelector("#comparisonMoveDown"),
    comparisonDeletePair: document.querySelector("#comparisonDeletePair"),
    comparisonDeleteAll: document.querySelector("#comparisonDeleteAll"),
    comparisonPairList: document.querySelector("#comparisonPairList"),
    comparisonYieldBatch: document.querySelector("#comparisonYieldBatch"),
    comparisonActionList: document.querySelector("#comparisonActionList"),
    themeWorkflow: document.querySelector("#themeWorkflow"),
    themeCount: document.querySelector("#themeCount"),
    themeSearchForm: document.querySelector("#themeSearchForm"),
    themeSearch: document.querySelector("#themeSearch"),
    themeNxtSession: document.querySelector("#themeNxtSession"),
    themeSearchButton: document.querySelector("#themeSearchButton"),
    themeSearchState: document.querySelector("#themeSearchState"),
    themeResults: document.querySelector("#themeResults"),
    selectedTheme: document.querySelector("#selectedTheme"),
    selectedThemeTitle: document.querySelector("#selectedThemeTitle"),
    selectedThemeMeta: document.querySelector("#selectedThemeMeta"),
    themeSort: document.querySelector("#themeSort"),
    themePreviewCount: document.querySelector("#themePreviewCount"),
    themePreviewState: document.querySelector("#themePreviewState"),
    themePreviewItems: document.querySelector("#themePreviewItems"),
    themeActionList: document.querySelector("#themeActionList"),
    themeCatalogManageButton: document.querySelector("#themeCatalogManageButton"),
    overseasWorkflow: document.querySelector("#overseasWorkflow"),
    overseasCount: document.querySelector("#overseasCount"),
    overseasSourceTabs: document.querySelector("#overseasSourceTabs"),
    overseasSearchForm: document.querySelector("#overseasSearchForm"),
    overseasSearch: document.querySelector("#overseasSearch"),
    overseasSearchButton: document.querySelector("#overseasSearchButton"),
    overseasSearchState: document.querySelector("#overseasSearchState"),
    overseasResults: document.querySelector("#overseasResults"),
    selectedOverseas: document.querySelector("#selectedOverseas"),
    selectedOverseasName: document.querySelector("#selectedOverseasName"),
    selectedOverseasMeta: document.querySelector("#selectedOverseasMeta"),
    overseasMa5: document.querySelector("#overseasMa5"),
    overseasMa20: document.querySelector("#overseasMa20"),
    overseasActionList: document.querySelector("#overseasActionList"),
    expertWorkflow: document.querySelector("#expertWorkflow"),
    expertCount: document.querySelector("#expertCount"),
    expertSearchForm: document.querySelector("#expertSearchForm"),
    expertSearch: document.querySelector("#expertSearch"),
    expertSearchButton: document.querySelector("#expertSearchButton"),
    expertSearchState: document.querySelector("#expertSearchState"),
    expertResults: document.querySelector("#expertResults"),
    selectedExpert: document.querySelector("#selectedExpert"),
    selectedExpertName: document.querySelector("#selectedExpertName"),
    selectedExpertMeta: document.querySelector("#selectedExpertMeta"),
    expertPreviewCount: document.querySelector("#expertPreviewCount"),
    expertPreviewState: document.querySelector("#expertPreviewState"),
    expertPreviewItems: document.querySelector("#expertPreviewItems"),
    expertActionList: document.querySelector("#expertActionList"),
    expertCatalogManageButton: document.querySelector("#expertCatalogManageButton"),
    tradingHaltWorkflow: document.querySelector("#tradingHaltWorkflow"),
    tradingHaltCount: document.querySelector("#tradingHaltCount"),
    tradingHaltSearchForm: document.querySelector("#tradingHaltSearchForm"),
    tradingHaltSearch: document.querySelector("#tradingHaltSearch"),
    tradingHaltSearchButton: document.querySelector("#tradingHaltSearchButton"),
    tradingHaltSearchState: document.querySelector("#tradingHaltSearchState"),
    tradingHaltNameSort: document.querySelector("#tradingHaltNameSort"),
    tradingHaltResults: document.querySelector("#tradingHaltResults"),
    selectedTradingHalt: document.querySelector("#selectedTradingHalt"),
    selectedTradingHaltName: document.querySelector("#selectedTradingHaltName"),
    selectedTradingHaltMeta: document.querySelector("#selectedTradingHaltMeta"),
    tradingHaltMa5: document.querySelector("#tradingHaltMa5"),
    tradingHaltMa20: document.querySelector("#tradingHaltMa20"),
    tradingHaltActionList: document.querySelector("#tradingHaltActionList"),
    liveDataPanel: document.querySelector("#liveDataPanel"), liveDataTitle: document.querySelector("#liveDataTitle"),
    liveDataState: document.querySelector("#liveDataState"), liveDataTables: document.querySelector("#liveDataTables"),
    databaseSources: document.querySelector("#databaseSources"),
    playlistBody: document.querySelector("#playlistBody"),
    playlistCount: document.querySelector("#playlistCount"), playlistEmpty: document.querySelector("#playlistEmpty"),
    toggleAllEnabled: document.querySelector("#toggleAllEnabledButton"),
    selectAll: document.querySelector("#selectAllButton"),
    moveUpButton: document.querySelector("#moveUpButton"),
    moveDownButton: document.querySelector("#moveDownButton"),
    deleteButton: document.querySelector("#deleteButton"),
    deleteAllButton: document.querySelector("#deleteAllButton"),
    globalMa5: document.querySelector("#globalMa5"),
    globalMa20: document.querySelector("#globalMa20"),
    loadButton: document.querySelector("#loadButton"),
    saveButton: document.querySelector("#saveButton"),
    namedPlaylistOpenButton: document.querySelector("#namedPlaylistOpenButton"),
    namedPlaylistPanel: document.querySelector("#namedPlaylistPanel"),
    namedPlaylistStateBadge: document.querySelector("#namedPlaylistStateBadge"),
    namedPlaylistRefreshButton: document.querySelector("#namedPlaylistRefreshButton"),
    namedPlaylistCloseButton: document.querySelector("#namedPlaylistCloseButton"),
    namedPlaylistStatus: document.querySelector("#namedPlaylistStatus"),
    namedPlaylistDefinitions: document.querySelector("#namedPlaylistDefinitions"),
    namedPlaylistSelectionSummary: document.querySelector("#namedPlaylistSelectionSummary"),
    namedPlaylistLoadButton: document.querySelector("#namedPlaylistLoadButton"),
    namedPlaylistPageRetryButton: document.querySelector("#namedPlaylistPageRetryButton"),
    namedPlaylistRestoreBackupButton: document.querySelector("#namedPlaylistRestoreBackupButton"),
    namedPlaylistProgramCode: document.querySelector("#namedPlaylistProgramCode"),
    namedPlaylistProgramTitle: document.querySelector("#namedPlaylistProgramTitle"),
    namedPlaylistCreateButton: document.querySelector("#namedPlaylistCreateButton"),
    namedPlaylistReplaceButton: document.querySelector("#namedPlaylistReplaceButton"),
    namedPlaylistDeleteButton: document.querySelector("#namedPlaylistDeleteButton"),
    namedPlaylistGuardSummary: document.querySelector("#namedPlaylistGuardSummary"),
    previewTitle: document.querySelector("#previewTitle"), previewSubtitle: document.querySelector("#previewSubtitle"),
    playoutState: document.querySelector("#playoutState"),
    playoutSummary: document.querySelector("#playoutSummary"),
    playoutSummaryIcon: document.querySelector("#playoutSummaryIcon"),
    playoutSummaryDetail: document.querySelector("#playoutSummaryDetail"),
    playoutSummaryBadge: document.querySelector("#playoutSummaryBadge"),
    tornadoProcessDot: document.querySelector("#tornadoProcessDot"),
    tornadoProcessState: document.querySelector("#tornadoProcessState"),
    playoutConnectionDot: document.querySelector("#playoutConnectionDot"),
    playoutConnectionState: document.querySelector("#playoutConnectionState"),
    playoutSafetyMode: document.querySelector("#playoutSafetyMode"),
    backgroundFileName: document.querySelector("#backgroundFileName"),
    backgroundStateText: document.querySelector("#backgroundStateText"),
    chooseBackgroundButton: document.querySelector("#chooseBackgroundButton"),
    toggleBackgroundButton: document.querySelector("#toggleBackgroundButton"),
    playoutStatusMessage: document.querySelector("#playoutStatusMessage"),
    playoutSceneCode: document.querySelector("#playoutSceneCode"),
    playoutEntryId: document.querySelector("#playoutEntryId"),
    playoutBuilderKey: document.querySelector("#playoutBuilderKey"),
    playoutPageNumber: document.querySelector("#playoutPageNumber"),
    playoutPageSize: document.querySelector("#playoutPageSize"),
    playoutItemCount: document.querySelector("#playoutItemCount"),
    playoutCurrentRows: document.querySelector("#playoutCurrentRows"),
    playoutLastPage: document.querySelector("#playoutLastPage"),
    playoutNextKind: document.querySelector("#playoutNextKind"),
    playoutRefreshState: document.querySelector("#playoutRefreshState"),
    sceneDataPreview: document.querySelector("#sceneDataPreview"),
    playoutError: document.querySelector("#playoutError"),
    playoutErrorMessage: document.querySelector("#playoutErrorMessage"),
    sceneSelectionForm: document.querySelector("#sceneSelectionForm"),
    sceneGroupCode: document.querySelector("#sceneGroupCode"),
    sceneSubject: document.querySelector("#sceneSubject"),
    sceneGraphicType: document.querySelector("#sceneGraphicType"),
    sceneSubtype: document.querySelector("#sceneSubtype"),
    sceneDataCode: document.querySelector("#sceneDataCode"),
    sceneCutAlias: document.querySelector("#sceneCutAlias"),
    sceneFadeDuration: document.querySelector("#sceneFadeDuration"),
    applySceneSelectionButton: document.querySelector("#applySceneSelectionButton"),
    prepareButton: document.querySelector("#prepareButton"), takeInButton: document.querySelector("#takeInButton"),
    nextButton: document.querySelector("#nextButton"), takeOutButton: document.querySelector("#takeOutButton"),
    eventLog: document.querySelector("#eventLog"),
    toast: document.querySelector("#toast")
  };

  const nativeBridge = window.chrome?.webview;
  let manualListsUi = null;
  let manualFinancialUi = null;
  let operatorCatalogUi = null;

  function createId() {
    return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
  }

  function operatorUiLocked() {
    return isPlaylistSnapshotLocked() || namedPlaylistBusy() ||
      state.manualFinancialRestore.entries.size > 0;
  }

  function selectedStockForManualFinancial() {
    const selected = state.stockSearch.selected;
    if (!selected) return null;
    return Object.freeze({
      market: selected.isNxt ? `nxt-${selected.market}` : selected.market,
      source: selected.isNxt ? "mariaDb" : "oracle",
      name: selected.stockName,
      code: selected.stockCode
    });
  }

  function appendTrustedOperatorEntry(entry) {
    if (operatorUiLocked()) throw new Error("The operator playlist is locked.");
    if (!entry || typeof entry !== "object" || Array.isArray(entry) ||
        typeof entry.id !== "string" || state.playlist.some(item => item.id === entry.id)) {
      throw new TypeError("A unique trusted operator playlist entry is required.");
    }
    const source = entry.operator?.source;
    if (source === "legacy-manual-lists-workflow") {
      if (!manualListsWorkflow.restorePlaylistEntry(entry) ||
          !manualListsWorkflow.isPlaylistEntryPlayable(entry)) {
        throw new TypeError("The manual-list entry is not fresh-read verified.");
      }
    } else if (source === "manual-financial-workflow") {
      if (!manualFinancialWorkflow.isPlaylistEntryPlayable(entry)) {
        throw new TypeError("The manual-financial entry is not native-confirmed.");
      }
    } else {
      throw new TypeError("The operator UI attempted to append an unsupported entry source.");
    }
    const definition = catalog.find(value => value.builderKey === entry.builderKey &&
      sceneAliases(value).includes(String(entry.code || "")));
    if (!definition) throw new TypeError("The operator entry does not resolve to a registered scene.");
    state.playlist.push(entry);
    state.selectedRows.clear();
    state.selectedRows.add(entry.id);
    state.currentIndex = state.playlist.length - 1;
    state.selectionAnchorIndex = state.currentIndex;
    renderPlaylist();
    updatePreview();
    return true;
  }

  function isPlaylistSnapshotLocked() {
    return playoutSafety.playlistSnapshotLocked({
      pending: Boolean(state.playout.pending),
      outcomeUnknown: state.playout.sessionQuarantined ||
        state.playout.nativeOutcomeUnknown ||
        state.playout.error?.outcomeUnknown === true,
      engineState: state.playout.engineState,
      preparedCode: state.playout.preparedCode,
      onAirCode: state.playout.onAirCode
    });
  }

  function requirePlaylistEditable(allowFailedManualRestore = false) {
    const restoreRecords = [...state.manualFinancialRestore.entries.values()];
    const failedRestoreOnly = restoreRecords.length > 0 &&
      restoreRecords.every(record => record.status === "error");
    if (state.namedPlaylist.pending || state.namedPlaylist.pagePlanPending ||
        state.namedPlaylist.comparisonRestorePending ||
        state.namedPlaylist.foreignStockRestorePending ||
        state.namedPlaylist.foreignIndexCandleRestorePending ||
        state.namedPlaylist.krxThemeRestorePending ||
        state.namedPlaylist.nxtThemeRestorePending ||
        (restoreRecords.length > 0 && !(allowFailedManualRestore && failedRestoreOnly))) {
      showToast("DB 플레이리스트 복원과 현재 identity 재검증이 끝날 때까지 순서를 변경할 수 없습니다.");
      return false;
    }
    if (!isPlaylistSnapshotLocked()) return true;
    showToast("PREPARE 이후에는 TAKE OUT까지 플레이리스트가 잠깁니다.");
    return false;
  }

  function updateClock() {
    elements.clock.textContent = new Intl.DateTimeFormat("ko-KR", {
      hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false
    }).format(new Date());
  }

  function filteredCatalog() {
    const term = state.search.trim().toLocaleLowerCase("ko-KR");
    return catalog.filter(item => {
      const marketMatches = state.activeMarket === "dashboard" || item.market === state.activeMarket;
      const categoryMatches = state.activeCategory === "all" || item.category === state.activeCategory;
      const searchMatches = !term || `${item.code} ${item.title} ${item.detail}`.toLocaleLowerCase("ko-KR").includes(term);
      return marketMatches && categoryMatches && searchMatches;
    });
  }

  function renderCatalog() {
    elements.catalogList.replaceChildren();
    const items = filteredCatalog();

    if (!items.length) {
      const empty = document.createElement("div");
      empty.className = "catalog-empty";
      empty.textContent = "조건에 맞는 그래픽 항목이 없습니다.";
      elements.catalogList.append(empty);
      return;
    }

    for (const item of items) {
      const row = document.createElement("article");
      row.className = "catalog-item";

      const code = document.createElement("span");
      code.className = "cut-code";
      code.textContent = item.code;

      const copy = document.createElement("div");
      const title = document.createElement("strong");
      const detail = document.createElement("small");
      title.textContent = item.title;
      detail.textContent = item.detail;
      copy.append(title, detail);

      const add = document.createElement("button");
      add.className = "add-cut";
      add.type = "button";
      add.disabled = item.reachable === false || isPlaylistSnapshotLocked();
      add.title = item.reachable === false ? "원본 MainForm에서 도달하지 않는 장면" : "플레이리스트에 추가";
      add.textContent = "+";
      add.addEventListener("click", () => addToPlaylist(item));

      row.append(code, copy, add);
      elements.catalogList.append(row);
    }
  }

  function fixedMarketTitle(market) {
    return { overseas: "해외", exchange: "환율", index: "지수" }[market] || "원본";
  }

  function openFixedManualAction(action) {
    if (!action || action.available !== false || operatorUiLocked()) return false;
    if (action.builderKey === "s5025") {
      manualListsUi?.openNetSell(action.selection?.groupCode);
      return true;
    }
    if (action.builderKey === "s5074" && action.selection?.groupCode === "PAGED_VI") {
      manualListsUi?.openVi();
      return true;
    }
    showToast(action.prerequisite || "지원하는 수동 입력 화면을 찾지 못했습니다.");
    return false;
  }

  function addFixedAction(action) {
    if (action?.available === false) {
      openFixedManualAction(action);
      return;
    }
    if (!action?.available || !requirePlaylistEditable()) {
      return;
    }
    try {
      const created = fixedWorkflow.createFixedPlaylistEntry(action.id, {
        id: createId(),
        fadeDuration: DEFAULT_FADE_DURATION,
        ma5: state.candleOptions.ma5,
        ma20: state.candleOptions.ma20
      });
      const definition = catalog.find(item =>
        item.builderKey === created.builderKey && sceneAliases(item).includes(created.code));
      if (!definition) throw new Error("The fixed action does not resolve to a registered scene alias.");
      const entry = { ...definition, ...created };
      state.playlist.push(entry);
      state.selectedRows.clear();
      state.selectedRows.add(entry.id);
      state.currentIndex = state.playlist.length - 1;
      state.selectionAnchorIndex = state.currentIndex;
      renderPlaylist();
      updatePreview();
      addLog(`${entry.code} ${action.section} · ${action.label} 추가`);
      showToast(`${action.label} 항목을 플레이리스트에 추가했습니다.`);
    } catch {
      showToast("원본 운영 항목 매핑을 확인할 수 없어 추가하지 않았습니다.");
    }
  }

  function addFixedActions(sectionActions) {
    if (!Array.isArray(sectionActions) || !sectionActions.length) {
      showToast("일괄 추가할 원본 운영 항목이 없습니다.");
      return;
    }
    if (sectionActions.some(action => action?.available === false)) {
      showToast("이 섹션에는 수동 입력이 필요한 항목이 있어 아무것도 추가하지 않았습니다. 수동 항목은 개별 입력 버튼으로 먼저 확정하세요.");
      return;
    }
    if (sectionActions.some(action => !action?.available) || !requirePlaylistEditable()) {
      return;
    }

    try {
      const entries = legacyBatchSelectionWorkflow.materializeAtomicBatch(
        sectionActions,
        action => {
          const created = fixedWorkflow.createFixedPlaylistEntry(action.id, {
            id: createId(),
            fadeDuration: DEFAULT_FADE_DURATION,
            ma5: state.candleOptions.ma5,
            ma20: state.candleOptions.ma20
          });
          const definition = catalog.find(item =>
            item.builderKey === created.builderKey &&
            sceneAliases(item).includes(created.code));
          if (!definition) {
            throw new Error("A fixed batch item does not resolve to a registered scene alias.");
          }
          return { ...definition, ...created, enabled: true };
        });
      state.playlist.push(...entries);
      state.selectedRows.clear();
      entries.forEach(entry => state.selectedRows.add(entry.id));
      state.currentIndex = state.playlist.length - 1;
      state.selectionAnchorIndex = state.currentIndex;
      renderPlaylist();
      updatePreview();
      addLog(`${sectionActions[0].section} 섹션 전체 추가 · ${entries.length}개 · 원본 자식 순서`);
      showToast(`${sectionActions[0].section} 항목 ${entries.length}개를 순서대로 추가했습니다.`);
    } catch {
      showToast("섹션의 모든 원본 항목을 검증하지 못해 아무것도 추가하지 않았습니다.");
    }
  }

  function renderFixedWorkflow() {
    const visible = fixedWorkflow.fixedMarkets.includes(state.activeMarket);
    elements.legacyFixedWorkflow.hidden = !visible;
    if (!visible) return;

    const allActions = fixedWorkflow.actionsForMarket(state.activeMarket);
    const search = state.fixedSearch.trim().toLocaleLowerCase("ko-KR");
    const actions = allActions.filter(action => !search ||
      `${action.section} ${action.label} ${action.code} ${action.builderKey}`
        .toLocaleLowerCase("ko-KR").includes(search));
    const available = allActions.filter(action => action.available).length;
    const manual = allActions.length - available;
    elements.legacyFixedWorkflow.classList.toggle("collapsed", state.fixedWorkflowCollapsed);
    elements.legacyFixedToggle.textContent = state.fixedWorkflowCollapsed ? "펼치기" : "접기";
    elements.legacyFixedToggle.setAttribute("aria-expanded", String(!state.fixedWorkflowCollapsed));
    elements.legacyFixedWorkflowTitle.textContent = `${fixedMarketTitle(state.activeMarket)} 원본 운영 항목`;
    elements.legacyFixedCount.textContent = `${allActions.length} ACTIONS`;
    elements.legacyFixedState.textContent = search
      ? `${actions.length}개 검색 결과 · 원본 실행본 SHA 고정`
      : `${available}개 즉시 사용${manual ? ` · 수동 전제 ${manual}개` : ""}`;
    elements.legacyFixedSections.replaceChildren();

    if (!actions.length) {
      const empty = document.createElement("div");
      empty.className = "catalog-empty";
      empty.textContent = "조건에 맞는 원본 운영 항목이 없습니다.";
      elements.legacyFixedSections.append(empty);
      return;
    }

    const sections = new Map();
    for (const action of actions) {
      if (!sections.has(action.section)) sections.set(action.section, []);
      sections.get(action.section).push(action);
    }
    for (const [sectionName, sectionActions] of sections) {
      const section = document.createElement("section");
      section.className = "legacy-fixed-section";
      const header = document.createElement("header");
      const title = document.createElement("strong");
      const count = document.createElement("span");
      const tools = document.createElement("div");
      const addAll = document.createElement("button");
      title.textContent = sectionName;
      count.textContent = `${sectionActions.length}`;
      tools.className = "legacy-fixed-section-tools";
      addAll.className = "legacy-fixed-batch-add";
      addAll.type = "button";
      addAll.textContent = "전체 추가";
      addAll.disabled = operatorUiLocked();
      addAll.title = sectionActions.some(action => action.available === false)
        ? "수동 입력 항목이 포함되어 있으며 전체 배치는 부분 추가 없이 차단됩니다."
        : "현재 표시된 섹션 자식을 원본 순서대로 추가합니다.";
      addAll.addEventListener("click", event => {
        event.stopPropagation();
        addFixedActions(sectionActions);
      });
      header.addEventListener("dblclick", event => {
        if (event.target.closest("button")) return;
        addFixedActions(sectionActions);
      });
      tools.append(count, addAll);
      header.append(title, tools);
      section.append(header);

      for (const action of sectionActions) {
        const row = document.createElement("article");
        row.className = `legacy-fixed-row${action.available ? "" : " unavailable"}`;
        const number = document.createElement("span");
        number.className = "legacy-fixed-number";
        number.textContent = action.id.slice(-3);
        const copy = document.createElement("div");
        copy.className = "legacy-fixed-copy";
        const label = document.createElement("strong");
        const meta = document.createElement("small");
        label.textContent = action.label;
        meta.textContent = action.available
          ? `${action.code} · ${action.builderKey}`
          : `수동 입력 필요 · ${action.builderKey}`;
        copy.append(label, meta);
        const add = document.createElement("button");
        add.className = "legacy-fixed-add";
        add.type = "button";
        add.textContent = action.available ? "+" : "입력";
        add.disabled = operatorUiLocked();
        add.title = action.available ? "플레이리스트에 추가" : "원본 수동 입력 화면 열기";
        add.addEventListener("click", () => addFixedAction(action));
        row.addEventListener("dblclick", event => {
          if (event.target.closest("button")) return;
          addFixedAction(action);
        });
        row.append(number, copy, add);
        section.append(row);
      }
      elements.legacyFixedSections.append(section);
    }
  }

  function synchronizeFixedEntries(showFeedback) {
    let changed = false;
    let invalid = false;
    state.playlist = state.playlist.map(item => {
      if (item?.operator?.source !== "legacy-fixed-workflow") return item;
      const refreshed = fixedWorkflow.refreshFixedPlaylistEntry(item);
      if (!refreshed) {
        invalid = true;
        return item;
      }
      const definition = catalog.find(value =>
        value.builderKey === refreshed.builderKey && sceneAliases(value).includes(refreshed.code));
      if (!definition) {
        invalid = true;
        return item;
      }
      if (refreshed.selection.dataCode !== item.selection?.dataCode ||
          refreshed.selection.graphicType !== item.selection?.graphicType) changed = true;
      return { ...definition, ...refreshed };
    });
    if (invalid) {
      showToast("저장된 원본 운영 항목의 신뢰 매핑이 손상되어 PREPARE를 중단했습니다.");
      return false;
    }
    if (changed) {
      renderPlaylist();
      updatePreview();
      if (showFeedback) showToast("날짜와 NXT 세션을 현재 운영 시점으로 갱신했습니다.");
    }
    return true;
  }

  function clearIndustryTimeout() {
    if (state.industry.timeoutId !== null) clearTimeout(state.industry.timeoutId);
    state.industry.timeoutId = null;
  }

  function industryIdentity(industry) {
    if (!industry) return "";
    return [industry.market, industry.code, industry.name].join("|");
  }

  function reconcileIndustrySelection(value, results) {
    const identity = industryIdentity(value);
    return identity ? results.find(item => industryIdentity(item) === identity) || null : null;
  }

  function selectIndustry(industry) {
    state.industry.highlighted = industry;
    renderIndustryWorkflow();
    addLog(`업종 선택 · ${industry.name} · ${industry.code}`);
  }

  function shiftIndustryPair(industry) {
    state.industry.pairSecond = state.industry.pairFirst;
    state.industry.pairFirst = industry;
    renderIndustryWorkflow();
    addLog(`업종 비교 선택 · ${industry.name}`);
  }

  function addIndustryCut(cutId) {
    if (!requirePlaylistEditable()) return;
    const industry = state.industry;
    if (!industryWorkflow.isCutAllowed(
        state.activeMarket,
        cutId,
        industry.highlighted,
        industry.pairFirst,
        industry.pairSecond)) {
      showToast(cutId === "two-column"
        ? "업종 목록에서 두 업종을 차례로 더블클릭하세요."
        : "업종 목록에서 한 업종을 선택하세요.");
      return;
    }

    try {
      const created = industryWorkflow.createIndustryPlaylistEntry(state.activeMarket, cutId, {
        id: createId(),
        fadeDuration: DEFAULT_FADE_DURATION,
        selected: industry.highlighted,
        pair: [industry.pairFirst, industry.pairSecond],
        ma5: state.candleOptions.ma5,
        ma20: state.candleOptions.ma20
      });
      const definition = catalog.find(item =>
        item.builderKey === created.builderKey && sceneAliases(item).includes(created.code));
      if (!definition) throw new Error("The industry action does not resolve to a registered scene alias.");
      const entry = { ...definition, ...created };
      state.playlist.push(entry);
      state.selectedRows.clear();
      state.selectedRows.add(entry.id);
      state.currentIndex = state.playlist.length - 1;
      state.selectionAnchorIndex = state.currentIndex;
      renderPlaylist();
      updatePreview();
      addLog(`${entry.code} ${entry.title} · ${entry.detail} 추가`);
      showToast(`${entry.detail} 항목을 플레이리스트에 추가했습니다.`);
    } catch {
      showToast("선택한 업종과 컷을 안전한 장면으로 구성하지 못했습니다.");
    }
  }

  function renderIndustryWorkflow() {
    const visible = industryWorkflow.markets.includes(state.activeMarket);
    elements.industryWorkflow.hidden = !visible;
    if (!visible) return;

    const industry = state.industry;
    const marketLabel = state.activeMarket === "kospi" ? "코스피" : "코스닥";
    const playlistLocked = isPlaylistSnapshotLocked();
    elements.industryWorkflow.classList.toggle("collapsed", state.industryWorkflowCollapsed);
    elements.industryWorkflowToggle.textContent = state.industryWorkflowCollapsed ? "펼치기" : "접기";
    elements.industryWorkflowToggle.setAttribute("aria-expanded", String(!state.industryWorkflowCollapsed));
    elements.industryWorkflowTitle.textContent = `${marketLabel} 업종 선택`;
    elements.industryCount.textContent = industry.status === "loading"
      ? "LOADING"
      : `${industry.totalRowCount.toLocaleString("ko-KR")} DB`;
    elements.industryRetry.disabled = industry.status === "loading";
    elements.industryState.className = `industry-state ${industry.status}`;
    if (industry.status === "loading") {
      elements.industryState.textContent = `${marketLabel} 업종 목록을 Oracle에서 조회하고 있습니다.`;
    } else if (industry.status === "error") {
      elements.industryState.textContent = industry.error || "업종 목록을 불러오지 못했습니다.";
    } else if (industry.status === "ready") {
      elements.industryState.textContent = `${industry.totalRowCount.toLocaleString("ko-KR")}개 업종 · 클릭은 단일 선택, 더블클릭은 비교 선택`;
    } else {
      elements.industryState.textContent = "업종 목록을 조회할 준비가 되었습니다.";
    }

    const term = industry.search.trim().toLocaleLowerCase("ko-KR");
    const results = industry.results.filter(item => !term ||
      `${item.name} ${item.code}`.toLocaleLowerCase("ko-KR").includes(term));
    elements.industryResults.replaceChildren();
    if (!results.length) {
      const empty = document.createElement("div");
      empty.className = "industry-results-empty";
      empty.textContent = industry.status === "ready" ? "일치하는 업종이 없습니다." : "업종 목록이 여기에 표시됩니다.";
      elements.industryResults.append(empty);
    } else {
      const selectedIdentity = industryIdentity(industry.highlighted);
      for (const item of results) {
        const row = document.createElement("button");
        row.type = "button";
        row.className = "industry-result";
        row.setAttribute("role", "option");
        const selected = industryIdentity(item) === selectedIdentity;
        row.classList.toggle("selected", selected);
        row.setAttribute("aria-selected", selected ? "true" : "false");
        const name = document.createElement("strong");
        const code = document.createElement("small");
        name.textContent = item.name;
        code.textContent = item.code;
        row.append(name, code);
        row.addEventListener("click", () => selectIndustry(item));
        row.addEventListener("dblclick", () => shiftIndustryPair(item));
        elements.industryResults.append(row);
      }
    }

    elements.industryPrimary.textContent = industry.pairFirst?.name || "선택 없음";
    elements.industrySecondary.textContent = industry.pairSecond?.name || "선택 없음";
    elements.industrySwap.disabled = playlistLocked || !industry.pairFirst || !industry.pairSecond;
    elements.industryClear.disabled = playlistLocked ||
      (!industry.highlighted && !industry.pairFirst && !industry.pairSecond);

    elements.industryCutList.replaceChildren();
    let previousSection = null;
    industryWorkflow.cuts.forEach((cut, index) => {
      if (cut.section !== previousSection) {
        const section = document.createElement("div");
        section.className = "industry-cut-section";
        section.textContent = cut.section;
        elements.industryCutList.append(section);
        previousSection = cut.section;
      }
      const allowed = industryWorkflow.isCutAllowed(
        state.activeMarket, cut.id, industry.highlighted, industry.pairFirst, industry.pairSecond);
      const row = document.createElement("div");
      row.className = "industry-cut-row";
      row.classList.toggle("disabled", !allowed);
      const number = document.createElement("span");
      number.className = "industry-cut-number";
      number.textContent = String(index + 1).padStart(2, "0");
      const copy = document.createElement("span");
      copy.className = "industry-cut-copy";
      const label = document.createElement("strong");
      const detail = document.createElement("small");
      label.textContent = cut.label;
      detail.textContent = `${cut.builderKey} · ${cut.kind === "fixed" ? "시장 전체" : (cut.kind === "pair" ? "두 업종" : "선택 업종")}`;
      copy.append(label, detail);
      const add = document.createElement("button");
      add.type = "button";
      add.className = "industry-cut-add";
      add.textContent = "+";
      add.disabled = !allowed || playlistLocked;
      add.title = allowed ? "플레이리스트에 추가" : (cut.kind === "pair" ? "두 업종을 더블클릭해 선택하세요." : "업종을 선택하세요.");
      add.addEventListener("click", () => addIndustryCut(cut.id));
      row.addEventListener("dblclick", event => {
        if (!event.target.closest("button") && allowed) addIndustryCut(cut.id);
      });
      row.append(number, copy, add);
      elements.industryCutList.append(row);
    });
  }

  function requestIndustries(market = state.activeMarket) {
    clearIndustryTimeout();
    if (!industryWorkflow.markets.includes(market)) return;
    const changingMarket = state.industry.market !== market;
    if (changingMarket) {
      state.industry.highlighted = null;
      state.industry.pairFirst = null;
      state.industry.pairSecond = null;
      state.industry.search = "";
      elements.industrySearch.value = "";
    }
    const requestId = createId();
    state.industry.requestId = requestId;
    state.industry.market = market;
    state.industry.status = "loading";
    state.industry.results = [];
    state.industry.totalRowCount = 0;
    state.industry.error = "";
    renderIndustryWorkflow();

    if (!nativeBridge) {
      state.industry.status = "error";
      state.industry.error = "브라우저 미리보기에서는 실제 업종 DB 조회를 사용할 수 없습니다.";
      renderIndustryWorkflow();
      return;
    }

    postNative("request-industries", { requestId, market });
    state.industry.timeoutId = setTimeout(() => {
      if (state.industry.requestId !== requestId || state.industry.status !== "loading") return;
      state.industry.status = "error";
      state.industry.error = "업종 조회 응답 시간이 초과되었습니다. DB 상태를 확인하고 다시 조회하세요.";
      renderIndustryWorkflow();
      addLog(`${marketLabelForIndustry(market)} 업종 조회 시간 초과`);
    }, 30000);
  }

  function marketLabelForIndustry(market) {
    return market === "kospi" ? "코스피" : "코스닥";
  }

  function failIndustryResponse(message) {
    clearIndustryTimeout();
    state.industry.status = "error";
    state.industry.results = [];
    state.industry.totalRowCount = 0;
    state.industry.error = message;
    renderIndustryWorkflow();
  }

  function handleIndustryResults(payload) {
    if (!payload || payload.requestId !== state.industry.requestId || payload.market !== state.industry.market) return;
    const rawResults = Array.isArray(payload.results) ? payload.results : null;
    if (!rawResults || rawResults.length > 500 || !Number.isInteger(payload.totalRowCount) ||
        payload.totalRowCount !== rawResults.length) {
      failIndustryResponse("업종 목록 응답 형식이 올바르지 않습니다.");
      return;
    }
    const results = [];
    const names = new Set();
    const codes = new Set();
    for (const value of rawResults) {
      const normalized = industryWorkflow.normalizeIndustry(value);
      if (!normalized || normalized.market !== state.industry.market ||
          names.has(normalized.name) || codes.has(normalized.code)) {
        failIndustryResponse("업종 목록에 올바르지 않거나 중복된 항목이 있습니다.");
        return;
      }
      names.add(normalized.name);
      codes.add(normalized.code);
      results.push(normalized);
    }
    clearIndustryTimeout();
    state.industry.results = results;
    state.industry.totalRowCount = results.length;
    state.industry.highlighted = reconcileIndustrySelection(state.industry.highlighted, results);
    state.industry.pairFirst = reconcileIndustrySelection(state.industry.pairFirst, results);
    state.industry.pairSecond = reconcileIndustrySelection(state.industry.pairSecond, results);
    state.industry.status = "ready";
    state.industry.error = "";
    renderIndustryWorkflow();
    addLog(`${marketLabelForIndustry(payload.market)} 업종 조회 완료 · ${results.length}건`);
  }

  function handleIndustryError(payload) {
    if (!payload || payload.requestId !== state.industry.requestId || payload.market !== state.industry.market) return;
    failIndustryResponse(typeof payload.message === "string" && payload.message.trim()
      ? payload.message.trim()
      : "업종 목록을 불러오지 못했습니다.");
    addLog(`${marketLabelForIndustry(payload.market)} 업종 조회 실패`);
  }

  function synchronizeIndustryEntries(showFeedback) {
    let changed = false;
    let invalid = false;
    state.playlist = state.playlist.map(item => {
      if (item?.operator?.source !== "legacy-industry-workflow") return item;
      const refreshed = industryWorkflow.refreshIndustryPlaylistEntry(item);
      if (!refreshed) {
        invalid = true;
        return item;
      }
      const definition = catalog.find(value =>
        value.builderKey === refreshed.builderKey && sceneAliases(value).includes(refreshed.code));
      if (!definition) {
        invalid = true;
        return item;
      }
      if (JSON.stringify(refreshed.selection) !== JSON.stringify(item.selection)) changed = true;
      return { ...definition, ...refreshed };
    });
    if (invalid) {
      showToast("저장된 업종 항목의 신뢰 매핑이 손상되어 PREPARE를 중단했습니다.");
      return false;
    }
    if (changed) {
      renderPlaylist();
      updatePreview();
      if (showFeedback) showToast("업종 페이지 날짜를 현재 운영 시점으로 갱신했습니다.");
    }
    return true;
  }

  function clearComparisonSearchTimeout(kind) {
    const search = state.comparison[kind];
    if (search?.timeoutId !== null) clearTimeout(search.timeoutId);
    if (search) search.timeoutId = null;
  }

  function comparisonTargetDisplay(target) {
    return target?.displayName || target?.stockName || target?.inputName || target?.target || "선택 없음";
  }

  function comparisonTargetMeta(target) {
    if (!target) return "";
    if (target.kind === "market-target") return target.target;
    if (target.kind === "world-stock") return `${target.nation} · ${target.symbol}`;
    return `${target.market.toLocaleUpperCase("en-US")}${target.kind === "nxt-stock" ? " NXT" : " KRX"} · ${target.stockCode}`;
  }

  function comparisonTargetFromStockResult(value) {
    const stock = operatorWorkflow.normalizeStockResult(value);
    if (!stock) return null;
    return comparisonWorkflow.normalizeTarget({
      kind: stock.isNxt ? "nxt-stock" : "krx-stock",
      market: stock.market,
      stockName: stock.stockName,
      stockCode: stock.stockCode,
      displayName: stock.displayName
    });
  }

  function comparisonTargetFromWorldResult(value) {
    if (!value || typeof value !== "object" || Array.isArray(value)) return null;
    return comparisonWorkflow.normalizeTarget({
      kind: "world-stock",
      inputName: value.inputName,
      displayName: value.inputName,
      symbol: value.symbol,
      nation: value.nationCode
    });
  }

  function rotateComparisonDraft(target) {
    if (isPlaylistSnapshotLocked()) return;
    const rotated = comparisonWorkflow.rotatePair(
      [state.comparison.draftFirst, state.comparison.draftSecond],
      target);
    if (!rotated) return;
    [state.comparison.draftFirst, state.comparison.draftSecond] = rotated;
    renderComparisonWorkflow();
    addLog(`비교 대상 선택 · ${comparisonTargetDisplay(target)}`);
  }

  function selectedComparisonRecord() {
    return state.comparison.pairList.pairs.find(record =>
      record.id === state.comparison.selectedPairId) || null;
  }

  function selectedComparisonPair() {
    return selectedComparisonRecord()?.pair || null;
  }

  function persistComparisonPairList(nextList, successMessage) {
    try {
      const envelope = comparisonImportWorkflow.createStorageEnvelope(
        nextList,
        state.comparison.legacyImport.marker);
      if (!envelope) throw new Error("The comparison storage envelope is invalid.");
      localStorage.setItem(comparisonStorageKey, JSON.stringify(envelope));
      state.comparison.pairList = nextList;
      if (successMessage) addLog(successMessage);
      return true;
    } catch {
      showToast("비교쌍 로컬 저장소를 갱신하지 못했습니다.");
      return false;
    }
  }

  function loadComparisonPairList() {
    const operation = state.comparison.legacyImport;
    try {
      const serialized = localStorage.getItem(comparisonStorageKey);
      const raw = JSON.parse(serialized || "null");
      let envelope = comparisonImportWorkflow.normalizeStorageEnvelope(raw);
      if (!envelope && raw !== null) {
        const oldMarker = comparisonImportWorkflow.normalizeMarker(JSON.parse(
          localStorage.getItem(comparisonImportMarkerStorageKey) || "null"));
        envelope = comparisonImportWorkflow.createStorageEnvelope(raw, oldMarker);
        if (!envelope) throw new Error("A split legacy comparison import was detected.");
        localStorage.setItem(comparisonStorageKey, JSON.stringify(envelope));
        localStorage.removeItem(comparisonImportMarkerStorageKey);
      }
      if (!envelope) {
        envelope = comparisonImportWorkflow.createStorageEnvelope(
          comparisonWorkflow.deleteAllPairs(),
          null);
      }
      if (!envelope) throw new Error("The comparison storage envelope is invalid.");
      state.comparison.pairList = envelope.pairList;
      operation.marker = envelope.legacyImportMarker;
      operation.status = operation.marker ? "imported" : "idle";
      operation.recoveryBlocked = false;
      operation.error = "";
      state.comparison.selectedPairId = state.comparison.pairList.pairs[0]?.id || null;
    } catch {
      state.comparison.pairList = comparisonWorkflow.deleteAllPairs();
      state.comparison.selectedPairId = null;
      operation.marker = null;
      operation.status = "error";
      operation.recoveryBlocked = true;
      operation.error = "비교쌍과 1회 가져오기 표식이 분리되어 안전하게 복구할 수 없습니다. 로컬 상태를 확인하기 전에는 가져오기를 다시 실행하지 않습니다.";
    }
  }

  function clearComparisonImportTimeout() {
    const operation = state.comparison.legacyImport;
    if (operation.timeoutId !== null) clearTimeout(operation.timeoutId);
    operation.timeoutId = null;
  }

  function persistComparisonLegacyImport(nextList, marker) {
    try {
      const envelope = comparisonImportWorkflow.createStorageEnvelope(nextList, marker);
      if (!envelope) return false;
      localStorage.setItem(comparisonStorageKey, JSON.stringify(envelope));
    } catch {
      return false;
    }

    state.comparison.pairList = nextList;
    state.comparison.legacyImport.marker = marker;
    state.comparison.legacyImport.status = "imported";
    state.comparison.legacyImport.recoveryBlocked = false;
    return true;
  }

  function requestLegacyComparisonImport() {
    const operation = state.comparison.legacyImport;
    if (isPlaylistSnapshotLocked() || operation.status === "loading" ||
        operation.recoveryBlocked) return;
    if (operation.marker) {
      showToast("원본 비교쌍은 이미 한 번 안전하게 가져왔습니다.");
      return;
    }
    if (!nativeBridge) {
      operation.status = "error";
      operation.error = "브라우저 미리보기에서는 원본 파일과 실제 DB를 검증할 수 없습니다.";
      renderComparisonWorkflow();
      showToast(operation.error);
      return;
    }
    if (!window.confirm(
      "원본 종목비교.dat를 한 번 읽고, 모든 종목을 현재 DB에서 재검증한 뒤 로컬 비교쌍으로 가져올까요?")) return;

    clearComparisonImportTimeout();
    const requestId = createId();
    Object.assign(operation, {
      requestId,
      status: "loading",
      error: ""
    });
    renderComparisonWorkflow();
    postNative("import-legacy-comparison-pairs", { requestId });
    operation.timeoutId = setTimeout(() => {
      if (operation.requestId !== requestId || operation.status !== "loading") return;
      operation.status = "error";
      operation.error = "원본 비교쌍 검증 응답 시간이 초과되었습니다. 자동 재시도하지 않습니다.";
      renderComparisonWorkflow();
      addLog("원본 비교쌍 가져오기 시간 초과 · 자동 재시도 없음");
      showToast(operation.error);
    }, 60000);
  }

  function handleLegacyComparisonImportResults(payload) {
    const operation = state.comparison.legacyImport;
    if (operation.status !== "loading" || payload?.requestId !== operation.requestId) return;
    clearComparisonImportTimeout();
    const imported = comparisonImportWorkflow.normalizeImportResponse(
      payload,
      operation.requestId);
    if (!imported) {
      operation.status = "error";
      operation.error = "Native가 반환한 원본 비교쌍 형식 또는 상관관계가 올바르지 않습니다.";
      renderComparisonWorkflow();
      showToast(operation.error);
      return;
    }

    let merged;
    try {
      merged = comparisonImportWorkflow.mergeImport(
        state.comparison.pairList,
        imported);
    } catch {
      operation.status = "error";
      operation.error = "기존 비교쌍과 중복되거나 저장 한도를 초과해 전체 가져오기를 취소했습니다.";
      renderComparisonWorkflow();
      showToast(operation.error);
      return;
    }

    const previousCount = state.comparison.pairList.pairs.length;
    const marker = comparisonImportWorkflow.createMarker(imported);
    if (!marker || !persistComparisonLegacyImport(merged, marker)) {
      operation.status = "error";
      operation.error = "비교쌍 로컬 저장소를 갱신하지 못해 전체 가져오기를 취소했습니다.";
      renderComparisonWorkflow();
      showToast(operation.error);
      return;
    }

    try {
      state.comparison.selectedPairId = merged.pairs[previousCount]?.id ||
        merged.pairs[0]?.id || null;
      operation.requestId = null;
      operation.error = "";
      renderComparisonWorkflow();
      addLog(`원본 비교쌍 가져오기 완료 · ${imported.sourceRowCount.toLocaleString("ko-KR")}쌍 · 현재 DB 재검증`);
      showToast(`원본 비교쌍 ${imported.sourceRowCount.toLocaleString("ko-KR")}개를 로컬 저장소로 가져왔습니다.`);
    } catch {
      // Persistence already completed; rendering/logging failures must not
      // roll back or reopen the one-time import gate.
    }
  }

  function handleLegacyComparisonImportError(payload) {
    const operation = state.comparison.legacyImport;
    if (operation.status !== "loading" || payload?.requestId !== operation.requestId) return;
    clearComparisonImportTimeout();
    const message = typeof payload.message === "string" && payload.message.trim().length <= 512 &&
      !/[\u0000-\u001f\u007f]/u.test(payload.message)
      ? payload.message.trim()
      : "원본 비교쌍 가져오기를 완료하지 못했습니다.";
    operation.status = "error";
    operation.error = message;
    renderComparisonWorkflow();
    addLog(`원본 비교쌍 가져오기 실패 · ${message}`);
    showToast(message);
  }

  function saveComparisonDraftPair() {
    if (isPlaylistSnapshotLocked()) return;
    const pair = comparisonWorkflow.normalizePair([
      state.comparison.draftFirst,
      state.comparison.draftSecond
    ]);
    if (!pair) {
      showToast("비교 대상 두 개를 더블클릭해 선택하세요.");
      return;
    }
    const id = createId();
    const record = comparisonWorkflow.createPairRecord(id, pair);
    if (!record) {
      showToast("비교쌍 식별자를 안전하게 구성하지 못했습니다.");
      return;
    }
    try {
      const next = comparisonWorkflow.appendPair(state.comparison.pairList, record);
      if (!persistComparisonPairList(next, `비교쌍 저장 · ${comparisonWorkflow.pairLabel(pair)}`)) return;
      state.comparison.selectedPairId = id;
      renderComparisonWorkflow();
      showToast("비교쌍을 저장하고 선택했습니다.");
    } catch {
      showToast("비교쌍 저장 한도 또는 형식을 확인하세요.");
    }
  }

  function moveComparisonPair(direction) {
    if (isPlaylistSnapshotLocked() || !state.comparison.selectedPairId) return;
    const next = comparisonWorkflow.reorderPairList(
      state.comparison.pairList,
      state.comparison.selectedPairId,
      direction);
    if (next === state.comparison.pairList ||
        next.pairs.map(value => value.id).join("|") === state.comparison.pairList.pairs.map(value => value.id).join("|")) return;
    if (persistComparisonPairList(next, "비교쌍 순서 변경")) renderComparisonWorkflow();
  }

  function deleteSelectedComparisonPair() {
    if (isPlaylistSnapshotLocked() || !state.comparison.selectedPairId) return;
    const previousId = state.comparison.selectedPairId;
    const index = state.comparison.pairList.pairs.findIndex(record => record.id === previousId);
    const next = comparisonWorkflow.deletePair(state.comparison.pairList, previousId);
    if (!persistComparisonPairList(next, "비교쌍 삭제")) return;
    state.comparison.selectedPairId = next.pairs[Math.min(Math.max(index, 0), next.pairs.length - 1)]?.id || null;
    renderComparisonWorkflow();
  }

  function deleteAllComparisonPairs() {
    if (isPlaylistSnapshotLocked() || !state.comparison.pairList.pairs.length) return;
    if (!window.confirm("저장된 비교쌍을 모두 삭제할까요?")) return;
    const next = comparisonWorkflow.deleteAllPairs();
    if (!persistComparisonPairList(next, "비교쌍 전체 삭제")) return;
    state.comparison.selectedPairId = null;
    renderComparisonWorkflow();
  }

  function appendComparisonPlaylistEntries(createdEntries) {
    if (!requirePlaylistEditable()) return false;
    const entries = [];
    for (const created of createdEntries) {
      const definition = catalog.find(item =>
        item.builderKey === created.builderKey && sceneAliases(item).includes(created.code));
      if (!definition) {
        showToast("비교 컷이 등록된 장면 alias와 일치하지 않습니다.");
        return false;
      }
      entries.push({ ...definition, ...created });
    }
    const startIndex = state.playlist.length;
    state.playlist.push(...entries);
    state.selectedRows.clear();
    entries.forEach(entry => state.selectedRows.add(entry.id));
    state.currentIndex = startIndex;
    state.selectionAnchorIndex = state.currentIndex;
    renderPlaylist();
    updatePreview();
    addLog(`비교 컷 ${entries.length}개 추가 · ${entries[0]?.title || "비교쌍"}`);
    showToast(entries.length === 1 ? "비교 컷을 추가했습니다." : `비교 수익률 컷 ${entries.length}개를 추가했습니다.`);
    return true;
  }

  function addComparisonAction(actionId) {
    const pair = selectedComparisonPair();
    if (!pair) {
      showToast("저장 비교쌍에서 사용할 행을 선택하세요.");
      return;
    }
    const compatibility = comparisonWorkflow.getCompatibility(actionId, pair);
    if (!compatibility.supported) {
      showToast(comparisonCompatibilityText(compatibility.reason));
      return;
    }
    try {
      const entry = comparisonWorkflow.createComparisonPlaylistEntry(actionId, pair, {
        id: createId(),
        fadeDuration: DEFAULT_FADE_DURATION
      });
      if (appendComparisonPlaylistEntries([entry]) && entry.warning) showToast(entry.warning);
    } catch {
      showToast("선택한 비교쌍과 컷을 안전한 장면으로 구성하지 못했습니다.");
    }
  }

  function addComparisonYieldBatch() {
    const pair = selectedComparisonPair();
    if (!pair || !comparisonWorkflow.yieldActionIds.every(actionId =>
      comparisonWorkflow.isActionAllowed(actionId, pair))) {
      showToast("수익률 비교 5종은 국내 KRX 종목 두 개가 필요합니다.");
      return;
    }
    try {
      const entries = comparisonWorkflow.createYieldBatchEntries(pair, {
        idPrefix: createId(),
        fadeDuration: DEFAULT_FADE_DURATION
      });
      appendComparisonPlaylistEntries(entries);
    } catch {
      showToast("수익률 비교 5종을 안전하게 구성하지 못했습니다.");
    }
  }

  function comparisonCompatibilityText(reason) {
    return {
      "pair-incomplete-or-invalid": "저장 비교쌍을 선택하세요.",
      "comparison-graphs-require-two-krx-stocks": "비교 차트와 수익률은 국내 KRX 종목 두 개만 지원합니다.",
      "two-futures-targets-are-unsupported": "선물 지수 두 개의 조합은 지원하지 않습니다.",
      "futures-requires-a-market-target-counterpart": "선물은 다른 시장 지수와만 2열판으로 비교할 수 있습니다.",
      "mixed-market-and-world-is-unsupported": "시장 지수와 해외 종목의 혼합 2열판은 안전한 DTO가 없어 지원하지 않습니다.",
      "expected-mode-supports-only-krx-stocks-and-four-domestic-indices": "예상체결은 국내 KRX 종목과 국내 4개 지수만 지원합니다."
    }[reason] || "현재 Core에서 안전하게 지원하지 않는 비교 조합입니다.";
  }

  function renderComparisonTargetList(container, values, emptyText) {
    container.replaceChildren();
    if (!values.length) {
      const empty = document.createElement("div");
      empty.className = "comparison-target-empty";
      empty.textContent = emptyText;
      container.append(empty);
      return;
    }
    const locked = isPlaylistSnapshotLocked();
    for (const target of values) {
      const button = document.createElement("button");
      button.type = "button";
      button.className = "comparison-target";
      button.disabled = locked;
      button.title = "더블클릭하면 첫 번째 슬롯에 넣고 기존 첫 번째를 두 번째로 이동합니다.";
      const name = document.createElement("strong");
      const meta = document.createElement("small");
      name.textContent = comparisonTargetDisplay(target);
      meta.textContent = comparisonTargetMeta(target);
      button.append(name, meta);
      button.addEventListener("dblclick", () => rotateComparisonDraft(target));
      button.addEventListener("keydown", event => {
        if (event.key === "Enter") {
          event.preventDefault();
          rotateComparisonDraft(target);
        }
      });
      container.append(button);
    }
  }

  function renderComparisonSearchState(kind, element) {
    const search = state.comparison[kind];
    element.className = `comparison-search-state ${search.status}`;
    if (search.status === "loading") element.textContent = `“${search.query}” 검색 중입니다.`;
    else if (search.status === "error") element.textContent = search.error || "검색에 실패했습니다.";
    else if (search.status === "ready") {
      element.textContent = `${search.totalRowCount.toLocaleString("ko-KR")}건 · 대상을 더블클릭하세요.`;
    } else {
      element.textContent = kind === "domestic" ? "국내·NXT 종목명을 입력하세요." : "미국·대만 종목명을 입력하세요.";
    }
  }

  function renderComparisonWorkflow() {
    const visible = state.activeMarket === "compare";
    elements.comparisonWorkflow.hidden = !visible;
    if (!visible) return;
    const comparison = state.comparison;
    const locked = isPlaylistSnapshotLocked();
    const pairs = comparison.pairList.pairs;
    const selectedRecord = selectedComparisonRecord();
    const selectedPair = selectedRecord?.pair || null;
    elements.comparisonPairCount.textContent = `${pairs.length} PAIRS`;
    const legacyImport = comparison.legacyImport;
    elements.comparisonImportLegacy.classList.toggle("imported", Boolean(legacyImport.marker));
    elements.comparisonImportLegacy.disabled = locked || legacyImport.status === "loading" ||
      legacyImport.recoveryBlocked || Boolean(legacyImport.marker);
    elements.comparisonImportLegacy.textContent = legacyImport.marker
      ? `원본 ${legacyImport.marker.importedPairCount}쌍 가져옴`
      : (legacyImport.recoveryBlocked
        ? "원본 가져오기 복구 필요"
        : (legacyImport.status === "loading"
        ? "원본 검증 중…"
        : (legacyImport.status === "error" ? "원본 가져오기 재시도" : "원본 비교쌍 가져오기")));
    elements.comparisonImportLegacy.title = legacyImport.marker
      ? "원본 파일은 다시 참조하지 않으며 현재 로컬 비교쌍만 사용합니다."
      : (legacyImport.error || "고정된 원본 종목비교.dat를 한 번만 가져옵니다.");

    elements.comparisonSourceTabs.querySelectorAll("button[data-comparison-source]").forEach(button => {
      button.classList.toggle("active", button.dataset.comparisonSource === comparison.source);
      button.disabled = locked;
    });
    elements.comparisonDomesticPanel.hidden = comparison.source !== "domestic";
    elements.comparisonFixedPanel.hidden = comparison.source !== "fixed";
    elements.comparisonWorldPanel.hidden = comparison.source !== "world";

    renderComparisonSearchState("domestic", elements.comparisonDomesticState);
    renderComparisonSearchState("world", elements.comparisonWorldState);
    elements.comparisonDomesticSearch.disabled = locked;
    elements.comparisonDomesticSearchButton.disabled = locked || comparison.domestic.status === "loading";
    elements.comparisonWorldSearch.disabled = locked;
    elements.comparisonWorldSearchButton.disabled = locked || comparison.world.status === "loading";
    renderComparisonTargetList(
      elements.comparisonDomesticResults,
      comparison.domestic.results,
      comparison.domestic.status === "ready" ? "일치하는 국내·NXT 종목이 없습니다." : "검색 결과가 여기에 표시됩니다.");
    renderComparisonTargetList(elements.comparisonFixedTargets, comparisonWorkflow.marketTargets, "고정 비교 대상이 없습니다.");
    renderComparisonTargetList(
      elements.comparisonWorldResults,
      comparison.world.results,
      comparison.world.status === "ready" ? "일치하는 해외 종목이 없습니다." : "검색 결과가 여기에 표시됩니다.");

    elements.comparisonFirst.textContent = comparisonTargetDisplay(comparison.draftFirst);
    elements.comparisonSecond.textContent = comparisonTargetDisplay(comparison.draftSecond);
    elements.comparisonSwap.disabled = locked || !comparison.draftFirst || !comparison.draftSecond;
    elements.comparisonClear.disabled = locked || (!comparison.draftFirst && !comparison.draftSecond);
    elements.comparisonSavePair.disabled = locked ||
      !comparisonWorkflow.normalizePair([comparison.draftFirst, comparison.draftSecond]);

    elements.comparisonPairList.replaceChildren();
    if (!pairs.length) {
      const empty = document.createElement("div");
      empty.className = "comparison-target-empty";
      empty.textContent = "저장된 비교쌍이 없습니다.";
      elements.comparisonPairList.append(empty);
    } else {
      pairs.forEach((record, index) => {
        const row = document.createElement("button");
        row.type = "button";
        row.className = "comparison-pair-row";
        row.disabled = locked;
        row.classList.toggle("selected", record.id === comparison.selectedPairId);
        row.setAttribute("role", "option");
        row.setAttribute("aria-selected", record.id === comparison.selectedPairId ? "true" : "false");
        const number = document.createElement("span");
        number.textContent = String(index + 1).padStart(2, "0");
        const copy = document.createElement("span");
        const title = document.createElement("strong");
        const meta = document.createElement("small");
        title.textContent = comparisonWorkflow.pairLabel(record.pair);
        meta.textContent = record.pair.map(comparisonTargetMeta).join(" · ");
        copy.append(title, meta);
        const kind = document.createElement("span");
        kind.textContent = record.pair.map(target => target.kind.replace("-stock", "")).join("/");
        row.append(number, copy, kind);
        row.addEventListener("click", () => {
          if (isPlaylistSnapshotLocked()) return;
          comparison.selectedPairId = record.id;
          renderComparisonWorkflow();
        });
        elements.comparisonPairList.append(row);
      });
    }
    const selectedIndex = pairs.findIndex(record => record.id === comparison.selectedPairId);
    elements.comparisonMoveUp.disabled = locked || selectedIndex <= 0;
    elements.comparisonMoveDown.disabled = locked || selectedIndex < 0 || selectedIndex >= pairs.length - 1;
    elements.comparisonDeletePair.disabled = locked || selectedIndex < 0;
    elements.comparisonDeleteAll.disabled = locked || !pairs.length;

    elements.comparisonActionList.replaceChildren();
    let previousSection = null;
    comparisonWorkflow.actions.forEach((action, index) => {
      const sectionName = action.kind === "two-column"
        ? "2열판"
        : (action.kind === "comparison" ? "종목별 비교분석" : "종목별 수익률 비교");
      if (sectionName !== previousSection) {
        const section = document.createElement("div");
        section.className = "comparison-action-section";
        section.textContent = sectionName;
        elements.comparisonActionList.append(section);
        previousSection = sectionName;
      }
      const compatibility = comparisonWorkflow.getCompatibility(action.id, selectedPair);
      const row = document.createElement("div");
      row.className = "comparison-action-row";
      row.classList.toggle("disabled", !compatibility.supported);
      const number = document.createElement("span");
      number.textContent = String(index + 1).padStart(2, "0");
      const copy = document.createElement("span");
      copy.className = "comparison-action-copy";
      const label = document.createElement("strong");
      const detail = document.createElement("small");
      label.textContent = action.label;
      detail.textContent = compatibility.supported
        ? `${action.kind === "two-column" ? "s8018/s5032" : (action.kind === "return" ? "s5029" : action.builderKey)} · ${compatibility.effectiveMode || "GRAPH"}`
        : comparisonCompatibilityText(compatibility.reason);
      copy.append(label, detail);
      const add = document.createElement("button");
      add.type = "button";
      add.className = "comparison-action-add";
      add.textContent = "+";
      add.disabled = locked || !compatibility.supported;
      add.title = compatibility.supported ? "플레이리스트에 추가" : comparisonCompatibilityText(compatibility.reason);
      add.addEventListener("click", () => addComparisonAction(action.id));
      row.addEventListener("dblclick", event => {
        if (!event.target.closest("button") && compatibility.supported) addComparisonAction(action.id);
      });
      row.append(number, copy, add);
      elements.comparisonActionList.append(row);
    });
    elements.comparisonYieldBatch.disabled = locked || !selectedPair ||
      !comparisonWorkflow.yieldActionIds.every(actionId => comparisonWorkflow.isActionAllowed(actionId, selectedPair));
  }

  function requestComparisonDomesticSearch(event) {
    event?.preventDefault();
    const search = state.comparison.domestic;
    clearComparisonSearchTimeout("domestic");
    const query = String(elements.comparisonDomesticSearch.value || "").trim();
    if (!query || query.length > 64 || /[\u0000-\u001f\u007f]/.test(query)) {
      search.status = "error";
      search.error = "1~64자의 국내·NXT 종목명을 입력하세요.";
      search.results = [];
      renderComparisonWorkflow();
      return;
    }
    if (!nativeBridge) {
      search.status = "error";
      search.error = "브라우저 미리보기에서는 실제 종목 DB 검색을 사용할 수 없습니다.";
      renderComparisonWorkflow();
      return;
    }
    const requestId = createId();
    Object.assign(search, {
      requestId, query, status: "loading", results: [], totalRowCount: 0, error: ""
    });
    renderComparisonWorkflow();
    postNative("search-stocks", { requestId, query, maximumResults: 200 });
    search.timeoutId = setTimeout(() => {
      if (search.requestId !== requestId || search.status !== "loading") return;
      search.status = "error";
      search.error = "국내·NXT 종목 검색 응답 시간이 초과되었습니다.";
      renderComparisonWorkflow();
    }, 30000);
  }

  function handleComparisonDomesticResults(payload) {
    const search = state.comparison.domestic;
    if (!payload || payload.requestId !== search.requestId || payload.query !== search.query) return false;
    clearComparisonSearchTimeout("domestic");
    const raw = Array.isArray(payload.results) ? payload.results.slice(0, 500) : [];
    const results = raw.flatMap(value => {
      const target = comparisonTargetFromStockResult(value);
      return target ? [target] : [];
    });
    if (results.length !== raw.length || new Set(results.map(comparisonWorkflow.targetKey)).size !== results.length) {
      search.status = "error";
      search.results = [];
      search.error = "국내·NXT 종목 검색 결과 식별자가 올바르지 않습니다.";
    } else {
      search.status = "ready";
      search.results = results;
      search.totalRowCount = Number.isInteger(payload.totalRowCount) ? Math.max(0, payload.totalRowCount) : results.length;
      search.error = "";
    }
    renderComparisonWorkflow();
    return true;
  }

  function handleComparisonDomesticError(payload) {
    const search = state.comparison.domestic;
    if (!payload || payload.requestId !== search.requestId || payload.query !== search.query) return false;
    clearComparisonSearchTimeout("domestic");
    search.status = "error";
    search.results = [];
    search.error = typeof payload.message === "string" && payload.message.trim()
      ? payload.message.trim()
      : "국내·NXT 종목 검색에 실패했습니다.";
    renderComparisonWorkflow();
    return true;
  }

  function requestComparisonWorldSearch(event) {
    event?.preventDefault();
    const search = state.comparison.world;
    clearComparisonSearchTimeout("world");
    const query = String(elements.comparisonWorldSearch.value || "").trim();
    if (!query || query.length > 64 || /[\u0000-\u001f\u007f]/.test(query)) {
      search.status = "error";
      search.error = "1~64자의 미국·대만 종목명을 입력하세요.";
      search.results = [];
      renderComparisonWorkflow();
      return;
    }
    if (!nativeBridge) {
      search.status = "error";
      search.error = "브라우저 미리보기에서는 실제 해외종목 DB 검색을 사용할 수 없습니다.";
      renderComparisonWorkflow();
      return;
    }
    const requestId = createId();
    Object.assign(search, {
      requestId, query, status: "loading", results: [], totalRowCount: 0, error: ""
    });
    renderComparisonWorkflow();
    postNative("search-world-stocks", { requestId, query, maximumResults: 100 });
    search.timeoutId = setTimeout(() => {
      if (search.requestId !== requestId || search.status !== "loading") return;
      search.status = "error";
      search.error = "해외종목 검색 응답 시간이 초과되었습니다.";
      renderComparisonWorkflow();
    }, 30000);
  }

  function handleComparisonWorldResults(payload) {
    const search = state.comparison.world;
    if (!payload || payload.requestId !== search.requestId || payload.query !== search.query) return;
    clearComparisonSearchTimeout("world");
    const raw = Array.isArray(payload.results) ? payload.results.slice(0, 500) : [];
    const results = raw.flatMap(value => {
      const target = comparisonTargetFromWorldResult(value);
      return target ? [target] : [];
    });
    if (results.length !== raw.length || new Set(results.map(comparisonWorkflow.targetKey)).size !== results.length) {
      search.status = "error";
      search.results = [];
      search.error = "해외종목 검색 결과 식별자가 올바르지 않습니다.";
    } else {
      search.status = "ready";
      search.results = results;
      search.totalRowCount = Number.isInteger(payload.totalRowCount) ? Math.max(0, payload.totalRowCount) : results.length;
      search.error = "";
    }
    renderComparisonWorkflow();
  }

  function handleComparisonWorldError(payload) {
    const search = state.comparison.world;
    if (!payload || payload.requestId !== search.requestId || payload.query !== search.query) return;
    clearComparisonSearchTimeout("world");
    search.status = "error";
    search.results = [];
    search.error = typeof payload.message === "string" && payload.message.trim()
      ? payload.message.trim()
      : "해외종목 검색에 실패했습니다.";
    renderComparisonWorkflow();
  }

  function synchronizeComparisonEntries() {
    let invalid = false;
    state.playlist = state.playlist.map(item => {
      if (item?.operator?.source !== "legacy-comparison-workflow") return item;
      const refreshed = comparisonWorkflow.refreshComparisonPlaylistEntry(item);
      if (!refreshed) {
        invalid = true;
        return item;
      }
      const definition = catalog.find(value =>
        value.builderKey === refreshed.builderKey && sceneAliases(value).includes(refreshed.code));
      if (!definition) {
        invalid = true;
        return item;
      }
      return { ...definition, ...refreshed };
    });
    if (invalid) {
      showToast("저장된 비교 항목의 신뢰 매핑이 손상되어 PREPARE를 중단했습니다.");
      return false;
    }
    return true;
  }

  function clearThemeTimeout(kind) {
    const value = state.theme[kind];
    if (value?.timeoutId !== null) clearTimeout(value.timeoutId);
    if (value) value.timeoutId = null;
  }

  function themeSessionWire(theme) {
    if (theme?.market === "krx") return "notApplicable";
    return theme?.session === "AFTER_MARKET" ? "afterMarket" : "preMarket";
  }

  function themeIdentity(theme) {
    if (!theme) return "";
    return [theme.market, themeSessionWire(theme), theme.themeCode, theme.title].join("|");
  }

  function selectedThemeWithItemCount(itemCount) {
    const selected = state.theme.selected;
    return selected ? themeWorkflow.normalizeTheme({ ...selected, itemCount }) : null;
  }

  function themeCompatibilityText(reason) {
    return {
      "invalid-theme": "테마를 선택하세요.",
      "invalid-theme-sort": "테마 정렬 방식을 확인하세요.",
      "theme-preview-is-empty": "종목이 없는 테마는 송출할 수 없습니다.",
      "expected-theme-is-krx-only": "예상체결가는 KRX 테마 5단 표에서만 지원합니다.",
      "nxt-session-required": "NXT 장전 또는 시간외 세션을 선택하세요."
    }[reason] || "현재 테마와 컷 조합은 지원하지 않습니다.";
  }

  function addThemeAction(actionId) {
    if (!requirePlaylistEditable()) return;
    const theme = state.theme.selected;
    const sort = state.theme.sort;
    if (state.theme.preview.status !== "ready" || !theme) {
      showToast("테마를 선택하고 종목 미리보기가 완료될 때까지 기다리세요.");
      return;
    }
    const compatibility = themeWorkflow.getCompatibility(actionId, theme, sort);
    if (!compatibility.supported) {
      showToast(themeCompatibilityText(compatibility.reason));
      return;
    }
    try {
      const created = themeWorkflow.createThemePlaylistEntry(actionId, theme, sort, {
        id: createId(),
        fadeDuration: DEFAULT_FADE_DURATION
      });
      const definition = catalog.find(item =>
        item.builderKey === created.builderKey && sceneAliases(item).includes(created.code));
      if (!definition) throw new Error("The theme action does not resolve to a registered scene alias.");
      const entry = { ...definition, ...created };
      state.playlist.push(entry);
      state.selectedRows.clear();
      state.selectedRows.add(entry.id);
      state.currentIndex = state.playlist.length - 1;
      state.selectionAnchorIndex = state.currentIndex;
      renderPlaylist();
      updatePreview();
      addLog(`${entry.code} ${entry.title} · ${entry.detail} 추가`);
      showToast("테마 컷을 플레이리스트에 추가했습니다.");
    } catch {
      showToast("테마와 컷을 안전한 페이지 장면으로 구성하지 못했습니다.");
    }
  }

  function renderThemeWorkflow() {
    const visible = state.activeMarket === "theme";
    elements.themeWorkflow.hidden = !visible;
    if (!visible) return;
    const theme = state.theme;
    const search = theme.search;
    const preview = theme.preview;
    const locked = isPlaylistSnapshotLocked();
    elements.themeCatalogManageButton.disabled = operatorUiLocked();
    elements.themeCatalogManageButton.textContent = preview.catalogContext ? "선택 테마 편집" : "테마 관리";
    elements.themeCount.textContent = search.status === "loading"
      ? "LOADING"
      : `${search.totalRowCount.toLocaleString("ko-KR")} THEMES`;
    elements.themeSearch.disabled = locked;
    elements.themeNxtSession.disabled = locked;
    elements.themeSearchButton.disabled = locked || search.status === "loading";
    elements.themeSearchState.className = `theme-search-state ${search.status}`;
    if (search.status === "loading") {
      elements.themeSearchState.textContent = "KRX(Oracle)와 NXT(MariaDB) 테마를 조회하고 있습니다.";
    } else if (search.status === "error") {
      elements.themeSearchState.textContent = search.error || "테마 검색에 실패했습니다.";
    } else if (search.status === "ready") {
      elements.themeSearchState.textContent = `${search.totalRowCount.toLocaleString("ko-KR")}개 테마${search.truncated ? " · 결과 제한 적용" : ""}`;
    } else {
      elements.themeSearchState.textContent = "조회 버튼을 누르면 전체 테마를 불러옵니다.";
    }

    elements.themeResults.replaceChildren();
    if (!search.results.length) {
      const empty = document.createElement("div");
      empty.className = "comparison-target-empty";
      empty.textContent = search.status === "ready" ? "일치하는 테마가 없습니다." : "테마 검색 결과가 여기에 표시됩니다.";
      elements.themeResults.append(empty);
    } else {
      const selectedIdentity = themeIdentity(theme.selected);
      for (const item of search.results) {
        const row = document.createElement("button");
        row.type = "button";
        row.className = "theme-result";
        row.disabled = locked;
        const selected = themeIdentity(item) === selectedIdentity;
        row.classList.toggle("selected", selected);
        row.setAttribute("role", "option");
        row.setAttribute("aria-selected", selected ? "true" : "false");
        const copy = document.createElement("span");
        const title = document.createElement("strong");
        const meta = document.createElement("small");
        title.textContent = item.displayTitle;
        meta.textContent = `${item.themeCode} · ${themeSessionWire(item)}`;
        copy.append(title, meta);
        const market = document.createElement("span");
        market.className = "theme-market-badge";
        market.textContent = item.market.toLocaleUpperCase("en-US");
        row.append(copy, market);
        row.addEventListener("click", () => selectTheme(item));
        elements.themeResults.append(row);
      }
    }

    elements.selectedTheme.hidden = !theme.selected;
    elements.themeSort.disabled = locked || !theme.selected;
    if (theme.selected) {
      elements.selectedThemeTitle.textContent = theme.selected.displayTitle;
      elements.selectedThemeMeta.textContent = `${theme.selected.themeCode} · ${theme.selected.market.toLocaleUpperCase("en-US")} · ${themeSessionWire(theme.selected)}`;
    }

    elements.themePreviewCount.textContent = `${preview.totalRowCount.toLocaleString("ko-KR")} ITEMS`;
    elements.themePreviewState.className = `theme-preview-state ${preview.status}`;
    if (preview.status === "loading") elements.themePreviewState.textContent = "선택 테마의 종목을 조회하고 있습니다.";
    else if (preview.status === "error") elements.themePreviewState.textContent = preview.error || "테마 종목을 불러오지 못했습니다.";
    else if (preview.status === "ready") {
      elements.themePreviewState.textContent = `${preview.totalRowCount.toLocaleString("ko-KR")}개 종목${preview.truncated ? " · 240개 상한 적용" : ""}`;
    } else elements.themePreviewState.textContent = "테마를 선택하면 종목 구성을 확인합니다.";
    elements.themePreviewItems.replaceChildren();
    if (!preview.items.length) {
      const empty = document.createElement("div");
      empty.className = "comparison-target-empty";
      empty.textContent = preview.status === "ready" ? "테마에 송출 가능한 종목이 없습니다." : "미리보기 대기 중";
      elements.themePreviewItems.append(empty);
    } else {
      for (const item of preview.items) {
        const row = document.createElement("div");
        row.className = "theme-preview-item";
        const index = document.createElement("span");
        const name = document.createElement("strong");
        const code = document.createElement("small");
        index.textContent = String(item.inputIndex).padStart(2, "0");
        name.textContent = item.itemName;
        code.textContent = item.itemCode;
        row.append(index, name, code);
        elements.themePreviewItems.append(row);
      }
    }

    elements.themeActionList.replaceChildren();
    themeWorkflow.actions.forEach((action, index) => {
      const compatibility = preview.status === "ready"
        ? themeWorkflow.getCompatibility(action.id, theme.selected, theme.sort)
        : { supported: false, reason: "invalid-theme" };
      const row = document.createElement("div");
      row.className = "theme-action-row";
      row.classList.toggle("disabled", !compatibility.supported);
      const number = document.createElement("span");
      number.textContent = String(index + 1).padStart(2, "0");
      const copy = document.createElement("span");
      copy.className = "theme-action-copy";
      const label = document.createElement("strong");
      const detail = document.createElement("small");
      label.textContent = action.label;
      const page = theme.selected ? themeWorkflow.calculatePagePreview(action.id, theme.selected) : null;
      detail.textContent = compatibility.supported
        ? `${action.code} · ${action.pageSize}개 × ${page?.pageCount ?? "?"}페이지${page?.isTruncated ? " · 20페이지 상한" : ""}`
        : themeCompatibilityText(compatibility.reason);
      copy.append(label, detail);
      const add = document.createElement("button");
      add.type = "button";
      add.className = "theme-action-add";
      add.textContent = "+";
      add.disabled = locked || !compatibility.supported;
      add.title = compatibility.supported ? "플레이리스트에 추가" : themeCompatibilityText(compatibility.reason);
      add.addEventListener("click", () => addThemeAction(action.id));
      row.addEventListener("dblclick", event => {
        if (!event.target.closest("button") && compatibility.supported) addThemeAction(action.id);
      });
      row.append(number, copy, add);
      elements.themeActionList.append(row);
    });
  }

  function requestThemeSearch(event) {
    event?.preventDefault();
    const search = state.theme.search;
    clearThemeTimeout("search");
    clearThemeTimeout("preview");
    const query = String(elements.themeSearch.value || "").trim();
    if (query.length > 64 || /[\u0000-\u001f\u007f]/.test(query)) {
      search.status = "error";
      search.error = "테마 검색어는 64자 이하여야 합니다.";
      search.results = [];
      renderThemeWorkflow();
      return;
    }
    state.theme.selected = null;
    Object.assign(state.theme.preview, {
      requestId: null, status: "idle", items: [], totalRowCount: 0, truncated: false, error: ""
    });
    if (!nativeBridge) {
      search.status = "error";
      search.error = "브라우저 미리보기에서는 실제 테마 DB 조회를 사용할 수 없습니다.";
      renderThemeWorkflow();
      return;
    }
    const requestId = createId();
    Object.assign(search, {
      requestId, query, status: "loading", results: [], totalRowCount: 0, truncated: false, error: ""
    });
    renderThemeWorkflow();
    postNative("search-themes", {
      requestId,
      query,
      nxtSession: state.theme.nxtSession,
      maximumResults: 200
    });
    search.timeoutId = setTimeout(() => {
      if (search.requestId !== requestId || search.status !== "loading") return;
      search.status = "error";
      search.error = "테마 검색 응답 시간이 초과되었습니다.";
      renderThemeWorkflow();
    }, 30000);
  }

  function handleThemeSearchResults(payload) {
    const search = state.theme.search;
    if (!payload || payload.requestId !== search.requestId || payload.query !== search.query ||
        payload.nxtSession !== state.theme.nxtSession) return;
    clearThemeTimeout("search");
    const raw = Array.isArray(payload.results) ? payload.results.slice(0, 500) : [];
    const results = raw.flatMap(value => {
      const normalized = themeWorkflow.normalizeTheme({ ...value, itemCount: null });
      return normalized ? [normalized] : [];
    });
    const identities = results.map(themeIdentity);
    if (results.length !== raw.length || new Set(identities).size !== identities.length) {
      search.status = "error";
      search.results = [];
      search.error = "테마 검색 결과 식별자가 올바르지 않습니다.";
    } else {
      search.status = "ready";
      search.results = results;
      search.totalRowCount = Number.isInteger(payload.totalRowCount) ? Math.max(0, payload.totalRowCount) : results.length;
      search.truncated = payload.truncated === true;
      search.error = "";
    }
    renderThemeWorkflow();
  }

  function handleThemeSearchError(payload) {
    const search = state.theme.search;
    if (!payload || payload.requestId !== search.requestId || payload.query !== search.query ||
        payload.nxtSession !== state.theme.nxtSession) return;
    clearThemeTimeout("search");
    search.status = "error";
    search.results = [];
    search.error = typeof payload.message === "string" && payload.message.trim()
      ? payload.message.trim()
      : "테마 검색에 실패했습니다.";
    renderThemeWorkflow();
  }

  function selectTheme(theme) {
    if (isPlaylistSnapshotLocked()) return;
    const normalized = themeWorkflow.normalizeTheme({ ...theme, itemCount: null });
    if (!normalized) return;
    state.theme.selected = normalized;
    requestThemePreview(normalized);
    addLog(`테마 선택 · ${normalized.displayTitle} · ${normalized.themeCode}`);
  }

  function requestThemePreview(theme) {
    clearThemeTimeout("preview");
    const preview = state.theme.preview;
    const requestId = createId();
    Object.assign(preview, {
      requestId, status: "loading", items: [], totalRowCount: 0, truncated: false, error: "",
      catalogContext: null
    });
    renderThemeWorkflow();
    if (!nativeBridge) {
      preview.status = "error";
      preview.error = "브라우저 미리보기에서는 실제 테마 종목 조회를 사용할 수 없습니다.";
      renderThemeWorkflow();
      return;
    }
    postNative("request-theme-preview", {
      requestId,
      market: theme.market,
      session: themeSessionWire(theme),
      themeCode: theme.themeCode,
      themeTitle: theme.title,
      maximumItems: 240
    });
    preview.timeoutId = setTimeout(() => {
      if (preview.requestId !== requestId || preview.status !== "loading") return;
      preview.status = "error";
      preview.error = "테마 종목 미리보기 응답 시간이 초과되었습니다.";
      renderThemeWorkflow();
    }, 30000);
  }

  function handleThemePreviewResults(payload) {
    const preview = state.theme.preview;
    if (!payload || payload.requestId !== preview.requestId || !state.theme.selected) return;
    const rawSelection = payload.selection;
    const responseTheme = themeWorkflow.normalizeTheme({
      market: rawSelection?.market,
      session: rawSelection?.session,
      themeCode: rawSelection?.themeCode,
      title: rawSelection?.title,
      itemCount: Number.isInteger(payload.totalRowCount) ? payload.totalRowCount : null
    });
    if (!responseTheme || themeIdentity(responseTheme) !== themeIdentity(state.theme.selected)) return;
    clearThemeTimeout("preview");
    const rawItems = Array.isArray(payload.items) ? payload.items.slice(0, 240) : [];
    const seenCodes = new Set();
    const seenIndexes = new Set();
    const items = [];
    for (const value of rawItems) {
      const inputIndex = value?.inputIndex;
      const itemCode = typeof value?.itemCode === "string" ? value.itemCode.trim() : "";
      const itemName = typeof value?.itemName === "string" ? value.itemName.trim() : "";
      if (!Number.isInteger(inputIndex) || inputIndex < 0 || inputIndex > 9999 ||
          !itemCode || itemCode.length > 32 || !itemName || itemName.length > 200 ||
          /[\u0000-\u001f\u007f]/.test(`${itemCode}${itemName}`) ||
          seenCodes.has(itemCode) || seenIndexes.has(inputIndex)) {
        preview.status = "error";
        preview.items = [];
        preview.error = "테마 종목 미리보기 식별자가 올바르지 않습니다.";
        renderThemeWorkflow();
        return;
      }
      seenCodes.add(itemCode);
      seenIndexes.add(inputIndex);
      items.push({ inputIndex, itemCode, itemName });
    }
    if (!Number.isInteger(payload.totalRowCount) || payload.totalRowCount !== items.length) {
      preview.status = "error";
      preview.items = [];
      preview.error = "테마 종목 미리보기 행 수가 올바르지 않습니다.";
      renderThemeWorkflow();
      return;
    }
    state.theme.selected = responseTheme;
    preview.status = "ready";
    preview.items = items;
    preview.totalRowCount = items.length;
    preview.truncated = payload.truncated === true;
    preview.error = "";
    const catalogSelection = {
      market: responseTheme.market,
      session: themeSessionWire(responseTheme),
      themeCode: responseTheme.themeCode,
      themeTitle: responseTheme.title,
      source: responseTheme.market === "krx" ? "oracle" : "mariaDb"
    };
    const catalogPreview = {
      requestId: payload.requestId,
      selection: {
        market: responseTheme.market,
        session: themeSessionWire(responseTheme),
        themeCode: responseTheme.themeCode,
        title: responseTheme.title
      },
      retrievedAt: payload.retrievedAt,
      totalRowCount: items.length,
      truncated: payload.truncated === true,
      items: items.map(value => ({ ...value }))
    };
    try {
      operatorCatalogWorkflow.createThemeEditDraft(catalogSelection, catalogPreview);
      preview.catalogContext = Object.freeze({
        selection: Object.freeze({ ...catalogSelection }),
        preview: Object.freeze({ ...catalogPreview, items: Object.freeze(catalogPreview.items) })
      });
    } catch {
      preview.catalogContext = null;
    }
    renderThemeWorkflow();
    addLog(`테마 종목 조회 완료 · ${responseTheme.displayTitle} · ${items.length}건`);
  }

  function handleThemePreviewError(payload) {
    const preview = state.theme.preview;
    if (!payload || payload.requestId !== preview.requestId || !state.theme.selected) return;
    const selection = payload.selection;
    const identity = [selection?.market, selection?.session, selection?.themeCode, selection?.title].join("|");
    if (identity !== themeIdentity(state.theme.selected)) return;
    clearThemeTimeout("preview");
    preview.status = "error";
    preview.items = [];
    preview.catalogContext = null;
    preview.error = typeof payload.message === "string" && payload.message.trim()
      ? payload.message.trim()
      : "테마 종목을 불러오지 못했습니다.";
    renderThemeWorkflow();
  }

  function synchronizeThemeEntries() {
    let invalid = false;
    state.playlist = state.playlist.map(item => {
      if (item?.operator?.source !== "legacy-theme-workflow") return item;
      const namedKrx = namedKrxThemeRestoreWorkflow.isMaterializedEntry(item);
      const refreshed = namedKrx
        ? item
        : namedNxtThemeRestoreWorkflow.isMaterializedEntry(item)
          ? namedNxtThemeRestoreWorkflow.refreshDynamicSession(item, new Date())
          : themeWorkflow.refreshThemePlaylistEntry(item);
      if (!refreshed) {
        invalid = true;
        return item;
      }
      const definition = catalog.find(value =>
        value.builderKey === refreshed.builderKey && sceneAliases(value).includes(refreshed.code));
      if (!definition) {
        invalid = true;
        return item;
      }
      // Native-verified named theme entries already have the complete operator
      // shape. Do not clone them here: the in-memory verification mark must
      // survive until PREPARE/save validation.
      return namedKrx || namedNxtThemeRestoreWorkflow.isMaterializedEntry(refreshed)
        ? refreshed
        : { ...definition, ...refreshed };
    });
    if (invalid) {
      showToast("저장된 테마 항목의 신뢰 매핑이 손상되어 PREPARE를 중단했습니다.");
      return false;
    }
    return true;
  }

  function clearOverseasTimeout(kind) {
    const value = state.overseas[kind];
    if (value?.timeoutId !== null) clearTimeout(value.timeoutId);
    if (value) value.timeoutId = null;
  }

  function currentOverseasStore() {
    return state.overseas[state.overseas.source];
  }

  function overseasIdentity(kind, value) {
    if (!value) return "";
    return kind === "industry"
      ? [value.koreanName, value.inputName, value.symbol, value.nationCode, value.fdtc].join("|")
      : [value.inputName, value.symbol, value.nationCode, value.fdtc].join("|");
  }

  function selectOverseas(value) {
    if (isPlaylistSnapshotLocked()) return;
    const kind = state.overseas.source;
    const normalized = kind === "industry"
      ? overseasWorkflow.normalizeIndustry(value)
      : overseasWorkflow.normalizeStock(value);
    if (!normalized) return;
    state.overseas[kind].selected = normalized;
    renderOverseasWorkflow();
    addLog(`해외 ${kind === "industry" ? "업종" : "종목"} 선택 · ${kind === "industry" ? normalized.koreanName : normalized.inputName} · ${normalized.symbol}`);
  }

  function addOverseasAction(actionId) {
    if (!requirePlaylistEditable()) return;
    const kind = state.overseas.source;
    const selected = state.overseas[kind].selected;
    if (!selected) {
      showToast("먼저 해외업종 또는 해외종목을 선택하세요.");
      elements.overseasSearch.focus();
      return;
    }
    try {
      const options = {
        id: createId(),
        fadeDuration: DEFAULT_FADE_DURATION,
        ma5: state.candleOptions.ma5,
        ma20: state.candleOptions.ma20
      };
      const created = kind === "industry"
        ? overseasWorkflow.createOverseasIndustryPlaylistEntry(selected, options)
        : overseasWorkflow.createOverseasStockPlaylistEntry(selected, actionId, options);
      const definition = catalog.find(item =>
        item.builderKey === created.builderKey && sceneAliases(item).includes(created.code));
      if (!definition) throw new Error("The overseas action does not resolve to a scene alias.");
      const entry = { ...definition, ...created };
      state.playlist.push(entry);
      state.selectedRows.clear();
      state.selectedRows.add(entry.id);
      state.currentIndex = state.playlist.length - 1;
      state.selectionAnchorIndex = state.currentIndex;
      renderPlaylist();
      updatePreview();
      addLog(`${entry.code} ${entry.title} · ${entry.detail} 추가`);
      showToast("해외 대상 컷을 플레이리스트에 추가했습니다.");
    } catch {
      showToast("선택한 해외 대상과 컷을 안전하게 구성하지 못했습니다.");
    }
  }

  function renderOverseasWorkflow() {
    renderGlobalCandleOptions();
    const visible = state.activeMarket === "foreign-stock";
    elements.overseasWorkflow.hidden = !visible;
    if (!visible) return;
    const kind = state.overseas.source;
    const store = currentOverseasStore();
    const locked = isPlaylistSnapshotLocked();
    elements.overseasSourceTabs.querySelectorAll("button[data-overseas-source]").forEach(button => {
      button.classList.toggle("active", button.dataset.overseasSource === kind);
      button.disabled = locked;
    });
    elements.overseasCount.textContent = store.status === "loading"
      ? "LOADING"
      : `${store.totalRowCount.toLocaleString("ko-KR")} ${kind === "industry" ? "INDUSTRIES" : "STOCKS"}`;
    elements.overseasSearch.disabled = locked;
    elements.overseasSearchButton.disabled = locked || store.status === "loading";
    elements.overseasSearchState.className = `overseas-search-state ${store.status}`;
    if (store.status === "loading") {
      elements.overseasSearchState.textContent = kind === "industry"
        ? "Oracle에서 미국 해외업종을 조회하고 있습니다."
        : "Oracle에서 미국·대만 해외종목을 조회하고 있습니다.";
    } else if (store.status === "error") {
      elements.overseasSearchState.textContent = store.error || "해외 대상 조회에 실패했습니다.";
    } else if (store.status === "ready") {
      elements.overseasSearchState.textContent = `${store.totalRowCount.toLocaleString("ko-KR")}개 결과${store.truncated ? " · 결과 제한 적용" : ""}`;
    } else {
      elements.overseasSearchState.textContent = "조회 버튼을 누르면 전체 목록을 불러옵니다.";
    }

    elements.overseasResults.replaceChildren();
    if (!store.results.length) {
      const empty = document.createElement("div");
      empty.className = "comparison-target-empty";
      empty.textContent = store.status === "ready" ? "일치하는 결과가 없습니다." : "검색 결과가 여기에 표시됩니다.";
      elements.overseasResults.append(empty);
    } else {
      const selectedIdentity = overseasIdentity(kind, store.selected);
      for (const item of store.results) {
        const row = document.createElement("button");
        row.type = "button";
        row.className = "overseas-result";
        row.disabled = locked;
        const selected = overseasIdentity(kind, item) === selectedIdentity;
        row.classList.toggle("selected", selected);
        row.setAttribute("role", "option");
        row.setAttribute("aria-selected", selected ? "true" : "false");
        const copy = document.createElement("span");
        const name = document.createElement("strong");
        const meta = document.createElement("small");
        name.textContent = kind === "industry" ? item.koreanName : item.inputName;
        meta.textContent = kind === "industry"
          ? `${item.inputName} · ${item.symbol}`
          : `${item.symbol} · FDTC ${item.fdtc}`;
        copy.append(name, meta);
        const nation = document.createElement("span");
        nation.textContent = item.nationCode;
        row.append(copy, nation);
        row.addEventListener("click", () => selectOverseas(item));
        elements.overseasResults.append(row);
      }
    }

    elements.selectedOverseas.hidden = !store.selected;
    elements.overseasMa5.disabled = locked || kind !== "stock" || !store.selected;
    elements.overseasMa20.disabled = locked || kind !== "stock" || !store.selected;
    if (store.selected) {
      elements.selectedOverseasName.textContent = kind === "industry"
        ? store.selected.koreanName
        : store.selected.inputName;
      elements.selectedOverseasMeta.textContent = `${store.selected.symbol} · ${store.selected.nationCode} · FDTC ${store.selected.fdtc}`;
    }

    const actions = kind === "industry"
      ? overseasWorkflow.industryActions
      : overseasWorkflow.stockActions;
    elements.overseasActionList.replaceChildren();
    actions.forEach((action, index) => {
      const allowed = overseasWorkflow.isActionAllowed(kind, action.id, store.selected);
      const row = document.createElement("div");
      row.className = "overseas-action-row";
      row.classList.toggle("disabled", !allowed);
      const number = document.createElement("span");
      number.textContent = String(index + 1).padStart(2, "0");
      const copy = document.createElement("span");
      copy.className = "overseas-action-copy";
      const label = document.createElement("strong");
      const detail = document.createElement("small");
      label.textContent = action.label;
      detail.textContent = `${action.code}${action.periodDays ? ` · ${action.periodDays}일` : " · 현재가"}`;
      copy.append(label, detail);
      const add = document.createElement("button");
      add.type = "button";
      add.className = "overseas-action-add";
      add.textContent = "+";
      add.disabled = locked || !allowed;
      add.title = allowed ? "플레이리스트에 추가" : "해외 대상을 먼저 선택하세요.";
      add.addEventListener("click", () => addOverseasAction(action.id));
      row.addEventListener("dblclick", event => {
        if (!event.target.closest("button") && allowed) addOverseasAction(action.id);
      });
      row.append(number, copy, add);
      elements.overseasActionList.append(row);
    });
  }

  function requestOverseasSearch(event) {
    event?.preventDefault();
    if (isPlaylistSnapshotLocked()) return;
    const kind = state.overseas.source;
    const store = state.overseas[kind];
    clearOverseasTimeout(kind);
    let request;
    try {
      const createRequest = kind === "industry"
        ? overseasWorkflow.createIndustrySearchRequest
        : overseasWorkflow.createStockSearchRequest;
      request = createRequest(
        createId(),
        String(elements.overseasSearch.value || ""),
        overseasWorkflow.bridgeContracts[kind].maximumResults);
    } catch {
      store.status = "error";
      store.error = "검색어는 64자 이하의 안전한 문자여야 합니다.";
      store.results = [];
      store.totalRowCount = 0;
      renderOverseasWorkflow();
      return;
    }
    store.request = request;
    store.status = "loading";
    store.results = [];
    store.selected = null;
    store.totalRowCount = 0;
    store.truncated = false;
    store.error = "";
    renderOverseasWorkflow();
    if (!nativeBridge) {
      store.status = "error";
      store.error = "브라우저 미리보기에서는 실제 해외 DB 조회를 사용할 수 없습니다.";
      renderOverseasWorkflow();
      return;
    }
    postNative(overseasWorkflow.bridgeContracts[kind].requestType, request);
    store.timeoutId = setTimeout(() => {
      if (store.request !== request || store.status !== "loading") return;
      store.status = "error";
      store.error = "해외 대상 조회 응답 시간이 초과되었습니다.";
      renderOverseasWorkflow();
    }, 30000);
  }

  function handleOverseasSearchResults(kind, payload) {
    const store = state.overseas[kind];
    const request = store.request;
    if (!request || payload?.requestId !== request.requestId || payload?.query !== request.query) return false;
    clearOverseasTimeout(kind);
    const response = kind === "industry"
      ? overseasWorkflow.normalizeIndustrySearchResponse(payload, request)
      : overseasWorkflow.normalizeStockSearchResponse(payload, request);
    if (!response) {
      store.status = "error";
      store.results = [];
      store.totalRowCount = 0;
      store.error = "해외 대상 응답 형식 또는 식별자가 올바르지 않습니다.";
    } else {
      store.status = "ready";
      store.results = [...response.results];
      store.totalRowCount = response.totalRowCount;
      store.truncated = response.truncated;
      store.error = "";
    }
    renderOverseasWorkflow();
    return true;
  }

  function handleOverseasSearchError(kind, payload) {
    const store = state.overseas[kind];
    const request = store.request;
    if (!request || payload?.requestId !== request.requestId || payload?.query !== request.query) return false;
    clearOverseasTimeout(kind);
    const error = kind === "industry"
      ? overseasWorkflow.normalizeIndustrySearchError(payload, request)
      : overseasWorkflow.normalizeStockSearchError(payload, request);
    store.status = "error";
    store.results = [];
    store.totalRowCount = 0;
    store.error = error?.message || "해외 대상 조회에 실패했습니다.";
    renderOverseasWorkflow();
    return true;
  }

  function synchronizeOverseasEntries() {
    let invalid = false;
    state.playlist = state.playlist.map(item => {
      if (item?.operator?.source !== "legacy-overseas-workflow") return item;
      const refreshed = overseasWorkflow.refreshOverseasPlaylistEntry(item);
      if (!refreshed) {
        invalid = true;
        return item;
      }
      const definition = catalog.find(value =>
        value.builderKey === refreshed.builderKey && sceneAliases(value).includes(refreshed.code));
      if (!definition) {
        invalid = true;
        return item;
      }
      return { ...definition, ...refreshed };
    });
    if (invalid) {
      showToast("저장된 해외 대상 항목의 신뢰 매핑이 손상되어 PREPARE를 중단했습니다.");
      return false;
    }
    return true;
  }

  function clearExpertTimeout(kind) {
    const value = state.expert[kind];
    if (value?.timeoutId !== null) clearTimeout(value.timeoutId);
    if (value) value.timeoutId = null;
  }

  function expertIdentity(value) {
    return value ? `${value.expertCode}|${value.expertName}` : "";
  }

  function selectExpert(value) {
    if (isPlaylistSnapshotLocked()) return;
    const selected = expertWorkflow.normalizeExpert(value);
    if (!selected) return;
    state.expert.selected = selected;
    state.expert.selectable = null;
    requestExpertPreview(selected);
    addLog(`전문가 선택 · ${selected.expertName} · ${selected.expertCode}`);
  }

  function addExpertAction() {
    if (!requirePlaylistEditable()) return;
    const selected = state.expert.selectable;
    if (!selected || state.expert.preview.status !== "ready") {
      showToast("전문가를 선택하고 추천 종목 미리보기가 완료될 때까지 기다리세요.");
      return;
    }
    try {
      const created = expertWorkflow.createExpertPlaylistEntry(selected, {
        id: createId(),
        fadeDuration: DEFAULT_FADE_DURATION
      });
      const definition = catalog.find(item =>
        item.builderKey === created.builderKey && sceneAliases(item).includes(created.code));
      if (!definition) throw new Error("The expert action does not resolve to a scene alias.");
      const entry = { ...definition, ...created };
      state.playlist.push(entry);
      state.selectedRows.clear();
      state.selectedRows.add(entry.id);
      state.currentIndex = state.playlist.length - 1;
      state.selectionAnchorIndex = state.currentIndex;
      renderPlaylist();
      updatePreview();
      addLog(`${entry.code} ${entry.title} · ${entry.pageCount}페이지 추가`);
      showToast("전문가 추천 컷을 플레이리스트에 추가했습니다.");
    } catch {
      showToast("전문가 추천을 안전한 페이지 장면으로 구성하지 못했습니다.");
    }
  }

  function renderExpertWorkflow() {
    const visible = state.activeMarket === "expert";
    elements.expertWorkflow.hidden = !visible;
    if (!visible) return;
    const expert = state.expert;
    const search = expert.search;
    const preview = expert.preview;
    const locked = isPlaylistSnapshotLocked();
    elements.expertCatalogManageButton.disabled = operatorUiLocked();
    elements.expertCatalogManageButton.textContent = preview.catalogContext ? "선택 전문가 편집" : "전문가 관리";
    elements.expertCount.textContent = search.status === "loading"
      ? "LOADING"
      : `${search.totalRowCount.toLocaleString("ko-KR")} EXPERTS`;
    elements.expertSearch.disabled = locked;
    elements.expertSearchButton.disabled = locked || search.status === "loading";
    elements.expertSearchState.className = `expert-search-state ${search.status}`;
    if (search.status === "loading") elements.expertSearchState.textContent = "Oracle 전문가 목록을 조회하고 있습니다.";
    else if (search.status === "error") elements.expertSearchState.textContent = search.error || "전문가 검색에 실패했습니다.";
    else if (search.status === "ready") elements.expertSearchState.textContent = `${search.totalRowCount.toLocaleString("ko-KR")}명${search.truncated ? " · 결과 제한 적용" : ""}`;
    else elements.expertSearchState.textContent = "조회 버튼을 누르면 전체 전문가를 불러옵니다.";

    elements.expertResults.replaceChildren();
    if (!search.results.length) {
      const empty = document.createElement("div");
      empty.className = "comparison-target-empty";
      empty.textContent = search.status === "ready" ? "일치하는 전문가가 없습니다." : "전문가 검색 결과가 여기에 표시됩니다.";
      elements.expertResults.append(empty);
    } else {
      const selectedIdentity = expertIdentity(expert.selected);
      for (const item of search.results) {
        const row = document.createElement("button");
        row.type = "button";
        row.className = "expert-result";
        row.disabled = locked;
        const selected = expertIdentity(item) === selectedIdentity;
        row.classList.toggle("selected", selected);
        row.setAttribute("role", "option");
        row.setAttribute("aria-selected", selected ? "true" : "false");
        const copy = document.createElement("span");
        const name = document.createElement("strong");
        const code = document.createElement("small");
        name.textContent = item.expertName;
        code.textContent = item.expertCode;
        copy.append(name, code);
        const source = document.createElement("span");
        source.textContent = "ORACLE";
        row.append(copy, source);
        row.addEventListener("click", () => selectExpert(item));
        elements.expertResults.append(row);
      }
    }

    elements.selectedExpert.hidden = !expert.selected;
    if (expert.selected) {
      elements.selectedExpertName.textContent = expert.selected.expertName;
      elements.selectedExpertMeta.textContent = `${expert.selected.expertCode} · ORACLE`;
    }
    elements.expertPreviewCount.textContent = `${preview.totalRowCount.toLocaleString("ko-KR")} ITEMS`;
    elements.expertPreviewState.className = `expert-preview-state ${preview.status}`;
    if (preview.status === "loading") elements.expertPreviewState.textContent = "추천 종목을 PLAYINDEX 순서로 조회하고 있습니다.";
    else if (preview.status === "error") elements.expertPreviewState.textContent = preview.error || "추천 종목을 불러오지 못했습니다.";
    else if (preview.status === "ready") elements.expertPreviewState.textContent = `${preview.totalRowCount.toLocaleString("ko-KR")}개 추천${preview.truncated ? " · 100개 상한 적용" : ""}`;
    else elements.expertPreviewState.textContent = "전문가를 선택하면 추천 종목을 확인합니다.";
    elements.expertPreviewItems.replaceChildren();
    if (!preview.items.length) {
      const empty = document.createElement("div");
      empty.className = "comparison-target-empty";
      empty.textContent = preview.status === "ready" ? "추천 종목이 없습니다." : "미리보기 대기 중";
      elements.expertPreviewItems.append(empty);
    } else {
      for (const item of preview.items) {
        const row = document.createElement("div");
        row.className = "expert-preview-item";
        const index = document.createElement("span");
        const name = document.createElement("strong");
        const amount = document.createElement("small");
        index.textContent = String(item.playIndex).padStart(2, "0");
        name.textContent = `${item.stockName} · ${item.stockCode}`;
        amount.textContent = `${item.buyAmount.toLocaleString("ko-KR")}원`;
        row.append(index, name, amount);
        elements.expertPreviewItems.append(row);
      }
    }

    elements.expertActionList.replaceChildren();
    const action = expertWorkflow.actions[0];
    const page = expert.selectable ? expertWorkflow.calculatePagePreview(expert.selectable) : null;
    const allowed = Boolean(expert.selectable && preview.status === "ready" && preview.totalRowCount > 0);
    const row = document.createElement("div");
    row.className = "expert-action-row";
    row.classList.toggle("disabled", !allowed);
    const number = document.createElement("span");
    number.textContent = "01";
    const copy = document.createElement("span");
    copy.className = "expert-action-copy";
    const label = document.createElement("strong");
    const detail = document.createElement("small");
    label.textContent = action.label;
    detail.textContent = allowed
      ? `${action.code} · 5개 × ${page?.pageCount ?? 0}페이지${page?.isTruncated ? " · 상한" : ""}`
      : "전문가와 추천 종목을 먼저 확인하세요.";
    copy.append(label, detail);
    const add = document.createElement("button");
    add.type = "button";
    add.className = "expert-action-add";
    add.textContent = "+";
    add.disabled = locked || !allowed;
    add.addEventListener("click", addExpertAction);
    row.addEventListener("dblclick", event => {
      if (!event.target.closest("button") && allowed) addExpertAction();
    });
    row.append(number, copy, add);
    elements.expertActionList.append(row);
  }

  function requestExpertSearch(event) {
    event?.preventDefault();
    if (isPlaylistSnapshotLocked()) return;
    const search = state.expert.search;
    clearExpertTimeout("search");
    clearExpertTimeout("preview");
    let request;
    try {
      request = expertWorkflow.createExpertSearchRequest(
        createId(),
        String(elements.expertSearch.value || ""),
        expertWorkflow.bridgeContract.maximumResults);
    } catch {
      search.status = "error";
      search.error = "검색어는 64자 이하의 안전한 문자여야 합니다.";
      search.results = [];
      search.totalRowCount = 0;
      renderExpertWorkflow();
      return;
    }
    state.expert.selected = null;
    state.expert.selectable = null;
    Object.assign(state.expert.preview, {
      request: null, response: null, status: "idle", items: [], totalRowCount: 0,
      truncated: false, error: ""
    });
    Object.assign(search, {
      request, status: "loading", results: [], totalRowCount: 0, truncated: false, error: ""
    });
    renderExpertWorkflow();
    if (!nativeBridge) {
      search.status = "error";
      search.error = "브라우저 미리보기에서는 실제 전문가 DB 조회를 사용할 수 없습니다.";
      renderExpertWorkflow();
      return;
    }
    postNative(expertWorkflow.bridgeContract.searchRequestType, request);
    search.timeoutId = setTimeout(() => {
      if (search.request !== request || search.status !== "loading") return;
      search.status = "error";
      search.error = "전문가 검색 응답 시간이 초과되었습니다.";
      renderExpertWorkflow();
    }, 30000);
  }

  function handleExpertSearchResults(payload) {
    const search = state.expert.search;
    const request = search.request;
    if (!request || payload?.requestId !== request.requestId || payload?.query !== request.query) return;
    clearExpertTimeout("search");
    const response = expertWorkflow.normalizeExpertSearchResponse(payload, request);
    if (!response) {
      search.status = "error";
      search.results = [];
      search.totalRowCount = 0;
      search.error = "전문가 응답 형식 또는 식별자가 올바르지 않습니다.";
    } else {
      search.status = "ready";
      search.results = [...response.results];
      search.totalRowCount = response.totalRowCount;
      search.truncated = response.truncated;
      search.error = "";
    }
    renderExpertWorkflow();
  }

  function handleExpertSearchError(payload) {
    const search = state.expert.search;
    const request = search.request;
    if (!request || payload?.requestId !== request.requestId || payload?.query !== request.query) return;
    clearExpertTimeout("search");
    const error = expertWorkflow.normalizeExpertSearchError(payload, request);
    search.status = "error";
    search.results = [];
    search.totalRowCount = 0;
    search.error = error?.message || "전문가 검색에 실패했습니다.";
    renderExpertWorkflow();
  }

  function requestExpertPreview(selected) {
    clearExpertTimeout("preview");
    const preview = state.expert.preview;
    let request;
    try {
      request = expertWorkflow.createExpertPreviewRequest(
        createId(), selected, expertWorkflow.bridgeContract.maximumPreviewItems);
    } catch {
      preview.status = "error";
      preview.error = "전문가 식별자가 올바르지 않습니다.";
      renderExpertWorkflow();
      return;
    }
    Object.assign(preview, {
      request, response: null, status: "loading", items: [], totalRowCount: 0,
      truncated: false, error: "", catalogContext: null
    });
    state.expert.selectable = null;
    renderExpertWorkflow();
    if (!nativeBridge) {
      preview.status = "error";
      preview.error = "브라우저 미리보기에서는 실제 추천 종목 조회를 사용할 수 없습니다.";
      renderExpertWorkflow();
      return;
    }
    postNative(expertWorkflow.bridgeContract.previewRequestType, request);
    preview.timeoutId = setTimeout(() => {
      if (preview.request !== request || preview.status !== "loading") return;
      preview.status = "error";
      preview.error = "추천 종목 미리보기 응답 시간이 초과되었습니다.";
      renderExpertWorkflow();
    }, 30000);
  }

  function handleExpertPreviewResults(payload) {
    const preview = state.expert.preview;
    const request = preview.request;
    if (!request || payload?.requestId !== request.requestId || !state.expert.selected) return;
    clearExpertTimeout("preview");
    const response = expertWorkflow.normalizeExpertPreviewResponse(payload, request);
    const selectable = response
      ? expertWorkflow.createSelectableExpert(state.expert.selected, response)
      : null;
    if (!response || !selectable) {
      preview.status = "error";
      preview.items = [];
      preview.totalRowCount = 0;
      preview.error = "추천 종목 응답 형식, 순서 또는 식별자가 올바르지 않습니다.";
      state.expert.selectable = null;
    } else {
      preview.status = "ready";
      preview.response = response;
      preview.items = [...response.items];
      preview.totalRowCount = response.totalRowCount;
      preview.truncated = response.truncated;
      preview.error = "";
      state.expert.selectable = selectable;
      try {
        operatorCatalogWorkflow.createExpertEditDraft(state.expert.selected, response);
        preview.catalogContext = Object.freeze({
          selection: state.expert.selected,
          preview: response
        });
      } catch {
        preview.catalogContext = null;
      }
    }
    renderExpertWorkflow();
  }

  function handleExpertPreviewError(payload) {
    const preview = state.expert.preview;
    const request = preview.request;
    if (!request || payload?.requestId !== request.requestId) return;
    clearExpertTimeout("preview");
    const error = expertWorkflow.normalizeExpertPreviewError(payload, request);
    preview.status = "error";
    preview.items = [];
    preview.totalRowCount = 0;
    preview.catalogContext = null;
    preview.error = error?.message || "추천 종목을 불러오지 못했습니다.";
    state.expert.selectable = null;
    renderExpertWorkflow();
  }

  function synchronizeExpertEntries() {
    let invalid = false;
    state.playlist = state.playlist.map(item => {
      if (item?.operator?.source !== "legacy-expert-workflow") return item;
      const refreshed = expertWorkflow.refreshExpertPlaylistEntry(item);
      if (!refreshed) {
        invalid = true;
        return item;
      }
      const definition = catalog.find(value =>
        value.builderKey === refreshed.builderKey && sceneAliases(value).includes(refreshed.code));
      if (!definition) {
        invalid = true;
        return item;
      }
      return { ...definition, ...refreshed };
    });
    if (invalid) {
      showToast("저장된 전문가 항목의 신뢰 매핑이 손상되어 PREPARE를 중단했습니다.");
      return false;
    }
    return true;
  }

  function clearTradingHaltTimeout() {
    if (state.tradingHalt.timeoutId !== null) clearTimeout(state.tradingHalt.timeoutId);
    state.tradingHalt.timeoutId = null;
  }

  function tradingHaltIdentity(value) {
    return tradingHaltWorkflow.tradingHaltIdentity(value);
  }

  function toggleTradingHaltNameSort() {
    const workflow = state.tradingHalt;
    if (isPlaylistSnapshotLocked() || workflow.status !== "ready" || !workflow.results.length) return;
    workflow.sortDirection = tradingHaltWorkflow.nextTradingHaltNameSort(workflow.sortDirection);
    renderTradingHaltWorkflow();
  }

  function selectTradingHalt(value) {
    if (isPlaylistSnapshotLocked()) return;
    const selected = tradingHaltWorkflow.normalizeTradingHalt(value);
    if (!selected) return;
    state.tradingHalt.selected = selected;
    renderTradingHaltWorkflow();
    addLog(`거래 정지 종목 선택 · ${selected.displayName} · ${selected.stockCode}`);
  }

  function addTradingHaltAction(actionId) {
    if (!requirePlaylistEditable()) return;
    const selected = state.tradingHalt.selected;
    if (!selected) {
      showToast("먼저 거래 정지 종목을 선택하세요.");
      elements.tradingHaltSearch.focus();
      return;
    }

    try {
      const created = tradingHaltWorkflow.createTradingHaltPlaylistEntry(selected, actionId, {
        id: createId(),
        fadeDuration: DEFAULT_FADE_DURATION,
        ma5: state.candleOptions.ma5,
        ma20: state.candleOptions.ma20
      });
      const definition = catalog.find(item =>
        item.builderKey === created.builderKey && sceneAliases(item).includes(created.code));
      if (!definition) throw new Error("The trading-halt action does not resolve to a scene alias.");
      const entry = { ...definition, ...created };
      state.playlist.push(entry);
      state.selectedRows.clear();
      state.selectedRows.add(entry.id);
      state.currentIndex = state.playlist.length - 1;
      state.selectionAnchorIndex = state.currentIndex;
      renderPlaylist();
      updatePreview();
      addLog(`${entry.code} ${entry.title} · ${entry.detail} 추가`);
      showToast("거래 정지 컷을 플레이리스트에 추가했습니다.");
    } catch {
      showToast("선택한 거래 정지 종목과 컷을 안전하게 구성하지 못했습니다.");
    }
  }

  function renderTradingHaltWorkflow() {
    renderGlobalCandleOptions();
    const visible = state.activeMarket === "halted";
    elements.tradingHaltWorkflow.hidden = !visible;
    if (!visible) return;

    const workflow = state.tradingHalt;
    const locked = isPlaylistSnapshotLocked();
    elements.tradingHaltCount.textContent = workflow.status === "loading"
      ? "LOADING"
      : `${workflow.totalRowCount.toLocaleString("ko-KR")} HALTED`;
    elements.tradingHaltSearch.disabled = locked;
    elements.tradingHaltSearchButton.disabled = locked || workflow.status === "loading";
    const canSort = workflow.status === "ready" && workflow.results.length > 0;
    elements.tradingHaltNameSort.disabled = locked || !canSort;
    elements.tradingHaltNameSort.dataset.sortDirection = workflow.sortDirection || "none";
    const sortLabel = workflow.sortDirection === "descending"
      ? "종목명 정렬: 내림차순, 누르면 오름차순"
      : workflow.sortDirection === "ascending"
        ? "종목명 정렬: 오름차순, 누르면 내림차순"
        : "종목명 정렬: 서버 기본 순서, 누르면 내림차순";
    elements.tradingHaltNameSort.setAttribute("aria-label", sortLabel);
    elements.tradingHaltNameSort.title = sortLabel;
    elements.tradingHaltSearchState.className = `trading-halt-search-state ${workflow.status}`;
    if (workflow.status === "loading") {
      elements.tradingHaltSearchState.textContent = "Oracle의 KOSPI·KOSDAQ 거래 정지 종목을 조회하고 있습니다.";
    } else if (workflow.status === "error") {
      elements.tradingHaltSearchState.textContent = workflow.error || "거래 정지 종목 조회에 실패했습니다.";
    } else if (workflow.status === "ready") {
      elements.tradingHaltSearchState.textContent = `${workflow.totalRowCount.toLocaleString("ko-KR")}개 종목${workflow.truncated ? " · 결과 제한 적용" : ""}`;
    } else {
      elements.tradingHaltSearchState.textContent = "조회 버튼을 누르면 현재 거래 정지 종목을 불러옵니다.";
    }

    elements.tradingHaltResults.replaceChildren();
    if (!workflow.results.length) {
      const empty = document.createElement("div");
      empty.className = "comparison-target-empty";
      empty.textContent = workflow.status === "ready"
        ? "일치하는 거래 정지 종목이 없습니다."
        : "거래 정지 종목 검색 결과가 여기에 표시됩니다.";
      elements.tradingHaltResults.append(empty);
    } else {
      const selectedIdentity = tradingHaltIdentity(workflow.selected);
      const displayResults = tradingHaltWorkflow.sortTradingHaltsForDisplay(
        workflow.results,
        workflow.sortDirection);
      for (const item of displayResults) {
        const row = document.createElement("button");
        row.type = "button";
        row.className = "trading-halt-result";
        row.disabled = locked;
        const selected = tradingHaltIdentity(item) === selectedIdentity;
        row.classList.toggle("selected", selected);
        row.setAttribute("role", "option");
        row.setAttribute("aria-selected", selected ? "true" : "false");
        const copy = document.createElement("span");
        const name = document.createElement("strong");
        const code = document.createElement("small");
        name.textContent = item.displayName;
        code.textContent = item.stockCode;
        copy.append(name, code);
        const market = document.createElement("span");
        market.className = "trading-halt-market";
        market.textContent = item.market.toLocaleUpperCase("en-US");
        row.append(copy, market);
        row.addEventListener("click", () => selectTradingHalt(item));
        elements.tradingHaltResults.append(row);
      }
    }

    elements.selectedTradingHalt.hidden = !workflow.selected;
    elements.tradingHaltMa5.disabled = locked || !workflow.selected;
    elements.tradingHaltMa20.disabled = locked || !workflow.selected;
    if (workflow.selected) {
      elements.selectedTradingHaltName.textContent = workflow.selected.displayName;
      elements.selectedTradingHaltMeta.textContent = `${workflow.selected.stockCode} · ${workflow.selected.market.toLocaleUpperCase("en-US")}`;
    }

    elements.tradingHaltActionList.replaceChildren();
    tradingHaltWorkflow.actions.forEach((action, index) => {
      const row = document.createElement("div");
      row.className = "trading-halt-action-row";
      row.classList.toggle("disabled", !workflow.selected);
      const number = document.createElement("span");
      number.textContent = String(index + 1).padStart(2, "0");
      const copy = document.createElement("span");
      copy.className = "trading-halt-action-copy";
      const label = document.createElement("strong");
      const detail = document.createElement("small");
      label.textContent = action.label;
      detail.textContent = action.kind === "candle"
        ? `${action.code} · 120일 · MA5/MA20 선택`
        : `${action.code} · 1열판 현재가`;
      copy.append(label, detail);
      const add = document.createElement("button");
      add.type = "button";
      add.className = "trading-halt-action-add";
      add.textContent = "+";
      add.disabled = locked || !workflow.selected;
      add.title = workflow.selected ? "플레이리스트에 추가" : "거래 정지 종목을 먼저 선택하세요.";
      add.addEventListener("click", () => addTradingHaltAction(action.id));
      row.addEventListener("dblclick", event => {
        if (!event.target.closest("button") && workflow.selected) addTradingHaltAction(action.id);
      });
      row.append(number, copy, add);
      elements.tradingHaltActionList.append(row);
    });
  }

  function requestTradingHaltSearch(event) {
    event?.preventDefault();
    if (isPlaylistSnapshotLocked()) return;
    clearTradingHaltTimeout();
    const workflow = state.tradingHalt;
    let request;
    try {
      request = tradingHaltWorkflow.createTradingHaltSearchRequest(
        createId(),
        String(elements.tradingHaltSearch.value || ""),
        tradingHaltWorkflow.bridgeContract.maximumResults);
    } catch {
      workflow.status = "error";
      workflow.error = "검색어는 64자 이하의 안전한 문자여야 합니다.";
      workflow.results = [];
      workflow.totalRowCount = 0;
      renderTradingHaltWorkflow();
      return;
    }

    workflow.request = request;
    workflow.status = "loading";
    workflow.results = [];
    workflow.selected = null;
    workflow.sortDirection = null;
    workflow.totalRowCount = 0;
    workflow.truncated = false;
    workflow.error = "";
    renderTradingHaltWorkflow();
    if (!nativeBridge) {
      workflow.status = "error";
      workflow.error = "브라우저 미리보기에서는 실제 거래 정지 DB 조회를 사용할 수 없습니다.";
      renderTradingHaltWorkflow();
      return;
    }
    postNative(tradingHaltWorkflow.bridgeContract.requestType, request);
    workflow.timeoutId = setTimeout(() => {
      if (workflow.request !== request || workflow.status !== "loading") return;
      workflow.status = "error";
      workflow.error = "거래 정지 종목 조회 응답 시간이 초과되었습니다.";
      renderTradingHaltWorkflow();
    }, 30000);
  }

  function handleTradingHaltResults(payload) {
    const workflow = state.tradingHalt;
    const request = workflow.request;
    if (!request || payload?.requestId !== request.requestId || payload?.query !== request.query) return;
    clearTradingHaltTimeout();
    workflow.sortDirection = null;
    const response = tradingHaltWorkflow.normalizeTradingHaltSearchResponse(payload, request);
    if (!response) {
      workflow.status = "error";
      workflow.results = [];
      workflow.totalRowCount = 0;
      workflow.error = "거래 정지 종목 응답 형식 또는 식별자가 올바르지 않습니다.";
    } else {
      workflow.status = "ready";
      workflow.results = [...response.results];
      workflow.totalRowCount = response.totalRowCount;
      workflow.truncated = response.truncated;
      workflow.error = "";
    }
    renderTradingHaltWorkflow();
  }

  function handleTradingHaltError(payload) {
    const workflow = state.tradingHalt;
    const request = workflow.request;
    if (!request || payload?.requestId !== request.requestId || payload?.query !== request.query) return;
    clearTradingHaltTimeout();
    const error = tradingHaltWorkflow.normalizeTradingHaltError(payload, request);
    workflow.status = "error";
    workflow.results = [];
    workflow.totalRowCount = 0;
    workflow.error = error?.message || "거래 정지 종목 조회에 실패했습니다.";
    renderTradingHaltWorkflow();
  }

  function synchronizeTradingHaltEntries() {
    let invalid = false;
    state.playlist = state.playlist.map(item => {
      if (item?.operator?.source !== "legacy-trading-halt-workflow") return item;
      const refreshed = tradingHaltWorkflow.refreshTradingHaltPlaylistEntry(item);
      if (!refreshed) {
        invalid = true;
        return item;
      }
      const definition = catalog.find(value =>
        value.builderKey === refreshed.builderKey && sceneAliases(value).includes(refreshed.code));
      if (!definition) {
        invalid = true;
        return item;
      }
      return { ...definition, ...refreshed };
    });
    if (invalid) {
      showToast("저장된 거래 정지 항목의 신뢰 매핑이 손상되어 PREPARE를 중단했습니다.");
      return false;
    }
    return true;
  }

  function clearStockSearchTimeout() {
    if (state.stockSearch.timeoutId !== null) clearTimeout(state.stockSearch.timeoutId);
    state.stockSearch.timeoutId = null;
  }

  function stockIdentity(stock) {
    if (!stock) return "";
    return [stock.market, stock.isNxt === true ? "nxt" : "krx", stock.stockCode, stock.stockName].join("|");
  }

  function selectStock(stock) {
    state.stockSearch.selected = stock;
    renderStockWorkflow();
    addLog(`종목 선택 · ${stock.displayName} · ${stock.stockCode}`);
  }

  function selectStockCut(index, event) {
    try {
      state.stockCutSelection = legacyBatchSelectionWorkflow.applyPointerSelection(
        state.stockCutSelection,
        index,
        {
          ctrlKey: event?.ctrlKey === true || event?.metaKey === true,
          shiftKey: event?.shiftKey === true
        });
      renderStockCutSelection();
    } catch {
      state.stockCutSelection = legacyBatchSelectionWorkflow.createSelection(
        operatorWorkflow.stockCuts.length);
      renderStockCutSelection();
    }
  }

  function clearStockCutSelection() {
    state.stockCutSelection = legacyBatchSelectionWorkflow.createSelection(
      operatorWorkflow.stockCuts.length);
    renderStockCutSelection();
  }

  function renderStockCutSelection() {
    let selection;
    try {
      selection = legacyBatchSelectionWorkflow.createSelection(
        operatorWorkflow.stockCuts.length,
        state.stockCutSelection.selectedIndices,
        state.stockCutSelection.anchorIndex);
    } catch {
      selection = legacyBatchSelectionWorkflow.createSelection(
        operatorWorkflow.stockCuts.length);
    }
    state.stockCutSelection = selection;
    const selected = new Set(selection.selectedIndices);
    const selectedCount = selection.selectedIndices.length;
    const locked = operatorUiLocked();
    elements.stockCutSelectionCount.textContent = `${selectedCount}개 선택`;
    elements.stockCutBatchAdd.disabled = selectedCount === 0 ||
      !state.stockSearch.selected || locked;
    elements.stockCutBatchAdd.title = selectedCount === 0
      ? "Ctrl 또는 Shift와 함께 컷을 선택하세요."
      : (!state.stockSearch.selected
        ? "먼저 검색 결과에서 종목을 선택하세요."
        : "선택 컷을 원본 목록 순서로 한 번에 추가합니다.");
    elements.stockCutSelectionClear.disabled = selectedCount === 0;
    for (const row of elements.stockCutList.querySelectorAll("[data-stock-cut-index]")) {
      const index = Number(row.dataset.stockCutIndex);
      const isSelected = selected.has(index);
      row.classList.toggle("selected", isSelected);
      row.setAttribute("aria-selected", isSelected ? "true" : "false");
    }
  }

  function addSelectedStockCuts() {
    const selected = state.stockCutSelection.selectedIndices;
    if (!selected.length) {
      showToast("추가할 종목 컷을 먼저 선택하세요.");
      return;
    }
    addStockCuts(selected.map(index => operatorWorkflow.stockCuts[index]?.id));
  }

  function addStockCut(cutId) {
    addStockCuts([cutId]);
  }

  function addStockCuts(cutIds) {
    if (!requirePlaylistEditable()) return;
    const stock = state.stockSearch.selected;
    if (!stock) {
      showToast("먼저 종목을 검색하고 결과에서 한 종목을 선택하세요.");
      elements.stockSearchInput.focus();
      return;
    }

    const cutsById = new Map(operatorWorkflow.stockCuts.map(cut => [cut.id, cut]));
    const ids = Array.isArray(cutIds) ? cutIds : [];
    const cuts = ids.map(id => cutsById.get(id));
    if (!ids.length || cuts.some(cut => !cut) || new Set(ids).size !== ids.length) {
      showToast("선택한 종목 컷 목록이 올바르지 않아 아무것도 추가하지 않았습니다.");
      return;
    }
    if (cuts.some(cut => !operatorWorkflow.isStockCutAllowed(stock, cut.id))) {
      showToast(stock.isNxt
        ? "선택한 컷 중 NXT가 지원하지 않는 항목이 있어 아무것도 추가하지 않았습니다. NXT는 1열판기본_현재가만 지원합니다."
        : "지원하지 않는 종목 컷이 포함되어 아무것도 추가하지 않았습니다.");
      return;
    }

    try {
      const entries = legacyBatchSelectionWorkflow.materializeAtomicBatch(cuts, cut => {
        const created = operatorWorkflow.createStockPlaylistEntry(stock, cut.id, {
          id: createId(),
          fadeDuration: DEFAULT_FADE_DURATION,
          ma5: state.candleOptions.ma5,
          ma20: state.candleOptions.ma20
        });
        const definition = catalog.find(item => item.builderKey === created.builderKey);
        if (!definition || !sceneAliases(definition).includes(String(created.code || ""))) {
          throw new Error("The operator cut does not resolve to a registered scene alias.");
        }
        return {
          ...definition,
          ...created,
          enabled: true
        };
      });
      state.playlist.push(...entries);
      state.selectedRows.clear();
      entries.forEach(entry => state.selectedRows.add(entry.id));
      state.currentIndex = state.playlist.length - 1;
      state.selectionAnchorIndex = state.currentIndex;
      renderPlaylist();
      updatePreview();
      addLog(`${stock.displayName} 종목 컷 일괄 추가 · ${entries.length}개 · 원본 목록 순서`);
      showToast(entries.length === 1
        ? `${stock.displayName} 컷을 플레이리스트에 추가했습니다.`
        : `${stock.displayName} 컷 ${entries.length}개를 순서대로 추가했습니다.`);
    } catch {
      showToast("선택한 종목 컷 전체를 안전한 장면으로 구성하지 못해 아무것도 추가하지 않았습니다.");
    }
  }

  function renderGlobalCandleOptions() {
    const { ma5, ma20 } = state.candleOptions;
    for (const element of [elements.globalMa5, elements.stockMa5, elements.overseasMa5, elements.tradingHaltMa5]) {
      if (element) element.checked = ma5;
    }
    for (const element of [elements.globalMa20, elements.stockMa20, elements.overseasMa20, elements.tradingHaltMa20]) {
      if (element) element.checked = ma20;
    }
    const locked = isPlaylistSnapshotLocked();
    elements.globalMa5.disabled = locked;
    elements.globalMa20.disabled = locked;
  }

  function synchronizeGlobalCandleOptions(render = false) {
    if (isPlaylistSnapshotLocked()) {
      renderGlobalCandleOptions();
      return false;
    }
    const { ma5, ma20 } = state.candleOptions;
    const result = candleOptionsWorkflow.synchronizePlaylist(
      state.playlist,
      ma5,
      ma20,
      {
        stock: operatorWorkflow,
        fixed: fixedWorkflow,
        industry: industryWorkflow,
        overseas: overseasWorkflow,
        tradingHalt: tradingHaltWorkflow
      },
      new Date());
    state.candleOptions.invalidIds = [...result.invalidIds];
    state.playlist = [...result.playlist].map(item => {
      if (item?.builderKey !== candleOptionsWorkflow.candleBuilderKey) return item;
      // The named foreign-stock entry carries a strict native identity proof
      // and original seven-field signature. Keep that exact branded object;
      // catalog decoration would make its save round-trip unverifiable.
      if (item.operator?.source === namedForeignStockRestoreWorkflow.source) return item;
      const definition = catalog.find(value =>
        value.builderKey === item.builderKey && sceneAliases(value).includes(String(item.code || "")));
      return definition ? { ...definition, ...item, enabled: item.enabled !== false } : item;
    });
    renderGlobalCandleOptions();

    if (render && (result.changed || !result.valid)) {
      renderPlaylist();
      updatePreview();
    }
    if (!result.valid) {
      if (render) {
        addLog(`캔들 이동평균선 동기화 차단 · 신뢰 복원 실패 ${result.invalidIds.length}건`);
        showToast("신뢰할 수 없는 캔들 항목이 있어 PREPARE를 차단했습니다. 해당 항목을 삭제하고 다시 추가하세요.");
      }
      return false;
    }
    if (result.changed && render) {
      addLog(`전체 캔들 이동평균선 적용 · 5일선 ${ma5 ? "표시" : "숨김"} · 20일선 ${ma20 ? "표시" : "숨김"}`);
    }
    return true;
  }

  function applyGlobalCandleOptions(ma5, ma20, render = true) {
    if (isPlaylistSnapshotLocked()) {
      renderGlobalCandleOptions();
      return false;
    }
    try {
      const normalized = candleOptionsWorkflow.normalizeOptions(Boolean(ma5), Boolean(ma20));
      state.candleOptions.ma5 = normalized.ma5;
      state.candleOptions.ma20 = normalized.ma20;
      return synchronizeGlobalCandleOptions(render);
    } catch {
      renderGlobalCandleOptions();
      return false;
    }
  }

  function synchronizeStockCandleOptions(render = false) {
    return applyGlobalCandleOptions(elements.stockMa5.checked, elements.stockMa20.checked, render);
  }

  function renderStockWorkflow() {
    renderGlobalCandleOptions();
    // In MainForm this was a top-level left panel, not content owned by the
    // dashboard/KOSPI/KOSDAQ tabs. Keep the same search result and selected
    // stock available while the operator moves between every business view.
    elements.stockWorkflow.hidden = false;
    elements.stockWorkflow.classList.toggle("collapsed", state.stockWorkflowCollapsed);
    elements.stockWorkflowToggle.textContent = state.stockWorkflowCollapsed ? "펼치기" : "접기";
    elements.stockWorkflowToggle.setAttribute("aria-expanded", String(!state.stockWorkflowCollapsed));

    const search = state.stockSearch;
    const playlistLocked = isPlaylistSnapshotLocked();
    elements.stockMa5.disabled = playlistLocked;
    elements.stockMa20.disabled = playlistLocked;
    elements.stockSearchButton.disabled = search.status === "loading";
    elements.stockSearchBadge.textContent = search.status === "loading" ? "SEARCHING" : "DB SEARCH";
    elements.stockSearchState.className = `stock-search-state ${search.status}`;
    if (search.status === "loading") {
      elements.stockSearchState.textContent = `“${search.query}” 종목을 Oracle/MariaDB에서 조회하고 있습니다.`;
    } else if (search.status === "error") {
      elements.stockSearchState.textContent = search.error || "종목 검색에 실패했습니다.";
    } else if (search.status === "ready") {
      const suffix = search.truncated ? " · 결과 제한 적용" : "";
      elements.stockSearchState.textContent = `${search.totalRowCount.toLocaleString("ko-KR")}개 종목 검색 완료${suffix}`;
    } else {
      elements.stockSearchState.textContent = "종목명을 입력하고 Enter를 누르세요.";
    }

    elements.stockSearchResults.replaceChildren();
    if (!search.results.length) {
      const empty = document.createElement("div");
      empty.className = "stock-results-empty";
      empty.textContent = search.status === "ready" ? "일치하는 종목이 없습니다." : "검색 결과가 여기에 표시됩니다.";
      elements.stockSearchResults.append(empty);
    } else {
      const selectedIdentity = stockIdentity(search.selected);
      for (const stock of search.results) {
        const row = document.createElement("button");
        row.className = "stock-result";
        row.type = "button";
        row.setAttribute("role", "option");
        const selected = stockIdentity(stock) === selectedIdentity;
        row.classList.toggle("selected", selected);
        row.setAttribute("aria-selected", selected ? "true" : "false");

        const copy = document.createElement("span");
        const name = document.createElement("strong");
        const meta = document.createElement("small");
        name.textContent = stock.displayName;
        meta.textContent = `${stock.stockCode} · ${stock.sourceLabel || stock.source || "DB"}`;
        copy.append(name, meta);
        const market = document.createElement("span");
        market.className = "stock-market-badge";
        market.textContent = stock.marketLabel || `${stock.market}${stock.isNxt ? " NXT" : ""}`;
        row.append(copy, market);
        row.addEventListener("click", () => selectStock(stock));
        elements.stockSearchResults.append(row);
      }
    }

    elements.selectedStock.hidden = !search.selected;
    if (search.selected) {
      elements.selectedStockName.textContent = search.selected.displayName;
      elements.selectedStockMeta.textContent = `${search.selected.marketLabel || search.selected.market} · ${search.selected.stockCode}`;
    }

    elements.stockCutList.replaceChildren();
    let previousSection = null;
    operatorWorkflow.stockCuts.forEach((cut, index) => {
      if (cut.section !== previousSection) {
        const section = document.createElement("div");
        section.className = "stock-cut-section";
        section.textContent = cut.section;
        elements.stockCutList.append(section);
        previousSection = cut.section;
      }
      const allowed = Boolean(search.selected) && operatorWorkflow.isStockCutAllowed(search.selected, cut.id);
      const row = document.createElement("div");
      row.className = "stock-cut-row";
      row.dataset.stockCutIndex = String(index);
      row.setAttribute("role", "option");
      row.tabIndex = 0;
      row.classList.toggle("disabled", !allowed);
      const number = document.createElement("span");
      number.className = "stock-cut-number";
      number.textContent = String(index + 1).padStart(2, "0");
      const copy = document.createElement("span");
      copy.className = "stock-cut-copy";
      const label = document.createElement("strong");
      const detail = document.createElement("small");
      label.textContent = cut.label;
      detail.textContent = `${cut.builderKey} · ${cut.detail || "원본 종목 컷"}`;
      copy.append(label, detail);
      const add = document.createElement("button");
      add.className = "stock-cut-add";
      add.type = "button";
      add.textContent = "+";
      add.disabled = !allowed || isPlaylistSnapshotLocked();
      add.title = !search.selected
        ? "먼저 종목을 선택하세요."
        : (!allowed ? "이 종목 시장에서는 지원하지 않는 컷입니다." : "플레이리스트에 추가");
      add.addEventListener("click", event => {
        event.stopPropagation();
        addStockCut(cut.id);
      });
      row.addEventListener("click", event => {
        if (event.target.closest("button")) return;
        selectStockCut(index, event);
      });
      row.addEventListener("dblclick", event => {
        if (event.target.closest("button")) return;
        if (allowed && legacyBatchSelectionWorkflow.canActivateSingle(
          state.stockCutSelection,
          index)) addStockCut(cut.id);
      });
      row.addEventListener("keydown", event => {
        if (event.repeat) return;
        if (event.key === " ") {
          event.preventDefault();
          selectStockCut(index, event);
        } else if (event.key === "Enter") {
          event.preventDefault();
          if (!legacyBatchSelectionWorkflow.canActivateSingle(
            state.stockCutSelection,
            index)) selectStockCut(index, {});
          if (allowed) addStockCut(cut.id);
        }
      });
      row.append(number, copy, add);
      elements.stockCutList.append(row);
    });
    renderStockCutSelection();

    elements.manualStockActions.replaceChildren();
    for (const action of operatorWorkflow.manualStockActions) {
      const button = document.createElement("button");
      button.type = "button";
      button.textContent = action.label;
      button.disabled = !search.selected || operatorUiLocked();
      button.title = !search.selected
        ? "먼저 종목을 검색하고 선택하세요."
        : `${action.prerequisiteTable} 원본 수동 입력 화면 열기`;
      button.addEventListener("click", () => manualFinancialUi?.open(action));
      elements.manualStockActions.append(button);
    }
  }

  function requestStockSearch(event) {
    event?.preventDefault();
    clearStockSearchTimeout();
    const query = String(elements.stockSearchInput.value || "").trim();
    if (!query || query.length > 64 || /[\u0000-\u001f\u007f]/.test(query)) {
      state.stockSearch.status = "error";
      state.stockSearch.error = "1~64자의 종목명을 입력하세요.";
      state.stockSearch.results = [];
      state.stockSearch.selected = null;
      renderStockWorkflow();
      return;
    }
    if (!nativeBridge) {
      state.stockSearch.status = "error";
      state.stockSearch.error = "브라우저 미리보기에서는 실제 종목 DB 검색을 사용할 수 없습니다.";
      renderStockWorkflow();
      return;
    }

    const requestId = createId();
    state.stockSearch.requestId = requestId;
    state.stockSearch.query = query;
    state.stockSearch.status = "loading";
    state.stockSearch.results = [];
    state.stockSearch.selected = null;
    state.stockSearch.totalRowCount = 0;
    state.stockSearch.truncated = false;
    state.stockSearch.error = "";
    renderStockWorkflow();
    postNative("search-stocks", { requestId, query, maximumResults: 200 });
    state.stockSearch.timeoutId = setTimeout(() => {
      if (state.stockSearch.requestId !== requestId || state.stockSearch.status !== "loading") return;
      state.stockSearch.status = "error";
      state.stockSearch.error = "종목 검색 응답 시간이 초과되었습니다. DB 상태를 확인하고 다시 검색하세요.";
      renderStockWorkflow();
      addLog(`종목 검색 시간 초과 · ${query}`);
    }, 30000);
  }

  function handleStockSearchResults(payload) {
    if (!payload || payload.requestId !== state.stockSearch.requestId || payload.query !== state.stockSearch.query) return;
    clearStockSearchTimeout();
    const results = Array.isArray(payload.results) ? payload.results : [];
    state.stockSearch.results = results.slice(0, 500).flatMap(value => {
      try {
        const normalized = operatorWorkflow.normalizeStockResult(value);
        return normalized ? [normalized] : [];
      } catch { return []; }
    });
    state.stockSearch.status = "ready";
    state.stockSearch.totalRowCount = Number.isInteger(payload.totalRowCount)
      ? Math.max(0, payload.totalRowCount)
      : state.stockSearch.results.length;
    state.stockSearch.truncated = payload.truncated === true;
    state.stockSearch.error = "";
    renderStockWorkflow();
    addLog(`종목 검색 완료 · ${state.stockSearch.query} · ${state.stockSearch.results.length}건 표시`);
  }

  function handleStockSearchError(payload) {
    if (!payload || payload.requestId !== state.stockSearch.requestId || payload.query !== state.stockSearch.query) return;
    clearStockSearchTimeout();
    state.stockSearch.status = "error";
    state.stockSearch.results = [];
    state.stockSearch.selected = null;
    state.stockSearch.error = typeof payload.message === "string" && payload.message.trim()
      ? payload.message.trim()
      : "종목 검색에 실패했습니다.";
    renderStockWorkflow();
    addLog(`종목 검색 실패 · ${state.stockSearch.query}`);
  }

  function addToPlaylist(item) {
    if (item.reachable === false || !requirePlaylistEditable()) return;
    if (operatorInputBuilderKeys.has(item.builderKey)) {
      if (["s5026", "s5029", "s5087"].includes(item.builderKey)) {
        showToast("비교 장면은 두 종목을 확정하는 전용 입력 화면에서 추가해야 합니다.");
      } else if (item.builderKey === "s5025") {
        showToast("수동 순매도 장면은 투자자별 수동 목록 화면에서 추가해야 합니다.");
      } else if (["s5074", "s5077", "s5088"].includes(item.builderKey)) {
        showToast("5·6·12행 페이지 장면은 지수·업종·테마 등 원본 전용 입력 화면에서 추가해야 합니다.");
      } else if (["s5076", "s5079", "s5080", "s5081"].includes(item.builderKey)) {
        showToast("이 장면은 원본 수동 재무 데이터 화면의 저장 항목에서 추가해야 합니다.");
      } else {
        showToast("종목 검색 결과와 종목용 컷을 선택해 추가하세요.");
        elements.stockSearchInput.focus();
      }
      return;
    }
    const selection = defaultSceneSelection(item.builderKey);
    const entry = {
      ...item,
      id: createId(),
      selection,
      enabled: true,
      fadeDuration: DEFAULT_FADE_DURATION,
      graphicType: selection?.graphicType || "",
      subtype: selection?.subtype || ""
    };
    state.playlist.push(entry);
    state.selectedRows.clear();
    state.selectedRows.add(entry.id);
    state.currentIndex = state.playlist.length - 1;
    state.selectionAnchorIndex = state.currentIndex;
    renderPlaylist();
    updatePreview();
    addLog(`${item.code} ${item.title} 항목 추가`);
    showToast("플레이리스트에 추가했습니다.");
  }

  function recreatePlaylistEntryWithEnabled(item, enabled) {
    if (!item || typeof item !== "object" || typeof item.enabled !== "boolean" ||
        typeof enabled !== "boolean") return null;
    if (item.enabled === enabled) return item;

    const operator = item.operator;
    if (namedKrxThemeRestoreWorkflow.isMaterializedEntry(item) ||
        operator?.namedKrxRestore !== undefined) {
      return namedKrxThemeRestoreWorkflow.withEnabled(item, enabled);
    }
    if (namedNxtThemeRestoreWorkflow.isMaterializedEntry(item) ||
        operator?.namedNxtRestore !== undefined) {
      return namedNxtThemeRestoreWorkflow.withEnabled(item, enabled);
    }
    if (operator?.source === namedForeignStockRestoreWorkflow.source) {
      return namedForeignStockRestoreWorkflow.withEnabled(item, enabled);
    }
    if (operator?.source === "legacy-foreign-index-candle-workflow") {
      return foreignIndexCandleRestoreWorkflow.withEnabled(item, enabled);
    }
    if (operator?.source === "manual-financial-workflow") {
      return manualFinancialWorkflow.withEnabled(item, enabled);
    }
    if (operator?.source === "legacy-manual-lists-workflow") {
      return manualListsWorkflow.withEnabled(item, enabled);
    }
    return { ...item, enabled };
  }

  function playlistEntryManualEditBlockReason(item) {
    if (!item || typeof item !== "object") return "missing-entry";
    if (item.operator?.source) return "trusted-workflow-entry";
    if (state.namedPlaylist.guardByEntryId.has(item.id)) return "named-playlist-entry";
    if (state.manualFinancialRestore.entries.has(item.id)) return "manual-restore-entry";
    return "";
  }

  function normalizeManualSceneSelection(value) {
    if (!value || typeof value !== "object" || Array.isArray(value)) return null;
    const keys = ["groupCode", "subject", "graphicType", "subtype", "dataCode"];
    if (Object.keys(value).length !== keys.length || keys.some(key => !(key in value))) return null;
    const selection = {};
    for (const key of keys) {
      if (typeof value[key] !== "string") return null;
      const text = value[key].trim();
      if (text.length > 256 || /[\u0000-\u001f\u007f]/.test(text)) return null;
      selection[key] = text;
    }
    return Object.freeze(selection);
  }

  function recreatePlaylistEntryWithSceneSelection(item, selection, code, fadeDuration) {
    if (playlistEntryManualEditBlockReason(item)) return null;
    const normalizedSelection = normalizeManualSceneSelection(selection);
    if (!normalizedSelection || typeof code !== "string" || !sceneAliases(item).includes(code) ||
        !Number.isInteger(fadeDuration) || fadeDuration < 0 || fadeDuration > 60) return null;
    return { ...item, selection: normalizedSelection, code, fadeDuration };
  }

  function recreatePlaylistEntryWithLiveSelection(item, selection) {
    if (playlistEntryManualEditBlockReason(item)) return null;
    const normalizedSelection = normalizeManualSceneSelection(selection);
    if (!normalizedSelection || (!normalizedSelection.subject && !normalizedSelection.dataCode)) return null;
    return {
      ...item,
      selection: normalizedSelection,
      subject: normalizedSelection.subject,
      dataCode: normalizedSelection.dataCode
    };
  }

  function replacePlaylistEntryAt(index, replacement) {
    if (!Number.isInteger(index) || index < 0 || index >= state.playlist.length ||
        !replacement || replacement.id !== state.playlist[index]?.id) return false;
    const playlist = [...state.playlist];
    playlist[index] = replacement;
    state.playlist = playlist;
    return true;
  }

  function replacePlaylistEntryEnabledAt(index, enabled) {
    if (!Number.isInteger(index) || index < 0 || index >= state.playlist.length) return null;
    const replacement = recreatePlaylistEntryWithEnabled(state.playlist[index], enabled);
    return replacement && replacePlaylistEntryAt(index, replacement) ? replacement : null;
  }

  function renderPlaylist() {
    renderGlobalCandleOptions();
    elements.playlistBody.replaceChildren();
    elements.playlistCount.textContent = `${state.playlist.length} CUTS`;
    elements.playlistEmpty.classList.toggle("show", state.playlist.length === 0);
    const allSelected = state.playlist.length > 0 &&
      state.playlist.every(item => state.selectedRows.has(item.id));
    const allEnabled = state.playlist.length > 0 &&
      state.playlist.every(item => item.enabled !== false);
    elements.selectAll.textContent = allSelected ? "작업 선택해제" : "작업 전체선택";
    elements.toggleAllEnabled.textContent = allEnabled ? "전체 송출 해제" : "전체 송출";

    state.playlist.forEach((item, index) => {
      const row = document.createElement("tr");
      const namedGuard = state.namedPlaylist.guardByEntryId.get(item.id);
      const manualFinancialRestore = state.manualFinancialRestore.entries.get(item.id);
      row.draggable = !isPlaylistSnapshotLocked();
      row.dataset.index = String(index);
      row.classList.toggle("active", index === state.currentIndex);
      row.classList.toggle("selected", state.selectedRows.has(item.id));
      row.classList.toggle("named-restore-invalid", namedGuard?.trusted === false);
      row.classList.toggle("named-page-pending", namedGuard?.trusted === true &&
        namedGuard.pageRecalculationRequired && !namedGuard.pageRecalculated);
      row.classList.toggle("named-restore-invalid", manualFinancialRestore?.status === "error");
      row.classList.toggle("named-page-pending", Boolean(manualFinancialRestore) &&
        manualFinancialRestore.status !== "error");
      row.setAttribute("aria-selected", state.selectedRows.has(item.id) ? "true" : "false");

      const checkCell = document.createElement("td");
      checkCell.className = "check-cell";
      const checkbox = document.createElement("input");
      checkbox.type = "checkbox";
      checkbox.checked = item.enabled !== false;
      checkbox.disabled = isPlaylistSnapshotLocked();
      checkbox.setAttribute("aria-label", `${item.title} 송출 포함`);
      checkbox.addEventListener("click", event => event.stopPropagation());
      checkbox.addEventListener("change", event => {
        event.stopPropagation();
        if (!requirePlaylistEditable()) {
          checkbox.checked = item.enabled !== false;
          return;
        }
        const replacement = replacePlaylistEntryEnabledAt(index, checkbox.checked);
        if (!replacement) {
          checkbox.checked = item.enabled !== false;
          showToast("검증된 항목의 송출 여부를 안전하게 변경할 수 없습니다.");
          return;
        }
        renderPlaylist();
        addLog(`${replacement.code} ${replacement.enabled ? "송출 포함" : "송출 제외"}`);
      });
      checkCell.append(checkbox);

      const number = document.createElement("td");
      number.className = "number-cell";
      number.textContent = String(index + 1).padStart(2, "0");

      const targetCell = document.createElement("td");
      targetCell.className = "playlist-target-cell";
      const target = document.createElement("strong");
      const targetMeta = document.createElement("small");
      target.textContent = item.selection?.subject || item.title;
      targetMeta.textContent = [item.selection?.groupCode, item.selection?.dataCode]
        .filter(Boolean).join(" · ") || item.detail;
      targetCell.append(target, targetMeta);

      const cutCell = document.createElement("td");
      cutCell.className = "playlist-cut-cell";
      const cutTitle = document.createElement("strong");
      const cutMeta = document.createElement("small");
      cutTitle.textContent = item.operator?.legacyLabel || item.title;
      cutMeta.textContent = `${item.code} · ${item.builderKey}`;
      cutCell.append(cutTitle, cutMeta);

      const detailCell = document.createElement("td");
      detailCell.className = "playlist-detail-cell";
      const detailTitle = document.createElement("strong");
      detailTitle.textContent = [item.selection?.graphicType, item.selection?.subtype]
        .filter(Boolean).join(" · ") || item.detail;
      const type = document.createElement("span");
      type.className = "row-type";
      let rowType = item.category.toUpperCase();
      if (namedGuard?.pageRecalculationRequired && !namedGuard.pageRecalculated) rowType = "PAGE 재계산";
      if (namedGuard?.recalculatedPagePlan?.isTruncated === true) rowType = "20 PAGE 제한";
      if (namedGuard?.pageUnavailableReason === "empty-result") rowType = "DB 결과 없음";
      if (namedGuard?.trusted === false) rowType = "복원 필요";
      if (manualFinancialRestore) rowType = manualFinancialRestore.status === "error"
        ? "재무 복원 실패"
        : "재무 DB 확인";
      type.textContent = rowType;
      const detailMeta = document.createElement("small");
      detailMeta.append(type);
      detailCell.append(detailTitle, detailMeta);

      const page = document.createElement("td");
      page.className = "page-cell";
      const freshNamedPagePlan = namedGuard?.recalculatedPagePlan;
      page.textContent = item.id === state.playout.currentEntryId && state.playout.pageCount > 0
        ? `${state.playout.pageIndex + 1}/${state.playout.pageCount}`
        : (freshNamedPagePlan?.itemCount === 0
          ? "0건"
          : (Number.isInteger(freshNamedPagePlan?.pageCount)
            ? `1/${freshNamedPagePlan.pageCount}`
            : (item.pageText || "1/1")));
      if (namedGuard?.rawRow) {
        const historical = namedPlaylistWorkflow.pageStatusLabel(namedGuard.rawRow);
        const current = freshNamedPagePlan
          ? `현재 DB ${freshNamedPagePlan.itemCount}건 · ${freshNamedPagePlan.pageCount}페이지` +
            (freshNamedPagePlan.isTruncated ? ` · ${freshNamedPagePlan.accessibleItemCount}건까지만 송출` : "")
          : "현재 DB 페이지 조회 전";
        page.title = `${historical} / ${current}`;
      }

      const drag = document.createElement("td");
      drag.className = "drag-cell";
      drag.textContent = "⠿";

      row.append(checkCell, number, targetCell, cutCell, detailCell, page, drag);
      row.addEventListener("click", event => selectPlaylistRow(index, event));
      row.addEventListener("dragstart", onDragStart);
      row.addEventListener("dragover", onDragOver);
      row.addEventListener("dragleave", () => row.classList.remove("drag-over"));
      row.addEventListener("drop", onDrop);
      row.addEventListener("dragend", onDragEnd);
      elements.playlistBody.append(row);
    });
  }

  function selectPlaylistRow(index, event) {
    if (isPlaylistSnapshotLocked()) return;
    const item = state.playlist[index];
    if (!item) return;
    if (event?.shiftKey) {
      const anchor = state.selectionAnchorIndex >= 0 && state.selectionAnchorIndex < state.playlist.length
        ? state.selectionAnchorIndex
        : (state.currentIndex >= 0 ? state.currentIndex : index);
      if (!(event.ctrlKey || event.metaKey)) state.selectedRows.clear();
      const from = Math.min(anchor, index);
      const to = Math.max(anchor, index);
      for (let value = from; value <= to; value++) state.selectedRows.add(state.playlist[value].id);
    } else if (event?.ctrlKey || event?.metaKey) {
      if (state.selectedRows.has(item.id)) state.selectedRows.delete(item.id);
      else state.selectedRows.add(item.id);
    } else {
      state.selectedRows.clear();
      state.selectedRows.add(item.id);
    }
    if (!event?.shiftKey) state.selectionAnchorIndex = index;
    state.currentIndex = index;
    renderPlaylist();
    updatePreview();
  }

  function updatePreview() {
    const item = state.playlist[state.currentIndex];
    if (!item) {
      elements.previewTitle.textContent = "선택된 그래픽 없음";
      elements.previewSubtitle.textContent = "플레이리스트 항목을 선택하세요.";
      renderSceneSelectionForm(null);
      renderPlayout();
      return;
    }
    elements.previewTitle.textContent = item.title;
    const selection = item.selection?.subject
      ? ` · ${item.selection.subject}${item.selection.dataCode ? ` (${item.selection.dataCode})` : ""}`
      : "";
    elements.previewSubtitle.textContent = `${item.code} · ${item.detail}${selection}`;
    renderSceneSelectionForm(item);
    renderPlayout();
  }

  function renderSceneSelectionForm(item) {
    const selection = item?.selection || {};
    const disabled = !item || isPlaylistSnapshotLocked() ||
      Boolean(playlistEntryManualEditBlockReason(item));
    elements.sceneGroupCode.value = String(selection.groupCode || "");
    elements.sceneSubject.value = String(selection.subject || "");
    elements.sceneGraphicType.value = String(selection.graphicType || "");
    elements.sceneSubtype.value = String(selection.subtype || "");
    elements.sceneDataCode.value = String(selection.dataCode || "");
    elements.sceneCutAlias.replaceChildren();
    for (const alias of item ? sceneAliases(item) : []) {
      const option = document.createElement("option");
      option.value = alias;
      option.textContent = alias;
      elements.sceneCutAlias.append(option);
    }
    elements.sceneCutAlias.value = String(item?.code || "");
    elements.sceneFadeDuration.value = String(Number.isInteger(Number(item?.fadeDuration))
      ? Number(item.fadeDuration)
      : DEFAULT_FADE_DURATION);
    for (const input of [
      elements.sceneGroupCode,
      elements.sceneSubject,
      elements.sceneGraphicType,
      elements.sceneSubtype,
      elements.sceneDataCode,
      elements.sceneCutAlias,
      elements.sceneFadeDuration
    ]) input.disabled = disabled;
    elements.applySceneSelectionButton.disabled = disabled;
  }

  function applySceneSelection(event) {
    event.preventDefault();
    if (!requirePlaylistEditable()) return;
    const item = state.playlist[state.currentIndex];
    if (!item) return;
    if (playlistEntryManualEditBlockReason(item)) {
      renderSceneSelectionForm(item);
      showToast("검증된 항목은 조회값을 직접 편집할 수 없습니다. 원본 입력 화면에서 다시 생성하세요.");
      return;
    }
    const values = [
      elements.sceneGroupCode.value,
      elements.sceneSubject.value,
      elements.sceneGraphicType.value,
      elements.sceneSubtype.value,
      elements.sceneDataCode.value
    ];
    if (values.some(value => value.length > 256 || /[\u0000-\u001f\u007f]/.test(value))) {
      showToast("장면 조회값 형식이 올바르지 않습니다.");
      return;
    }
    const fadeDuration = Number(elements.sceneFadeDuration.value);
    if (!Number.isInteger(fadeDuration) || fadeDuration < 0 || fadeDuration > 60) {
      showToast("FADE 값은 0~60 사이의 정수여야 합니다.");
      return;
    }
    const cutAlias = String(elements.sceneCutAlias.value || "");
    if (!sceneAliases(item).includes(cutAlias)) {
      showToast("허용되지 않은 CUT alias입니다.");
      return;
    }
    const replacement = recreatePlaylistEntryWithSceneSelection(item, {
      groupCode: values[0].trim(),
      subject: values[1].trim(),
      graphicType: values[2].trim(),
      subtype: values[3].trim(),
      dataCode: values[4].trim()
    }, cutAlias, fadeDuration);
    if (!replacement || !replacePlaylistEntryAt(state.currentIndex, replacement)) {
      renderSceneSelectionForm(item);
      showToast("장면 조회값을 안전하게 적용할 수 없습니다.");
      return;
    }
    renderPlaylist();
    updatePreview();
    addLog(`${replacement.code} 장면 조회값 적용`);
    showToast("장면 조회값을 적용했습니다.");
  }

  function applyCutAliasPreset() {
    const item = state.playlist[state.currentIndex];
    if (!item) return;
    const alias = String(elements.sceneCutAlias.value || "");
    if (item.builderKey === "s5001") {
      elements.sceneGroupCode.value = alias === "N5001" ? "NXT_KOSPI" : "KOSPI";
    } else if (item.builderKey === "s8001") {
      elements.sceneSubject.value = alias === "8002" ? "KOSDAQ" : "KOSPI";
    }
  }

  function onDragStart(event) {
    if (!requirePlaylistEditable()) {
      event.preventDefault();
      return;
    }
    state.dragIndex = Number(event.currentTarget.dataset.index);
    event.currentTarget.classList.add("dragging");
    event.dataTransfer.effectAllowed = "move";
    event.dataTransfer.setData("text/plain", String(state.dragIndex));
  }

  function onDragOver(event) {
    if (isPlaylistSnapshotLocked()) return;
    event.preventDefault();
    event.dataTransfer.dropEffect = "move";
    event.currentTarget.classList.add("drag-over");
  }

  function onDrop(event) {
    event.preventDefault();
    if (!requirePlaylistEditable()) return;
    const targetIndex = Number(event.currentTarget.dataset.index);
    if (state.dragIndex < 0 || state.dragIndex === targetIndex) return;
    const [moved] = state.playlist.splice(state.dragIndex, 1);
    state.playlist.splice(targetIndex, 0, moved);
    state.currentIndex = targetIndex;
    state.selectionAnchorIndex = targetIndex;
    state.dragIndex = -1;
    renderPlaylist();
    updatePreview();
    addLog("플레이리스트 순서 변경");
  }

  function onDragEnd() {
    state.dragIndex = -1;
    document.querySelectorAll("tr.dragging, tr.drag-over").forEach(row => row.classList.remove("dragging", "drag-over"));
  }

  function toggleSelectAll() {
    if (!requirePlaylistEditable()) return;
    const allSelected = state.playlist.length > 0 && state.playlist.every(item => state.selectedRows.has(item.id));
    state.selectedRows.clear();
    if (!allSelected) state.playlist.forEach(item => state.selectedRows.add(item.id));
    state.selectionAnchorIndex = !allSelected && state.playlist.length ? 0 : -1;
    renderPlaylist();
  }

  function toggleAllEntriesEnabled() {
    if (!requirePlaylistEditable()) return;
    if (!state.playlist.length) return showToast("송출 여부를 바꿀 플레이리스트 항목이 없습니다.");
    const enable = state.playlist.some(item => item.enabled === false);
    const replacements = state.playlist.map(item => recreatePlaylistEntryWithEnabled(item, enable));
    if (replacements.some(item => !item)) {
      showToast("검증된 항목의 송출 여부를 안전하게 변경할 수 없습니다.");
      return;
    }
    state.playlist = replacements;
    renderPlaylist();
    addLog(`플레이리스트 전체 ${enable ? "송출 포함" : "송출 제외"}`);
  }

  function moveSelected(direction) {
    if (!requirePlaylistEditable()) return;
    if (!state.selectedRows.size) return showToast("이동할 항목을 선택하세요.");
    const currentId = state.playlist[state.currentIndex]?.id;
    let changed = false;
    if (direction < 0) {
      for (let index = 1; index < state.playlist.length; index++) {
        if (state.selectedRows.has(state.playlist[index].id) &&
            !state.selectedRows.has(state.playlist[index - 1].id)) {
          [state.playlist[index - 1], state.playlist[index]] = [state.playlist[index], state.playlist[index - 1]];
          changed = true;
        }
      }
    } else {
      for (let index = state.playlist.length - 2; index >= 0; index--) {
        if (state.selectedRows.has(state.playlist[index].id) &&
            !state.selectedRows.has(state.playlist[index + 1].id)) {
          [state.playlist[index], state.playlist[index + 1]] = [state.playlist[index + 1], state.playlist[index]];
          changed = true;
        }
      }
    }
    if (!changed) return;
    state.currentIndex = state.playlist.findIndex(item => item.id === currentId);
    state.selectionAnchorIndex = state.currentIndex;
    renderPlaylist();
    updatePreview();
    addLog(`선택 항목 ${direction < 0 ? "위" : "아래"}로 이동`);
  }

  function deleteSelected() {
    if (!state.selectedRows.size) return showToast("삭제할 항목을 선택하세요.");
    const restoreRecords = [...state.manualFinancialRestore.entries.values()];
    const deletingFailedRestores = restoreRecords.length > 0 &&
      restoreRecords.every(record => record.status === "error") &&
      [...state.selectedRows].every(id =>
        state.manualFinancialRestore.entries.get(id)?.status === "error");
    if (!requirePlaylistEditable(deletingFailedRestores)) return;
    const deleted = state.selectedRows.size;
    for (const id of state.selectedRows) {
      const restore = state.manualFinancialRestore.entries.get(id);
      clearManualFinancialRestoreTimeout(restore);
      state.manualFinancialRestore.entries.delete(id);
    }
    state.playlist = state.playlist.filter(item => !state.selectedRows.has(item.id));
    state.selectedRows.clear();
    state.currentIndex = Math.min(state.currentIndex, state.playlist.length - 1);
    state.selectionAnchorIndex = state.currentIndex;
    renderPlaylist();
    renderNamedPlaylist();
    updatePreview();
    addLog(`${deleted}개 항목 삭제`);
    requestNextManualFinancialRestore();
  }

  function deleteAllPlaylistEntries() {
    const restoreRecords = [...state.manualFinancialRestore.entries.values()];
    const failedRestoreOnly = restoreRecords.length > 0 &&
      restoreRecords.every(record => record.status === "error");
    if (!requirePlaylistEditable(failedRestoreOnly)) return;
    if (!state.playlist.length) return;
    if (!window.confirm("플레이리스트의 모든 항목을 삭제할까요?")) return;
    const deleted = state.playlist.length;
    installManualFinancialRestores([]);
    clearNamedPlaylistLoadState();
    state.playlist = [];
    state.selectedRows.clear();
    state.currentIndex = -1;
    state.selectionAnchorIndex = -1;
    renderPlaylist();
    renderNamedPlaylist();
    updatePreview();
    addLog(`플레이리스트 전체 ${deleted}개 항목 삭제`);
  }

  function selectPlaylistBoundary(index, extend) {
    if (!requirePlaylistEditable() || !state.playlist.length) return;
    const target = Math.max(0, Math.min(state.playlist.length - 1, index));
    selectPlaylistRow(target, { shiftKey: extend, ctrlKey: false, metaKey: false });
    elements.playlistBody.querySelector(`tr[data-index="${target}"]`)?.scrollIntoView({ block: "nearest" });
  }

  function toggleCurrentEntryEnabled() {
    if (!requirePlaylistEditable()) return;
    const item = state.playlist[state.currentIndex];
    if (!item) return showToast("송출 여부를 바꿀 플레이리스트 항목을 선택하세요.");
    const replacement = replacePlaylistEntryEnabledAt(state.currentIndex, item.enabled === false);
    if (!replacement) {
      showToast("검증된 항목의 송출 여부를 안전하게 변경할 수 없습니다.");
      return;
    }
    renderPlaylist();
    addLog(`${replacement.code} ${replacement.enabled ? "송출 포함" : "송출 제외"}`);
  }

  function savePlaylist() {
    localStorage.setItem(storageKey, JSON.stringify(state.playlist));
    addLog(`플레이리스트 ${state.playlist.length}개 로컬 저장`);
    showToast("플레이리스트를 저장했습니다.");
  }

  function normalizeStoredSelection(value) {
    if (!value || typeof value !== "object") return undefined;
    const result = {};
    for (const key of ["groupCode", "subject", "graphicType", "subtype", "dataCode"]) {
      const text = typeof value[key] === "string" ? value[key] : "";
      if (text.length > 256 || /[\u0000-\u001f\u007f]/.test(text)) return undefined;
      result[key] = text;
    }
    return result;
  }

  function normalizeStoredPlaylist(value, options = {}) {
    if (!Array.isArray(value)) return [];
    return value.slice(0, 1000).flatMap((item, itemIndex) => {
      if (!item || typeof item !== "object") return [];
      const restoredFixedEntry = fixedWorkflow.restoreFixedPlaylistEntry(item, { now: options.now });
      if (restoredFixedEntry) {
        const fixedDefinition = catalog.find(definition =>
          definition.builderKey === restoredFixedEntry.builderKey &&
          sceneAliases(definition).includes(restoredFixedEntry.code));
        return fixedDefinition
          ? [{ ...fixedDefinition, ...restoredFixedEntry }]
          : [];
      }
      const restoredIndustryEntry = industryWorkflow.restoreIndustryPlaylistEntry(item, { now: options.now });
      if (restoredIndustryEntry) {
        const industryDefinition = catalog.find(definition =>
          definition.builderKey === restoredIndustryEntry.builderKey &&
          sceneAliases(definition).includes(restoredIndustryEntry.code));
        return industryDefinition
          ? [{ ...industryDefinition, ...restoredIndustryEntry }]
          : [];
      }
      const restoredComparisonEntry = comparisonWorkflow.restoreComparisonPlaylistEntry(item);
      if (restoredComparisonEntry) {
        const comparisonDefinition = catalog.find(definition =>
          definition.builderKey === restoredComparisonEntry.builderKey &&
          sceneAliases(definition).includes(restoredComparisonEntry.code));
        return comparisonDefinition
          ? [{ ...comparisonDefinition, ...restoredComparisonEntry }]
          : [];
      }
      const restoredThemeEntry = themeWorkflow.restoreThemePlaylistEntry(item);
      if (restoredThemeEntry) {
        const themeDefinition = catalog.find(definition =>
          definition.builderKey === restoredThemeEntry.builderKey &&
          sceneAliases(definition).includes(restoredThemeEntry.code));
        return themeDefinition
          ? [{ ...themeDefinition, ...restoredThemeEntry }]
          : [];
      }
      const restoredNamedForeignStockEntry =
        namedForeignStockRestoreWorkflow.restorePlaylistEntry(item);
      if (restoredNamedForeignStockEntry) {
        const overseasDefinition = catalog.find(definition =>
          definition.builderKey === restoredNamedForeignStockEntry.builderKey &&
          sceneAliases(definition).includes(restoredNamedForeignStockEntry.code));
        return overseasDefinition
          ? [{ ...overseasDefinition, ...restoredNamedForeignStockEntry }]
          : [];
      }
      const restoredOverseasEntry = overseasWorkflow.restoreOverseasPlaylistEntry(item);
      if (restoredOverseasEntry) {
        const overseasDefinition = catalog.find(definition =>
          definition.builderKey === restoredOverseasEntry.builderKey &&
          sceneAliases(definition).includes(restoredOverseasEntry.code));
        return overseasDefinition
          ? [{ ...overseasDefinition, ...restoredOverseasEntry }]
          : [];
      }
      const restoredExpertEntry = expertWorkflow.restoreExpertPlaylistEntry(item);
      if (restoredExpertEntry) {
        const expertDefinition = catalog.find(definition =>
          definition.builderKey === restoredExpertEntry.builderKey &&
          sceneAliases(definition).includes(restoredExpertEntry.code));
        return expertDefinition
          ? [{ ...expertDefinition, ...restoredExpertEntry }]
          : [];
      }
      const restoredTradingHaltEntry = tradingHaltWorkflow.restoreTradingHaltPlaylistEntry(item);
      if (restoredTradingHaltEntry) {
        const tradingHaltDefinition = catalog.find(definition =>
          definition.builderKey === restoredTradingHaltEntry.builderKey &&
          sceneAliases(definition).includes(restoredTradingHaltEntry.code));
        return tradingHaltDefinition
          ? [{ ...tradingHaltDefinition, ...restoredTradingHaltEntry }]
          : [];
      }
      const restoredManualListEntry = manualListsWorkflow.restorePlaylistEntry(item);
      if (restoredManualListEntry) {
        const pendingManualListEntry = namedManualRestoreWorkflow.createStoredManualListPending(
          restoredManualListEntry,
          itemIndex);
        if (!pendingManualListEntry) return [];
        const manualListDefinition = catalog.find(definition =>
          definition.builderKey === restoredManualListEntry.builderKey &&
          sceneAliases(definition).includes(restoredManualListEntry.code));
        if (manualListDefinition && Array.isArray(options.manualFinancialRestores)) {
          options.manualFinancialRestores.push(pendingManualListEntry);
        }
        return manualListDefinition
          ? [{ ...manualListDefinition, ...restoredManualListEntry }]
          : [];
      }
      const pendingManualFinancialEntry = manualFinancialWorkflow.restorePlaylistEntry(item);
      if (pendingManualFinancialEntry) {
        const manualFinancialDefinition = catalog.find(definition =>
          definition.builderKey === pendingManualFinancialEntry.entry.builderKey &&
          sceneAliases(definition).includes(pendingManualFinancialEntry.entry.code));
        if (!manualFinancialDefinition) return [];
        if (Array.isArray(options.manualFinancialRestores)) {
          options.manualFinancialRestores.push(pendingManualFinancialEntry);
        }
        return [{ ...manualFinancialDefinition, ...pendingManualFinancialEntry.entry }];
      }
      const restoredOperatorEntry = operatorWorkflow.restoreStockPlaylistEntry(item);
      if (restoredOperatorEntry) {
        const operatorDefinition = catalog.find(definition =>
          definition.builderKey === restoredOperatorEntry.builderKey &&
          sceneAliases(definition).includes(restoredOperatorEntry.code));
        return operatorDefinition
          ? [{ ...operatorDefinition, ...restoredOperatorEntry }]
          : [];
      }
      if (operatorInputBuilderKeys.has(String(item.builderKey || "")) || item.operator !== undefined) {
        return [];
      }
      const storedCode = String(item.code || "");
      const definitionIndex = playoutSafety.resolveStoredCatalogIndex(
        catalog,
        item.builderKey,
        storedCode);
      const definition = definitionIndex >= 0 ? catalog[definitionIndex] : null;
      if (!definition) return [];
      if (operatorInputBuilderKeys.has(definition.builderKey)) return [];
      const id = typeof item.id === "string" && /^[A-Za-z0-9_-]{1,128}$/.test(item.id)
        ? item.id
        : createId();
      const selection = normalizeStoredSelection(item.selection);
      if (!selection) return [];
      const fadeDuration = Number(item.fadeDuration);
      return [{
        ...definition,
        id,
        code: sceneAliases(definition).includes(storedCode) ? storedCode : definition.code,
        enabled: item.enabled !== false,
        fadeDuration: Number.isInteger(fadeDuration) && fadeDuration >= 0 && fadeDuration <= 60
          ? fadeDuration
          : DEFAULT_FADE_DURATION,
        ...(selection ? {
          selection,
          graphicType: selection.graphicType,
          subtype: selection.subtype
        } : {})
      }];
    });
  }

  function manualFinancialRestoreRecordForRequest(requestId, status) {
    if (typeof requestId !== "string") return null;
    for (const record of state.manualFinancialRestore.entries.values()) {
      const expectedId = record.request?.requestId ?? record.request?.payload?.requestId;
      if (record.status === status && expectedId === requestId) return record;
    }
    return null;
  }

  function clearManualFinancialRestoreTimeout(record) {
    if (record?.timeoutId !== null && record?.timeoutId !== undefined) {
      clearTimeout(record.timeoutId);
      record.timeoutId = null;
    }
  }

  function failManualFinancialRestore(record, message) {
    if (!record || !state.manualFinancialRestore.entries.has(record.pending.entry.id)) return;
    clearManualFinancialRestoreTimeout(record);
    record.status = "error";
    record.request = null;
    record.error = typeof message === "string" && message.trim()
      ? message.trim()
      : "수동 재무 항목을 현재 DB 값으로 다시 확인하지 못했습니다.";
    renderPlaylist();
    renderPlayout();
    const restoreLabel = record.pending.identity?.stockName || record.pending.audience ||
      (record.pending.kind === "vi" ? "VI" : "manual");
    addLog(`수동 입력 복원 차단 · ${restoreLabel} · ${record.error}`);
    requestNextManualFinancialRestore();
  }

  function armManualFinancialRestoreTimeout(record) {
    clearManualFinancialRestoreTimeout(record);
    const expectedRequestId = record.request?.requestId ?? record.request?.payload?.requestId;
    record.timeoutId = setTimeout(() => {
      if ((record.request?.requestId ?? record.request?.payload?.requestId) !== expectedRequestId) return;
      failManualFinancialRestore(record, "수동 재무 복원 응답 시간이 초과되었습니다. 자동 재시도하지 않습니다.");
    }, 30000);
  }

  function requestManualFinancialRestoreLoad(record) {
    try {
      if (["named-list", "stored-list"].includes(record.restoreKind)) {
        const envelope = namedManualRestoreWorkflow.createManualListReadRequest(
          `named-manual-read-${createId()}`,
          record.pending);
        record.status = "manual-list";
        record.request = envelope;
        record.error = "";
        postNative(envelope.type, envelope.payload);
        armManualFinancialRestoreTimeout(record);
        return;
      }
      const request = record.restoreKind === "named-financial"
        ? manualFinancialWorkflow.createLoadRequest(
            `named-financial-load-${createId()}`,
            record.pending.screen,
            record.pending.stockName)
        : manualFinancialWorkflow.createRestoreLoadRequest(
            `manual-restore-load-${createId()}`,
            record.pending);
      record.status = "load";
      record.request = request;
      record.error = "";
      postNative(manualFinancialWorkflow.bridgeContract.loadRequestType, request);
      armManualFinancialRestoreTimeout(record);
    } catch {
      failManualFinancialRestore(record, "저장된 수동 재무 식별자가 현재 계약과 일치하지 않습니다.");
    }
  }

  function requestNextManualFinancialRestore() {
    const records = [...state.manualFinancialRestore.entries.values()];
    if (records.some(record => ["load", "stock", "selection", "manual-list"].includes(record.status))) return;
    const next = records.find(record => record.status === "queued");
    if (next) {
      requestManualFinancialRestoreLoad(next);
      return;
    }
    finalizeNamedManualRestores();
  }

  function installManualFinancialRestores(pendingValues) {
    for (const current of state.manualFinancialRestore.entries.values()) {
      clearManualFinancialRestoreTimeout(current);
    }
    state.manualFinancialRestore.entries.clear();
    for (const pending of pendingValues) {
      if (!pending?.entry?.id || state.manualFinancialRestore.entries.has(pending.entry.id)) continue;
      state.manualFinancialRestore.entries.set(pending.entry.id, {
        pending,
        restoreKind: namedManualRestoreWorkflow.isPending(pending)
          ? (pending.kind === "financial"
            ? "named-financial"
            : (namedManualRestoreWorkflow.isStoredManualListPending(pending)
              ? "stored-list"
              : "named-list"))
          : "stored-financial",
        status: nativeBridge ? "queued" : "error",
        request: null,
        loadResponse: null,
        verifiedStock: null,
        timeoutId: null,
        error: nativeBridge ? "" : "Native Bridge가 없어 현재 DB 재검증을 실행할 수 없습니다."
      });
    }
    if (nativeBridge) requestNextManualFinancialRestore();
  }

  function handleManualFinancialRestoreLoadResults(payload) {
    const record = manualFinancialRestoreRecordForRequest(payload?.requestId, "load");
    if (!record) return false;
    clearManualFinancialRestoreTimeout(record);
    const response = manualFinancialWorkflow.normalizeLoadResponse(payload, record.request);
    if (!response) {
      failManualFinancialRestore(record, "Native Bridge가 올바른 수동 재무 load 응답을 보내지 않았습니다.");
      return true;
    }
    record.loadResponse = response;
    const expectedMarket = record.restoreKind === "named-financial"
      ? record.pending.market
      : record.pending.verifiedStock?.market;
    let searchQuery = response.snapshot.stockName;
    if (String(expectedMarket || "").startsWith("nxt-")) {
      const suffix = "(NXT)";
      if (!searchQuery.endsWith(suffix) || searchQuery.length === suffix.length) {
        failManualFinancialRestore(record, "NXT 종목의 원본 표시명에서 검색 identity를 복원할 수 없습니다.");
        return true;
      }
      searchQuery = searchQuery.slice(0, -suffix.length);
      if (!searchQuery || searchQuery !== searchQuery.trim() || searchQuery.includes(suffix)) {
        failManualFinancialRestore(record, "NXT 종목의 원본 표시명에서 검색 identity를 복원할 수 없습니다.");
        return true;
      }
    }
    const request = {
      requestId: `manual-restore-stock-${createId()}`,
      query: searchQuery,
      maximumResults: 200
    };
    record.status = "stock";
    record.request = request;
    postNative("search-stocks", request);
    armManualFinancialRestoreTimeout(record);
    return true;
  }

  function handleManualFinancialRestoreLoadError(payload) {
    const record = manualFinancialRestoreRecordForRequest(payload?.requestId, "load");
    if (!record) return false;
    const error = manualFinancialWorkflow.normalizeLoadError(payload, record.request);
    failManualFinancialRestore(record, error?.message || "수동 재무 DB load가 실패했습니다.");
    return true;
  }

  function handleManualFinancialRestoreStockResults(payload) {
    const record = manualFinancialRestoreRecordForRequest(payload?.requestId, "stock");
    if (!record) return false;
    clearManualFinancialRestoreTimeout(record);
    const response = manualFinancialWorkflow.normalizeStockSearchResponse(payload);
    const correlated = response && response.query === record.request.query &&
      response.truncated === false && response.totalRowCount <= record.request.maximumResults;
    const namedMatch = record.restoreKind === "named-financial" && correlated
      ? response.results.filter(item =>
          item.name === record.pending.stockName && item.market === record.pending.market)
      : [];
    const expectedMarket = record.restoreKind === "named-financial"
      ? record.pending.market
      : record.pending.verifiedStock.market;
    const expectedCode = record.restoreKind === "named-financial"
      ? (namedMatch.length === 1 ? namedMatch[0].code : "")
      : record.pending.verifiedStock.code;
    const verified = correlated && manualFinancialWorkflow.verifyStockForRecord(
      record.loadResponse.snapshot.record,
      response,
      expectedMarket,
      expectedCode);
    if (!verified) {
      failManualFinancialRestore(record, "저장된 종목의 시장·코드를 현재 종목 마스터에서 유일하게 확인하지 못했습니다.");
      return true;
    }
    record.verifiedStock = verified;
    try {
      const request = record.restoreKind === "named-financial"
        ? manualFinancialWorkflow.createSelectionRequest(
            `named-financial-selection-${createId()}`,
            record.loadResponse.snapshot,
            verified)
        : manualFinancialWorkflow.createRestoreSelectionRequest(
            `manual-restore-selection-${createId()}`,
            record.pending,
            record.loadResponse,
            verified);
      record.status = "selection";
      record.request = request;
      postNative(manualFinancialWorkflow.bridgeContract.selectionRequestType, request);
      armManualFinancialRestoreTimeout(record);
    } catch {
      failManualFinancialRestore(record, "수동 재무 행 또는 종목 마스터 값이 저장 시점과 달라졌습니다.");
    }
    return true;
  }

  function handleManualFinancialRestoreStockError(payload) {
    const record = manualFinancialRestoreRecordForRequest(payload?.requestId, "stock");
    if (!record) return false;
    const message = payload?.query === record.request.query && typeof payload?.message === "string"
      ? payload.message
      : "종목 마스터 재검증에 실패했습니다.";
    failManualFinancialRestore(record, message);
    return true;
  }

  function handleManualFinancialRestoreSelectionResults(payload) {
    const record = manualFinancialRestoreRecordForRequest(payload?.requestId, "selection");
    if (!record) return false;
    clearManualFinancialRestoreTimeout(record);
    const selection = manualFinancialWorkflow.normalizeSelectionResponse(payload, record.request);
    let entry = null;
    if (selection) {
      try {
        entry = record.restoreKind === "named-financial"
          ? manualFinancialWorkflow.createPlaylistEntry(
              record.loadResponse.snapshot,
              record.verifiedStock,
              selection,
              {
                id: record.pending.entry.id,
                fadeDuration: record.pending.fadeDuration,
                enabled: record.pending.enabled
              })
          : manualFinancialWorkflow.materializeRestoredPlaylistEntry(
              record.pending,
              record.loadResponse,
              record.verifiedStock,
              selection);
      } catch {
        entry = null;
      }
    }
    if (!entry || !manualFinancialWorkflow.isPlaylistEntryPlayable(entry) ||
        (record.restoreKind === "named-financial" &&
         !namedManualRestoreWorkflow.matchesMaterializedEntry(record.pending, entry))) {
      failManualFinancialRestore(record, "수동 재무 선택 결과가 저장된 행과 정확히 일치하지 않습니다.");
      return true;
    }
    const index = state.playlist.findIndex(item => item.id === record.pending.entry.id);
    if (index < 0 || isPlaylistSnapshotLocked()) {
      failManualFinancialRestore(record, "복원 중 플레이리스트 상태가 바뀌어 항목을 적용하지 않았습니다.");
      return true;
    }
    if (record.restoreKind === "named-financial" &&
        !trustNamedManualRestoration(record.pending, entry)) {
      failManualFinancialRestore(record, "DB raw 행과 현재 GraphE 선택을 안전하게 연결하지 못했습니다.");
      return true;
    }
    state.playlist[index] = entry;
    state.manualFinancialRestore.entries.delete(entry.id);
    renderPlaylist();
    updatePreview();
    renderPlayout();
    addLog(`수동 재무 복원 완료 · ${entry.title} · ${entry.code}`);
    requestNextManualFinancialRestore();
    return true;
  }

  function handleManualFinancialRestoreSelectionError(payload) {
    const record = manualFinancialRestoreRecordForRequest(payload?.requestId, "selection");
    if (!record) return false;
    const error = manualFinancialWorkflow.normalizeSelectionError(payload, record.request);
    failManualFinancialRestore(record, error?.message || "수동 재무 장면 선택 재검증이 실패했습니다.");
    return true;
  }

  function handleManualFinancialRestoreRequestError(payload) {
    const record = ["load", "selection"]
      .map(status => manualFinancialRestoreRecordForRequest(payload?.requestId, status))
      .find(Boolean);
    if (!record) return false;
    const expectedOperation = record.status === "selection" ? "selection" : "load";
    const error = manualFinancialWorkflow.normalizeRequestError(payload, expectedOperation);
    failManualFinancialRestore(record, error?.message || "수동 재무 복원 요청 형식이 거부되었습니다.");
    return true;
  }

  function trustNamedManualRestoration(pending, entry) {
    const restoration = state.namedPlaylist.restoration;
    if (!restoration || !namedManualRestoreWorkflow.matchesMaterializedEntry(pending, entry)) return false;
    const row = restoration.rows[pending.itemIndex];
    if (!row || row.trusted || row.rawRow.enabled !== pending.enabled ||
        !sameNamedSelection(row.rawRow, pending.rawSelection)) return false;
    const rows = restoration.rows.map((value, index) => index === pending.itemIndex
      ? Object.freeze({ ...value, entry, trusted: true })
      : value);
    state.namedPlaylist.restoration = Object.freeze({
      ...restoration,
      rows: Object.freeze(rows)
    });
    return true;
  }

  function finalizeNamedManualRestores() {
    if (state.namedPlaylist.manualRestoreFinalized || !state.namedPlaylist.restoration) return;
    const records = [...state.manualFinancialRestore.entries.values()];
    if (records.some(record => ["queued", "load", "stock", "selection", "manual-list"].includes(record.status))) {
      return;
    }
    if (state.namedPlaylist.comparisonRestorePending ||
        state.namedPlaylist.foreignStockRestorePending ||
        state.namedPlaylist.foreignIndexCandleRestorePending ||
        state.namedPlaylist.krxThemeRestorePending ||
        state.namedPlaylist.nxtThemeRestorePending) return;
    const restoration = prepareNamedPagePreflight(state.namedPlaylist.restoration);
    const materialized = materializeNamedRestoration(restoration);
    state.namedPlaylist.restoration = restoration;
    state.namedPlaylist.guardByEntryId = materialized.guards;
    state.namedPlaylist.manualRestoreFinalized = true;
    renderPlaylist();
    renderNamedPlaylist();
    renderPlayout();
    requestNamedPlaylistPagePlans();
  }

  function handleNamedManualListResults(payload) {
    const record = manualFinancialRestoreRecordForRequest(payload?.requestId, "manual-list");
    if (!record || !["named-list", "stored-list"].includes(record.restoreKind)) return false;
    clearManualFinancialRestoreTimeout(record);
    let entry = null;
    try {
      entry = namedManualRestoreWorkflow.materializeManualList(
        record.pending,
        payload,
        record.request);
    } catch {
      entry = null;
    }
    const index = state.playlist.findIndex(item => item.id === record.pending.entry.id);
    const current = index >= 0 ? state.playlist[index] : null;
    const trusted = entry && index >= 0 && !isPlaylistSnapshotLocked() &&
      (record.restoreKind === "named-list"
        ? trustNamedManualRestoration(record.pending, entry)
        : namedManualRestoreWorkflow.matchesMaterializedEntry(record.pending, current));
    if (!trusted) {
      failManualFinancialRestore(record, "현재 신뢰 저장소 값이 DB raw 행과 정확히 일치하지 않습니다.");
      return true;
    }
    state.playlist[index] = entry;
    state.manualFinancialRestore.entries.delete(entry.id);
    renderPlaylist();
    updatePreview();
    renderPlayout();
    addLog(`수동 입력 복원 완료 · ${entry.title} · ${entry.code}`);
    requestNextManualFinancialRestore();
    return true;
  }

  function handleNamedManualListError(payload) {
    const record = manualFinancialRestoreRecordForRequest(payload?.requestId, "manual-list");
    if (!record || !["named-list", "stored-list"].includes(record.restoreKind)) return false;
    const expectedId = record.request?.payload?.requestId;
    const error = manualListsWorkflow.normalizeError(payload, expectedId);
    failManualFinancialRestore(record, error?.message || "신뢰 수동 저장소 읽기에 실패했습니다.");
    return true;
  }

  function manualFinancialPrepareBlocked() {
    if (state.manualFinancialRestore.entries.size > 0) return true;
    return state.playlist.some(item =>
      (item?.operator?.source === "manual-financial-workflow" &&
       !manualFinancialWorkflow.isPlaylistEntryPlayable(item)) ||
      (item?.operator?.source === "legacy-manual-lists-workflow" &&
       (!manualListsWorkflow.restorePlaylistEntry(item) ||
        !manualListsWorkflow.isPlaylistEntryPlayable(item))));
  }

  function loadPlaylist() {
    if (!requirePlaylistEditable()) return;
    try {
      const stored = JSON.parse(localStorage.getItem(storageKey) || "[]");
      const pendingManualFinancial = [];
      state.playlist = normalizeStoredPlaylist(stored, {
        manualFinancialRestores: pendingManualFinancial
      });
      installManualFinancialRestores(pendingManualFinancial);
      state.namedPlaylist.guardByEntryId.clear();
      state.namedPlaylist.loadedDefinition = null;
      synchronizeGlobalCandleOptions(false);
      state.selectedRows.clear();
      if (state.playlist.length) state.selectedRows.add(state.playlist[0].id);
      state.currentIndex = state.playlist.length ? 0 : -1;
      state.selectionAnchorIndex = state.currentIndex;
      renderPlaylist();
      updatePreview();
      addLog(`플레이리스트 ${state.playlist.length}개 불러오기`);
      showToast("저장된 플레이리스트를 불러왔습니다.");
    } catch {
      showToast("저장 데이터를 읽을 수 없습니다.");
    }
  }

  function sameNamedSelection(left, right) {
    return Boolean(left && right) &&
      ["groupCode", "subject", "graphicType", "subtype", "dataCode"]
        .every(key => typeof left[key] === "string" && left[key] === right[key]);
  }

  function namedNowForRawRow(rawRow) {
    const dateMatch = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(rawRow?.dataCode || ""));
    const now = dateMatch
      ? new Date(Number(dateMatch[1]), Number(dateMatch[2]) - 1, Number(dateMatch[3]), 9, 0, 0, 0)
      : new Date();
    if (rawRow?.graphicType === "PRE_MARKET") now.setHours(9, 0, 0, 0);
    if (rawRow?.graphicType === "AFTER_MARKET") now.setHours(14, 0, 0, 0);
    return now;
  }

  function addNamedCandidate(candidates, factory) {
    try {
      const candidate = factory();
      if (candidate) candidates.push(candidate);
    } catch {
      // A raw seven-field row is untrusted until a closed workflow can recreate it.
    }
  }

  function namedMovingAverageOptions(rawRow) {
    const flags = new Set(String(rawRow.graphicType || "").split(",").filter(Boolean));
    return {
      ma5: flags.has("MA5"),
      ma20: flags.has("MA20")
    };
  }

  function addFixedNamedCandidates(candidates, rawRow, itemIndex, now) {
    for (const action of fixedWorkflow.actions) {
      if (!action.available) continue;
      const canonicalMatch = ["groupCode", "subject", "graphicType", "subtype", "dataCode"].every(key => {
        if (key === "dataCode" && action.dynamicDate) return /^\d{4}-\d{2}-\d{2}$/.test(rawRow.dataCode);
        if (key === "graphicType" && action.dynamicNxtSession) {
          return ["PRE_MARKET", "AFTER_MARKET"].includes(rawRow.graphicType);
        }
        return action.selection[key] === rawRow[key];
      });
      if (!canonicalMatch && !legacyNamedRowWorkflow.matchesFixedActionRaw(rawRow, action)) continue;
      addNamedCandidate(candidates, () => fixedWorkflow.createFixedPlaylistEntry(action.id, {
        id: `db-${itemIndex}-${action.id}`,
        fadeDuration: DEFAULT_FADE_DURATION,
        now
      }));
    }
  }

  function addStockNamedCandidates(candidates, rawRow, itemIndex) {
    const market = operatorWorkflow.normalizeMarket(rawRow.groupCode);
    if (!market || !rawRow.subject || !rawRow.dataCode) return;
    const displayName = rawRow.subject;
    const stockName = displayName.replace(/\s*\(NXT\)\s*/g, "").trim();
    const stock = {
      market: market.market,
      stockName,
      displayName,
      stockCode: rawRow.dataCode,
      isNxt: market.isNxt || displayName.includes("(NXT)")
    };
    const movingAverages = namedMovingAverageOptions(rawRow);
    for (const cut of operatorWorkflow.stockCuts) {
      addNamedCandidate(candidates, () => operatorWorkflow.createStockPlaylistEntry(stock, cut.id, {
        id: `db-${itemIndex}-stock-${cut.id}`,
        fadeDuration: DEFAULT_FADE_DURATION,
        ...movingAverages
      }));
    }
  }

  function addIndustryNamedCandidates(candidates, rawRow, itemIndex, now) {
    const descriptor = legacyNamedRowWorkflow.industryFixedDescriptor(rawRow);
    if (!descriptor) return;
    addNamedCandidate(candidates, () => industryWorkflow.createIndustryPlaylistEntry(
      descriptor.market,
      descriptor.cutId,
      {
        id: `db-${itemIndex}-industry-${descriptor.cutId}`,
        fadeDuration: DEFAULT_FADE_DURATION,
        now
      }));
  }

  function addThemeNamedCandidates(candidates, rawRow, itemIndex, now) {
    // Historical theme codes are not authoritative. Keep every original KRX
    // and NXT row untrusted until its correlated native batch maps the exact
    // title to one current code and validates the item preview.
    if (rawRow.groupCode === "테마" || rawRow.groupCode === "테마_NXT") return;
    let theme;
    let sort;
    let actionIds = themeWorkflow.actions.map(action => action.id);
    const legacy = legacyNamedRowWorkflow.themeLegacyDescriptor(rawRow, { now });
    if (legacy) {
      theme = legacy.theme;
      sort = legacy.sort;
      actionIds = [legacy.actionId];
    } else if (rawRow.groupCode === "PAGED_THEME") {
      theme = {
        market: "krx",
        themeCode: rawRow.dataCode,
        title: rawRow.subject,
        session: null,
        itemCount: 1
      };
      sort = rawRow.graphicType;
    } else if (rawRow.groupCode === "PAGED_NXT_THEME" && rawRow.subject.endsWith("(NXT)")) {
      theme = {
        market: "nxt",
        themeCode: rawRow.dataCode,
        title: rawRow.subject.slice(0, -5),
        session: rawRow.graphicType,
        itemCount: 1
      };
      sort = rawRow.subtype;
    } else {
      return;
    }
    for (const actionId of actionIds) {
      addNamedCandidate(candidates, () => themeWorkflow.createThemePlaylistEntry(actionId, theme, sort, {
        id: `db-${itemIndex}-theme-${actionId}`,
        fadeDuration: DEFAULT_FADE_DURATION
      }));
    }
  }

  function addOverseasNamedCandidates(candidates, rawRow, itemIndex) {
    const movingAverages = namedMovingAverageOptions(rawRow);
    if (rawRow.groupCode === "FOREIGN_INDUSTRY") {
      const parts = String(rawRow.dataCode || "").split("|");
      if (parts.length !== 4) return;
      addNamedCandidate(candidates, () => overseasWorkflow.createOverseasIndustryPlaylistEntry({
        koreanName: rawRow.subject,
        inputName: parts[0],
        symbol: parts[1],
        nationCode: parts[2],
        fdtc: parts[3]
      }, {
        id: `db-${itemIndex}-overseas-industry`,
        fadeDuration: DEFAULT_FADE_DURATION
      }));
      return;
    }
    if (rawRow.groupCode !== "FOREIGN_STOCK") return;
    const parts = String(rawRow.dataCode || "").split("|");
    if (parts.length !== 3) return;
    const stock = {
      inputName: rawRow.subject,
      symbol: parts[0],
      nationCode: parts[1],
      fdtc: parts[2]
    };
    for (const action of overseasWorkflow.stockActions) {
      addNamedCandidate(candidates, () => overseasWorkflow.createOverseasStockPlaylistEntry(stock, action.id, {
        id: `db-${itemIndex}-overseas-${action.id}`,
        fadeDuration: DEFAULT_FADE_DURATION,
        ...movingAverages
      }));
    }
  }

  function addExpertNamedCandidate(candidates, rawRow, itemIndex) {
    const expert = rawRow.groupCode === "PAGED_EXPERT"
      ? {
        expertCode: rawRow.dataCode,
        expertName: rawRow.subject,
        source: "oracle",
        itemCount: 1,
        previewTruncated: false
      }
      : legacyNamedRowWorkflow.expertLegacyDescriptor(rawRow);
    if (!expert) return;
    addNamedCandidate(candidates, () => expertWorkflow.createExpertPlaylistEntry(expert, {
      id: `db-${itemIndex}-expert`,
      fadeDuration: DEFAULT_FADE_DURATION
    }));
  }

  function addTradingHaltNamedCandidates(candidates, rawRow, itemIndex) {
    const upperGroup = String(rawRow.groupCode || "").toLocaleUpperCase("en-US");
    const market = upperGroup.includes("KOSDAQ") ? "kosdaq" : (upperGroup.includes("KOSPI") ? "kospi" : null);
    if (!market || !rawRow.subject || !rawRow.dataCode) return;
    const selected = { market, stockCode: rawRow.dataCode, displayName: rawRow.subject };
    const movingAverages = namedMovingAverageOptions(rawRow);
    for (const action of tradingHaltWorkflow.actions) {
      addNamedCandidate(candidates, () => tradingHaltWorkflow.createTradingHaltPlaylistEntry(selected, action.id, {
        id: `db-${itemIndex}-halt-${action.id}`,
        fadeDuration: DEFAULT_FADE_DURATION,
        ...movingAverages
      }));
    }
  }

  function addDefaultNamedCandidates(candidates, rawRow, itemIndex) {
    for (const definition of catalog) {
      if (definition.reachable === false || operatorInputBuilderKeys.has(definition.builderKey)) continue;
      const selection = defaultSceneSelection(definition.builderKey);
      if (!sameNamedSelection(selection, rawRow)) continue;
      candidates.push({
        ...definition,
        id: `db-${itemIndex}-default-${definition.builderKey}`,
        selection: { ...selection },
        enabled: rawRow.enabled,
        fadeDuration: DEFAULT_FADE_DURATION,
        graphicType: selection.graphicType,
        subtype: selection.subtype
      });
    }
  }

  function resolveNamedPlaylistRawRow(rawRow, itemIndex) {
    // The stored theme code is historical evidence, not a playable identity.
    // Route both legacy theme groups only through their correlated native
    // verifiers; no stock/default candidate may accept the stale code first.
    if (rawRow?.groupCode === "테마" || rawRow?.groupCode === "테마_NXT") return null;
    const candidates = [];
    const now = namedNowForRawRow(rawRow);
    addFixedNamedCandidates(candidates, rawRow, itemIndex, now);
    addStockNamedCandidates(candidates, rawRow, itemIndex);
    addIndustryNamedCandidates(candidates, rawRow, itemIndex, now);
    addThemeNamedCandidates(candidates, rawRow, itemIndex, now);
    addOverseasNamedCandidates(candidates, rawRow, itemIndex);
    addExpertNamedCandidate(candidates, rawRow, itemIndex);
    addTradingHaltNamedCandidates(candidates, rawRow, itemIndex);
    addDefaultNamedCandidates(candidates, rawRow, itemIndex);

    const trusted = [];
    for (const candidate of candidates) {
      const restored = normalizeStoredPlaylist([{ ...candidate, enabled: rawRow.enabled }], { now });
      const entry = restored.length === 1 ? restored[0] : null;
      if (!entry || entry.enabled !== rawRow.enabled ||
          !legacyNamedRowWorkflow.matchesCanonicalEntry(entry, rawRow, { now })) continue;
      trusted.push(entry);
    }
    return legacyNamedRowWorkflow.selectUniqueCanonicalEntry(trusted, rawRow, { now });
  }

  function prepareNamedPagePreflight(restoration) {
    return namedPlaylistWorkflow.preparePagePreflight(
      restoration,
      entry => namedPagedBuilderKeys.has(entry.builderKey));
  }

  function namedInvalidEntry(definition, rawRow) {
    return {
      id: `db-invalid-${definition.programCode}-${rawRow.itemIndex}`,
      builderKey: namedInvalidBuilderKey,
      code: "0",
      aliases: ["0"],
      title: rawRow.subject || `DB 행 ${rawRow.itemIndex + 1}`,
      detail: "DB 7필드에서 컷을 유일하게 복원할 수 없음",
      category: "invalid",
      market: rawRow.groupCode || "unknown",
      selection: {
        groupCode: rawRow.groupCode,
        subject: rawRow.subject,
        graphicType: rawRow.graphicType,
        subtype: rawRow.subtype,
        dataCode: rawRow.dataCode
      },
      enabled: rawRow.enabled,
      fadeDuration: DEFAULT_FADE_DURATION,
      graphicType: rawRow.graphicType,
      subtype: rawRow.subtype,
      pageText: rawRow.pageText
    };
  }

  function materializeNamedRestoration(restoration) {
    const entries = [];
    const guards = new Map();
    for (const row of restoration.rows) {
      const trustedOperatorEntry = row.entry?.operator?.source === "manual-financial-workflow" ||
        row.entry?.operator?.source === "legacy-manual-lists-workflow" ||
        namedKrxThemeRestoreWorkflow.isMaterializedEntry(row.entry) ||
        namedNxtThemeRestoreWorkflow.isMaterializedEntry(row.entry) ||
        namedForeignStockRestoreWorkflow.isMaterializedEntry(row.entry) ||
        foreignIndexCandleRestoreWorkflow.isMaterializedEntry(row.entry);
      const entry = row.trusted
        ? (trustedOperatorEntry ? row.entry : { ...row.entry, pageText: row.rawRow.pageText })
        : namedInvalidEntry(restoration.definition, row.rawRow);
      entries.push(entry);
      guards.set(entry.id, Object.freeze({
        trusted: row.trusted,
        pageRecalculationRequired: row.pageRecalculationRequired,
        pageRecalculated: row.pageRecalculated,
        pagePreflightCompleted: row.pagePreflightCompleted,
        pageUnavailableReason: row.pageUnavailableReason,
        recalculatedPagePlan: row.recalculatedPagePlan,
        historicalPageOutOfBounds: row.rawRow.historicalPageOutOfBounds,
        rawRow: row.rawRow
      }));
    }
    return { entries, guards };
  }

  function namedPlaylistPrepareBlockers() {
    const blockers = [];
    state.playlist.forEach((entry, index) => {
      const guard = state.namedPlaylist.guardByEntryId.get(entry.id);
      if (!guard) return;
      if (!guard.trusted) blockers.push({ itemIndex: index, reason: "trusted-restore-required" });
      if (guard.pageRecalculationRequired && !guard.pageRecalculated) {
        blockers.push({
          itemIndex: index,
          reason: guard.pagePreflightCompleted && guard.pageUnavailableReason === "empty-result"
            ? "page-result-empty"
            : "page-recalculation-required"
        });
      }
    });
    return blockers;
  }

  function clearNamedPlaylistLoadState() {
    state.namedPlaylist.guardByEntryId.clear();
    state.namedPlaylist.loadedDefinition = null;
    state.namedPlaylist.restoration = null;
    state.namedPlaylist.pagePlanPending = null;
    state.namedPlaylist.pagePlanError = null;
    state.namedPlaylist.manualRestoreFinalized = true;
    state.namedPlaylist.comparisonRestorePending = null;
    clearNamedForeignStockRestoreTimeout(state.namedPlaylist.foreignStockRestorePending);
    state.namedPlaylist.foreignStockRestorePending = null;
    state.namedPlaylist.foreignIndexCandleRestorePending = null;
    state.namedPlaylist.krxThemeRestorePending = null;
    state.namedPlaylist.nxtThemeRestorePending = null;
  }

  function saveNamedPlaylistBackup() {
    if (!state.playlist.length) return true;
    try {
      localStorage.setItem(namedPlaylistBackupStorageKey, JSON.stringify({
        savedAt: new Date().toISOString(),
        playlist: state.playlist
      }));
      return true;
    } catch {
      return false;
    }
  }

  function hasNamedPlaylistBackup() {
    try {
      return Boolean(localStorage.getItem(namedPlaylistBackupStorageKey));
    } catch {
      return false;
    }
  }

  function restoreNamedPlaylistBackup() {
    if (!requirePlaylistEditable() || namedPlaylistBusy()) return;
    try {
      const stored = JSON.parse(localStorage.getItem(namedPlaylistBackupStorageKey) || "null");
      if (!stored || typeof stored !== "object" || !Array.isArray(stored.playlist)) throw new Error();
      const pendingManualFinancial = [];
      const restored = normalizeStoredPlaylist(stored.playlist, {
        manualFinancialRestores: pendingManualFinancial
      });
      if (restored.length !== stored.playlist.length) throw new Error();
      state.playlist = restored;
      installManualFinancialRestores(pendingManualFinancial);
      synchronizeGlobalCandleOptions(false);
      clearNamedPlaylistLoadState();
      state.selectedRows.clear();
      if (state.playlist.length) state.selectedRows.add(state.playlist[0].id);
      state.currentIndex = state.playlist.length ? 0 : -1;
      state.selectionAnchorIndex = state.currentIndex;
      localStorage.removeItem(namedPlaylistBackupStorageKey);
      renderPlaylist();
      updatePreview();
      addLog(`DB 불러오기 전 플레이리스트 ${state.playlist.length}개 복원`);
      showToast("DB 불러오기 전 상태를 복원했습니다.");
    } catch {
      showToast("DB 불러오기 전 상태 백업을 복원할 수 없습니다.");
    }
  }

  function namedPageTextForEntry(entry) {
    if (!namedPagedBuilderKeys.has(entry.builderKey)) return "1/1";
    const guard = state.namedPlaylist.guardByEntryId.get(entry.id);
    const guardedCount = guard?.recalculatedPagePlan?.pageCount;
    const previewCount = entry.pagePreview?.pageCount ?? entry.pageCount;
    const pageCount = Number.isInteger(guardedCount) ? guardedCount : previewCount;
    if (!Number.isInteger(pageCount) || pageCount < 1 ||
        pageCount > namedPlaylistWorkflow.bridgeContract.maximumPageCount) {
      throw new Error("5·6·12행 장면은 현재 DB 결과로 페이지를 다시 계산한 뒤 저장해야 합니다.");
    }
    return `1/${pageCount}`;
  }

  function namedLegacyFieldsForEntry(entry, itemIndex, pageText) {
    const manualFields = namedManualRestoreWorkflow.legacyFieldsForEntry(entry, itemIndex);
    if (manualFields) {
      if (manualFields[5] !== pageText) {
        throw new Error("수동 항목의 원본 페이지 서명을 현재 검증 결과와 일치시킬 수 없습니다.");
      }
      return manualFields;
    }
    const selection = namedKrxThemeRestoreWorkflow.legacySelectionForEntry(entry) ||
      namedNxtThemeRestoreWorkflow.legacySelectionForEntry(entry) ||
      namedForeignStockRestoreWorkflow.legacySelectionForEntry(entry) ||
      legacyNamedRowWorkflow.legacySelectionForEntry(entry);
    if (!selection) {
      throw new Error("이 항목은 원본 PList와 상호 운용되는 7필드 서명이 아직 없습니다.");
    }
    const fields = namedPlaylistWorkflow.toLegacySevenFields(
      { ...entry, selection },
      pageText);
    const rawRow = {
      itemIndex,
      enabled: fields[0] === "1",
      groupCode: fields[1],
      subject: fields[2],
      graphicType: fields[3],
      subtype: fields[4],
      pageText: fields[5],
      dataCode: fields[6]
    };
    if (namedKrxThemeRestoreWorkflow.isMaterializedEntry(entry)) {
      if (!namedKrxThemeRestoreWorkflow.matchesRaw(entry, rawRow)) {
        throw new Error("KRX 테마의 과거 code와 현재 송출 code를 안전하게 왕복할 수 없습니다.");
      }
      return fields;
    }
    if (namedNxtThemeRestoreWorkflow.isMaterializedEntry(entry)) {
      if (!namedNxtThemeRestoreWorkflow.matchesRaw(entry, rawRow)) {
        throw new Error("NXT 테마의 과거 code와 현재 송출 code를 안전하게 왕복할 수 없습니다.");
      }
      return fields;
    }
    if (entry.operator?.source === "legacy-comparison-workflow") {
      const restoredComparison = comparisonWorkflow.restoreComparisonPlaylistEntry(entry);
      if (!restoredComparison || restoredComparison.builderKey !== entry.builderKey ||
          restoredComparison.code !== entry.code ||
          !legacyNamedRowWorkflow.matchesCanonicalEntry(restoredComparison, rawRow)) {
        throw new Error("비교 항목의 원본 7필드 서명을 안전하게 왕복할 수 없습니다.");
      }
      return fields;
    }
    if (entry.operator?.source === "legacy-foreign-index-candle-workflow") {
      const restoredForeignIndex = foreignIndexCandleRestoreWorkflow.restorePlaylistEntry(entry);
      if (!restoredForeignIndex || restoredForeignIndex.builderKey !== entry.builderKey ||
          restoredForeignIndex.code !== entry.code ||
          !legacyNamedRowWorkflow.matchesCanonicalEntry(restoredForeignIndex, rawRow)) {
        throw new Error("해외지수 캔들 항목의 원본 7필드 서명을 안전하게 왕복할 수 없습니다.");
      }
      return fields;
    }
    if (entry.operator?.source === "legacy-named-foreign-stock-workflow") {
      const restoredForeignStock = namedForeignStockRestoreWorkflow.restorePlaylistEntry(entry);
      if (!restoredForeignStock || restoredForeignStock.builderKey !== entry.builderKey ||
          restoredForeignStock.code !== entry.code ||
          !namedForeignStockRestoreWorkflow.matchesRaw(restoredForeignStock, rawRow)) {
        throw new Error("해외종목 항목의 원본 7필드 서명을 안전하게 왕복할 수 없습니다.");
      }
      return fields;
    }
    const roundTrip = resolveNamedPlaylistRawRow(rawRow, itemIndex);
    if (!roundTrip || roundTrip.builderKey !== entry.builderKey || roundTrip.code !== entry.code) {
      throw new Error("원본 PList 7필드 서명을 현재 항목으로 유일하게 복원할 수 없습니다.");
    }
    return fields;
  }

  function isTrustedNamedPlaylistEntry(entry, itemIndex = 0) {
    const guard = state.namedPlaylist.guardByEntryId.get(entry?.id);
    if (guard && (!guard.trusted || (guard.pageRecalculationRequired && !guard.pageRecalculated))) return false;
    if (namedKrxThemeRestoreWorkflow.isMaterializedEntry(entry)) {
      if (!guard || !guard.trusted) return false;
      try {
        const legacySelection = namedKrxThemeRestoreWorkflow.legacySelectionForEntry(entry);
        const fields = namedPlaylistWorkflow.toLegacySevenFields(
          { ...entry, selection: legacySelection },
          namedPageTextForEntry(entry));
        return namedKrxThemeRestoreWorkflow.matchesRaw(entry, {
          itemIndex,
          enabled: fields[0] === "1",
          groupCode: fields[1],
          subject: fields[2],
          graphicType: fields[3],
          subtype: fields[4],
          pageText: fields[5],
          dataCode: fields[6]
        });
      } catch {
        return false;
      }
    }
    if (namedNxtThemeRestoreWorkflow.isMaterializedEntry(entry)) {
      if (!guard || !guard.trusted) return false;
      try {
        const legacySelection = namedNxtThemeRestoreWorkflow.legacySelectionForEntry(entry);
        const fields = namedPlaylistWorkflow.toLegacySevenFields(
          { ...entry, selection: legacySelection },
          namedPageTextForEntry(entry));
        return namedNxtThemeRestoreWorkflow.matchesRaw(entry, {
          itemIndex,
          enabled: fields[0] === "1",
          groupCode: fields[1],
          subject: fields[2],
          graphicType: fields[3],
          subtype: fields[4],
          pageText: fields[5],
          dataCode: fields[6]
        });
      } catch {
        return false;
      }
    }
    if (entry?.operator?.source === "legacy-foreign-index-candle-workflow" &&
        (!guard || !guard.trusted)) return false;
    if (entry?.operator?.source === "legacy-named-foreign-stock-workflow" &&
        (!guard || !guard.trusted)) return false;
    if (namedManualRestoreWorkflow.isTrustedEntryForSave(entry, itemIndex)) return true;
    const selection = entry?.selection;
    if (!selection) return false;
    const now = namedNowForRawRow(selection);
    const restored = entry.operator?.source === "legacy-foreign-index-candle-workflow"
      ? [foreignIndexCandleRestoreWorkflow.restorePlaylistEntry(entry)].filter(Boolean)
      : entry.operator?.source === "legacy-named-foreign-stock-workflow"
        ? [namedForeignStockRestoreWorkflow.restorePlaylistEntry(entry)].filter(Boolean)
        : normalizeStoredPlaylist([entry], { now });
    if (restored.length !== 1 || restored[0].builderKey !== entry.builderKey ||
        restored[0].code !== entry.code || !sameNamedSelection(restored[0].selection, selection)) return false;
    if ([
      "legacy-comparison-workflow",
      "legacy-industry-workflow",
      "legacy-foreign-index-candle-workflow",
      "legacy-named-foreign-stock-workflow"
    ]
        .includes(entry.operator?.source)) {
      const legacySelection = legacyNamedRowWorkflow.legacySelectionForEntry(entry);
      if (!legacySelection) return false;
      let legacyFields;
      try {
        legacyFields = namedPlaylistWorkflow.toLegacySevenFields(
          { ...entry, selection: legacySelection },
          namedPageTextForEntry(entry));
      } catch {
        return false;
      }
      const rawRow = {
        itemIndex: 0,
        enabled: legacyFields[0] === "1",
        groupCode: legacyFields[1],
        subject: legacyFields[2],
        graphicType: legacyFields[3],
        subtype: legacyFields[4],
        pageText: legacyFields[5],
        dataCode: legacyFields[6]
      };
      const restoredClosedEntry = entry.operator.source === "legacy-comparison-workflow"
        ? comparisonWorkflow.restoreComparisonPlaylistEntry(entry)
        : entry.operator.source === "legacy-industry-workflow"
          ? industryWorkflow.restoreIndustryPlaylistEntry(entry)
          : entry.operator.source === "legacy-foreign-index-candle-workflow"
            ? foreignIndexCandleRestoreWorkflow.restorePlaylistEntry(entry)
            : namedForeignStockRestoreWorkflow.restorePlaylistEntry(entry);
      return Boolean(restoredClosedEntry && restoredClosedEntry.builderKey === entry.builderKey &&
        restoredClosedEntry.code === entry.code &&
        legacyNamedRowWorkflow.matchesCanonicalEntry(restoredClosedEntry, rawRow));
    }
    let fields;
    try {
      fields = namedPlaylistWorkflow.toLegacySevenFields(entry, namedPageTextForEntry(entry));
    } catch {
      return false;
    }
    const roundTrip = resolveNamedPlaylistRawRow({
      itemIndex: 0,
      enabled: fields[0] === "1",
      groupCode: fields[1],
      subject: fields[2],
      graphicType: fields[3],
      subtype: fields[4],
      pageText: fields[5],
      dataCode: fields[6]
    }, 0);
    return Boolean(roundTrip && roundTrip.builderKey === entry.builderKey && roundTrip.code === entry.code);
  }

  function selectedNamedPlaylistDefinition() {
    return state.namedPlaylist.definitions.find(
      value => value.programCode === state.namedPlaylist.selectedProgramCode) || null;
  }

  function namedPlaylistBusy() {
    return Boolean(state.namedPlaylist.pending || state.namedPlaylist.pagePlanPending ||
      state.namedPlaylist.comparisonRestorePending ||
      state.namedPlaylist.foreignStockRestorePending ||
      state.namedPlaylist.foreignIndexCandleRestorePending ||
      state.namedPlaylist.krxThemeRestorePending ||
      state.namedPlaylist.nxtThemeRestorePending ||
      state.manualFinancialRestore.entries.size > 0);
  }

  function renderNamedPlaylist() {
    const workflow = state.namedPlaylist;
    const locked = isPlaylistSnapshotLocked();
    const pending = workflow.pending || workflow.pagePlanPending;
    const freshRestorePending = state.manualFinancialRestore.entries.size > 0 ||
      Boolean(workflow.comparisonRestorePending) ||
      Boolean(workflow.foreignStockRestorePending) ||
      Boolean(workflow.foreignIndexCandleRestorePending) ||
      Boolean(workflow.krxThemeRestorePending) ||
      Boolean(workflow.nxtThemeRestorePending);
    const busy = Boolean(pending) || freshRestorePending;
    const selected = selectedNamedPlaylistDefinition();
    const blockers = namedPlaylistPrepareBlockers();
    const pageRetryAvailable = workflow.pagePlanError?.retryable === true ||
      blockers.some(value => value.reason === "page-result-empty");
    const writeAllowed = Boolean(nativeBridge) && !locked && !busy &&
      workflow.canMutate && !workflow.writeQuarantined;

    elements.namedPlaylistPanel.hidden = !workflow.open;
    elements.namedPlaylistOpenButton.setAttribute("aria-expanded", String(workflow.open));
    elements.namedPlaylistOpenButton.textContent = workflow.open ? "DB 패널 닫기" : "DB 저장·불러오기";

    elements.namedPlaylistDefinitions.replaceChildren();
    for (const definition of workflow.definitions) {
      const option = document.createElement("option");
      option.value = definition.programCode;
      option.textContent = `${definition.title} · ${definition.programCode}`;
      option.selected = definition.programCode === workflow.selectedProgramCode;
      elements.namedPlaylistDefinitions.append(option);
    }
    elements.namedPlaylistSelectionSummary.textContent = selected
      ? `${selected.title} · ${selected.programCode}${workflow.loadedDefinition?.programCode === selected.programCode ? " · 현재 불러온 정의" : ""}`
      : "선택된 DB 정의 없음";

    elements.namedPlaylistStatus.classList.remove("ready", "error", "quarantined");
    elements.namedPlaylistStateBadge.classList.remove("neutral", "live", "dry-run");
    let badge = "대기";
    let status = "Oracle에 저장된 이름 목록을 조회하세요. 로컬 임시저장과는 별도입니다.";
    if (!nativeBridge) {
      badge = "미리보기";
      status = "Native Bridge가 없어 Oracle DB 플레이리스트를 사용할 수 없습니다.";
    } else if (workflow.writeQuarantined) {
      badge = "WRITE 잠금";
      status = workflow.error || "DB 쓰기 결과가 불명확합니다. 앱을 다시 시작하기 전까지 생성·덮어쓰기·삭제를 반복하지 않습니다.";
      elements.namedPlaylistStatus.classList.add("quarantined");
      elements.namedPlaylistStateBadge.classList.add("dry-run");
    } else if (workflow.error) {
      badge = "오류";
      status = workflow.error;
      elements.namedPlaylistStatus.classList.add("error");
      elements.namedPlaylistStateBadge.classList.add("dry-run");
    } else if (pending) {
      badge = "처리 중";
      status = pending.operation === "page-preflight"
        ? "현재 Oracle 조회 결과로 5·6·12행 장면의 페이지 수를 계산하고 있습니다. 이 조회는 송출 엔진을 호출하지 않습니다."
        : `${pending.operation.toLocaleUpperCase("en-US")} 요청을 처리하고 있습니다. 완료 전에는 다른 DB 작업이나 PREPARE를 실행하지 않습니다.`;
    } else if (freshRestorePending) {
      badge = "재검증 중";
      status = "DB 행의 수동·비교 장면 identity를 현재 저장소와 종목 마스터에서 재검증하고 있습니다. 완료 전에는 다른 DB 작업이나 PREPARE를 실행하지 않습니다.";
    } else if (workflow.loadedDefinition) {
      badge = blockers.length ? "복원 확인" : "불러옴";
      status = blockers.length
        ? `${workflow.loadedDefinition.title}을(를) 불러왔지만 ${blockers.length}개 안전 조건이 남았습니다. 표시된 행을 현재 전용 선택 화면에서 다시 구성하세요.`
        : `${workflow.loadedDefinition.title} · ${state.playlist.length}개 행이 신뢰 복원과 페이지 검증을 통과했습니다.`;
      elements.namedPlaylistStatus.classList.add(blockers.length ? "quarantined" : "ready");
      elements.namedPlaylistStateBadge.classList.add(blockers.length ? "dry-run" : "live");
    } else if (workflow.status === "ready") {
      badge = "목록 준비";
      status = `${workflow.definitions.length}개 DB 정의를 조회했습니다.${workflow.canMutate ? "" : " 현재 환경은 DB 쓰기를 허용하지 않습니다."}`;
      elements.namedPlaylistStatus.classList.add("ready");
      elements.namedPlaylistStateBadge.classList.add("live");
    } else {
      elements.namedPlaylistStateBadge.classList.add("neutral");
    }
    if (!elements.namedPlaylistStateBadge.classList.contains("live") &&
        !elements.namedPlaylistStateBadge.classList.contains("dry-run")) {
      elements.namedPlaylistStateBadge.classList.add("neutral");
    }
    elements.namedPlaylistStateBadge.textContent = badge;
    elements.namedPlaylistStatus.textContent = locked
      ? "PREPARED/PROGRAM 상태에서는 DB 플레이리스트 UI가 TAKE OUT까지 잠깁니다."
      : status;

    elements.namedPlaylistRefreshButton.disabled = !nativeBridge || locked || busy;
    elements.namedPlaylistDefinitions.disabled = locked || busy || !workflow.definitions.length;
    elements.namedPlaylistLoadButton.disabled = !nativeBridge || locked || busy || !selected;
    elements.namedPlaylistPageRetryButton.hidden = !pageRetryAvailable;
    elements.namedPlaylistPageRetryButton.disabled = !nativeBridge || locked || busy ||
      !workflow.restoration;
    elements.namedPlaylistRestoreBackupButton.disabled = locked || busy || !hasNamedPlaylistBackup();
    elements.namedPlaylistProgramCode.disabled = !writeAllowed;
    elements.namedPlaylistProgramTitle.disabled = !writeAllowed;
    elements.namedPlaylistCreateButton.disabled = !writeAllowed;
    elements.namedPlaylistReplaceButton.disabled = !writeAllowed || !selected;
    elements.namedPlaylistDeleteButton.disabled = !writeAllowed || !selected;
    for (const button of [
      elements.namedPlaylistRefreshButton,
      elements.namedPlaylistLoadButton,
      elements.namedPlaylistPageRetryButton,
      elements.namedPlaylistCreateButton,
      elements.namedPlaylistReplaceButton,
      elements.namedPlaylistDeleteButton
    ]) button.setAttribute("aria-busy", String(busy));

    const unresolved = blockers.filter(value => value.reason === "trusted-restore-required").length;
    const pagePending = blockers.filter(value => value.reason === "page-recalculation-required").length;
    const pageEmpty = blockers.filter(value => value.reason === "page-result-empty").length;
    elements.namedPlaylistGuardSummary.classList.toggle("blocked", blockers.length > 0);
    elements.namedPlaylistGuardSummary.textContent = blockers.length
      ? `PREPARE 차단 · 컷 복원 ${unresolved}건 · 페이지 조회 ${pagePending}건 · DB 결과 없음 ${pageEmpty}건. 원본 7필드가 여러 컷과 겹치면 임의로 선택하지 않습니다.`
      : "DB에서 읽은 각 행은 기존 trusted restore 체인과 현재 20페이지 상한 검증을 통과했습니다.";
  }

  function beginNamedPlaylistOperation(operation, request, programCode = "") {
    if (namedPlaylistBusy()) return false;
    state.namedPlaylist.pending = {
      requestId: request.requestId,
      operation,
      programCode
    };
    state.namedPlaylist.status = "pending";
    state.namedPlaylist.error = "";
    postNative(namedPlaylistWorkflow.bridgeContract.requests[operation], request);
    renderNamedPlaylist();
    return true;
  }

  function requestNamedPlaylistList() {
    if (!nativeBridge) return showToast("브라우저 미리보기에서는 Oracle DB 이름 목록을 조회하지 않습니다.");
    if (isPlaylistSnapshotLocked()) return showToast("TAKE OUT 후 DB 플레이리스트를 조회하세요.");
    try {
      const request = namedPlaylistWorkflow.createListRequest(`named-list-${createId()}`, 1000);
      beginNamedPlaylistOperation("list", request);
    } catch {
      showToast("DB 플레이리스트 목록 요청을 만들 수 없습니다.");
    }
  }

  function requestNamedPlaylistLoad() {
    if (!nativeBridge || isPlaylistSnapshotLocked() || namedPlaylistBusy()) return;
    const definition = selectedNamedPlaylistDefinition();
    if (!definition) return showToast("불러올 DB 정의를 선택하세요.");
    if (state.playlist.length && !window.confirm(
        `${definition.title}을(를) 불러오면 현재 화면의 플레이리스트가 바뀝니다. 불러오기 직전 상태는 별도 로컬 백업으로 보존합니다.`)) return;
    try {
      const request = namedPlaylistWorkflow.createLoadRequest(
        `named-load-${createId()}`,
        definition.programCode);
      beginNamedPlaylistOperation("load", request, definition.programCode);
    } catch {
      showToast("선택한 DB 플레이리스트 요청 형식이 올바르지 않습니다.");
    }
  }

  function requestNamedPlaylistCreate() {
    if (!nativeBridge || isPlaylistSnapshotLocked() || namedPlaylistBusy() ||
        state.namedPlaylist.writeQuarantined || !state.namedPlaylist.canMutate) return;
    try {
      const request = namedPlaylistWorkflow.createDefinitionRequest(
        `named-create-${createId()}`,
        elements.namedPlaylistProgramCode.value,
        elements.namedPlaylistProgramTitle.value);
      if (!window.confirm(`${request.programCode} · ${request.title} 이름을 새로 만들까요? 현재 순서는 만든 뒤 '선택 정의에 현재 순서 저장'으로 저장합니다.`)) return;
      state.namedPlaylist.selectAfterRefresh = request.programCode;
      beginNamedPlaylistOperation("create", request, request.programCode);
    } catch {
      showToast("새 코드는 8자리 숫자, 이름은 앞뒤 공백 없는 128자 이내로 입력하세요.");
    }
  }

  function requestNamedPlaylistReplace() {
    if (!nativeBridge || isPlaylistSnapshotLocked() || namedPlaylistBusy() ||
        state.namedPlaylist.writeQuarantined || !state.namedPlaylist.canMutate) return;
    const definition = selectedNamedPlaylistDefinition();
    if (!definition) return showToast("덮어쓸 DB 정의를 선택하세요.");
    const blockers = namedPlaylistPrepareBlockers();
    if (blockers.length) {
      renderNamedPlaylist();
      return showToast("복원 또는 페이지 재계산이 끝나지 않은 행은 DB에 덮어쓸 수 없습니다.");
    }
    try {
      const request = namedPlaylistWorkflow.createReplaceRequest(
        `named-replace-${createId()}`,
        definition.programCode,
        state.playlist,
        {
          isTrustedEntry: isTrustedNamedPlaylistEntry,
          pageTextForEntry: namedPageTextForEntry,
          fieldsForEntry: namedLegacyFieldsForEntry
        });
      if (!window.confirm(`${definition.title}의 DB 플레이리스트를 현재 ${request.items.length}개 순서로 교체할까요? 이 작업은 한 트랜잭션으로 실행되며 자동 재시도하지 않습니다.`)) return;
      beginNamedPlaylistOperation("replace", request, definition.programCode);
    } catch (error) {
      addLog(`DB 플레이리스트 저장 차단 · ${error?.message || "trusted restore 실패"}`);
      showToast("DB 7필드로 유일하게 왕복 복원되지 않거나 현재 페이지 수가 없는 행이 있어 저장을 중단했습니다.");
    }
  }

  function requestNamedPlaylistDelete() {
    if (!nativeBridge || isPlaylistSnapshotLocked() || namedPlaylistBusy() ||
        state.namedPlaylist.writeQuarantined || !state.namedPlaylist.canMutate) return;
    const definition = selectedNamedPlaylistDefinition();
    if (!definition) return showToast("삭제할 DB 정의를 선택하세요.");
    if (!window.confirm(`${definition.title} · ${definition.programCode} 정의와 그 하위 PLAY_LIST 행을 삭제할까요?`)) return;
    try {
      const request = namedPlaylistWorkflow.createDeleteRequest(
        `named-delete-${createId()}`,
        definition.programCode);
      beginNamedPlaylistOperation("delete", request, definition.programCode);
    } catch {
      showToast("선택한 DB 정의를 삭제할 수 없습니다.");
    }
  }

  function namedPendingMatches(operation, payload) {
    const pending = state.namedPlaylist.pending;
    return Boolean(pending && pending.operation === operation && payload?.requestId === pending.requestId);
  }

  function failNamedPlaylistProtocol(operation) {
    const pending = state.namedPlaylist.pending;
    if (!pending || pending.operation !== operation) return;
    state.namedPlaylist.pending = null;
    state.namedPlaylist.status = "error";
    state.namedPlaylist.error = "Native Bridge가 유효한 DB 플레이리스트 응답을 보내지 않았습니다.";
    if (namedWriteOperations.has(operation)) {
      state.namedPlaylist.writeQuarantined = true;
      state.namedPlaylist.canMutate = false;
      state.namedPlaylist.error += " 쓰기 결과를 확인할 수 없어 앱 재시작 전까지 추가 쓰기를 차단합니다.";
    }
    renderNamedPlaylist();
  }

  function handleNamedPlaylistListResults(payload) {
    const result = namedPlaylistWorkflow.normalizeListResponse(payload);
    if (!result) return failNamedPlaylistProtocol("list");
    if (!namedPendingMatches("list", result)) return;
    const previousSuggestion = state.namedPlaylist.suggestedProgramCode;
    state.namedPlaylist.pending = null;
    state.namedPlaylist.status = "ready";
    state.namedPlaylist.definitions = [...result.definitions];
    state.namedPlaylist.suggestedProgramCode = result.suggestedProgramCode;
    state.namedPlaylist.canMutate = result.canMutate;
    state.namedPlaylist.writeQuarantined = state.namedPlaylist.writeQuarantined || result.writeQuarantined;
    const preferred = state.namedPlaylist.selectAfterRefresh || state.namedPlaylist.selectedProgramCode;
    state.namedPlaylist.selectedProgramCode = result.definitions.some(value => value.programCode === preferred)
      ? preferred
      : (result.definitions[0]?.programCode || "");
    state.namedPlaylist.selectAfterRefresh = "";
    if (!elements.namedPlaylistProgramCode.value ||
        elements.namedPlaylistProgramCode.value === previousSuggestion) {
      elements.namedPlaylistProgramCode.value = result.suggestedProgramCode;
    }
    renderNamedPlaylist();
    addLog(`DB 플레이리스트 이름 목록 ${result.definitions.length}개 조회`);
  }

  function handleNamedPlaylistLoadResults(payload) {
    const load = namedPlaylistWorkflow.normalizeLoadResponse(payload);
    if (!load) return failNamedPlaylistProtocol("load");
    if (!namedPendingMatches("load", load)) return;
    state.namedPlaylist.pending = null;
    state.namedPlaylist.canMutate = load.canMutate;
    state.namedPlaylist.writeQuarantined = state.namedPlaylist.writeQuarantined || load.writeQuarantined;
    if (isPlaylistSnapshotLocked()) {
      state.namedPlaylist.status = "error";
      state.namedPlaylist.error = "DB 응답 중 송출 상태가 잠겨 현재 플레이리스트를 교체하지 않았습니다.";
      renderNamedPlaylist();
      return;
    }
    if (!saveNamedPlaylistBackup()) {
      state.namedPlaylist.status = "error";
      state.namedPlaylist.error = "현재 플레이리스트를 로컬 백업하지 못해 DB 불러오기를 적용하지 않았습니다.";
      renderNamedPlaylist();
      return;
    }
    try {
      let restoration = namedPlaylistWorkflow.restoreRawRows(
        load,
        resolveNamedPlaylistRawRow,
        legacyNamedRowWorkflow.matchesCanonicalEntry);
      const pendingNamedManual = restoration.rows.flatMap(row => {
        if (row.trusted) return [];
        const pending = namedManualRestoreWorkflow.classify(row.rawRow, {
          id: `db-invalid-${load.definition.programCode}-${row.rawRow.itemIndex}`,
          fadeDuration: DEFAULT_FADE_DURATION
        });
        return pending ? [pending] : [];
      });
      const pendingNamedComparison = restoration.rows.flatMap(row => {
        if (row.trusted) return [];
        const pending = namedComparisonRestoreWorkflow.classify(row.rawRow, {
          id: `db-invalid-${load.definition.programCode}-${row.rawRow.itemIndex}`,
          fadeDuration: DEFAULT_FADE_DURATION
        });
        return pending ? [pending] : [];
      });
      const pendingForeignStocks = restoration.rows.flatMap(row => {
        if (row.trusted) return [];
        const pending = namedForeignStockRestoreWorkflow.classify(row.rawRow, {
          id: `db-invalid-${load.definition.programCode}-${row.rawRow.itemIndex}`,
          fadeDuration: DEFAULT_FADE_DURATION,
          ma5: state.candleOptions.ma5,
          ma20: state.candleOptions.ma20
        });
        return pending ? [pending] : [];
      });
      const pendingForeignIndexCandles = restoration.rows.flatMap(row => {
        if (row.trusted) return [];
        const pending = foreignIndexCandleRestoreWorkflow.classify(row.rawRow, {
          id: `db-invalid-${load.definition.programCode}-${row.rawRow.itemIndex}`,
          fadeDuration: DEFAULT_FADE_DURATION
        });
        return pending ? [pending] : [];
      });
      const pendingKrxThemes = restoration.rows.flatMap(row => {
        if (row.trusted) return [];
        const pending = namedKrxThemeRestoreWorkflow.classify(row.rawRow, {
          id: `db-invalid-${load.definition.programCode}-${row.rawRow.itemIndex}`,
          fadeDuration: DEFAULT_FADE_DURATION
        });
        return pending ? [pending] : [];
      });
      const pendingNxtThemes = restoration.rows.flatMap(row => {
        if (row.trusted) return [];
        const pending = namedNxtThemeRestoreWorkflow.classify(row.rawRow, {
          id: `db-invalid-${load.definition.programCode}-${row.rawRow.itemIndex}`,
          fadeDuration: DEFAULT_FADE_DURATION
        });
        return pending ? [pending] : [];
      });
      state.namedPlaylist.manualRestoreFinalized =
        pendingNamedManual.length === 0 && pendingNamedComparison.length === 0 &&
        pendingForeignStocks.length === 0 &&
        pendingForeignIndexCandles.length === 0 && pendingKrxThemes.length === 0 &&
        pendingNxtThemes.length === 0;
      if (state.namedPlaylist.manualRestoreFinalized) {
        restoration = prepareNamedPagePreflight(restoration);
      }
      const materialized = materializeNamedRestoration(restoration);
      state.playlist = materialized.entries;
      synchronizeGlobalCandleOptions(false);
      state.namedPlaylist.guardByEntryId = materialized.guards;
      state.namedPlaylist.restoration = restoration;
      state.namedPlaylist.comparisonRestorePending = pendingNamedComparison.length
        ? Object.freeze({
            requestId: load.requestId,
            rows: Object.freeze(pendingNamedComparison)
          })
        : null;
      state.namedPlaylist.foreignStockRestorePending = pendingForeignStocks.length
        ? {
            requestId: load.requestId,
            rows: Object.freeze(pendingForeignStocks),
            timeoutId: null
          }
        : null;
      armNamedForeignStockRestoreTimeout(state.namedPlaylist.foreignStockRestorePending);
      state.namedPlaylist.foreignIndexCandleRestorePending = pendingForeignIndexCandles.length
        ? Object.freeze({
            requestId: load.requestId,
            rows: Object.freeze(pendingForeignIndexCandles)
          })
        : null;
      state.namedPlaylist.krxThemeRestorePending = pendingKrxThemes.length
        ? Object.freeze({
            requestId: load.requestId,
            rows: Object.freeze(pendingKrxThemes)
          })
        : null;
      state.namedPlaylist.nxtThemeRestorePending = pendingNxtThemes.length
        ? Object.freeze({
            requestId: load.requestId,
            rows: Object.freeze(pendingNxtThemes)
          })
        : null;
      state.namedPlaylist.pagePlanError = null;
      state.namedPlaylist.loadedDefinition = load.definition;
      state.namedPlaylist.selectedProgramCode = load.definition.programCode;
      state.namedPlaylist.status = "loaded";
      state.namedPlaylist.error = "";
      state.selectedRows.clear();
      if (state.playlist.length) state.selectedRows.add(state.playlist[0].id);
      state.currentIndex = state.playlist.length ? 0 : -1;
      state.selectionAnchorIndex = state.currentIndex;
      installManualFinancialRestores(pendingNamedManual);
      renderPlaylist();
      updatePreview();
      const blockers = namedPlaylistPrepareBlockers();
      addLog(`DB 플레이리스트 ${load.definition.title} · ${state.playlist.length}개 불러오기 · 차단 ${blockers.length}건`);
      showToast(blockers.length
        ? "DB 목록을 불러왔습니다. 표시된 복원·페이지 항목을 확인하세요."
        : "DB 플레이리스트를 안전하게 불러왔습니다.");
      if (state.namedPlaylist.manualRestoreFinalized) requestNamedPlaylistPagePlans();
    } catch {
      state.namedPlaylist.status = "error";
      state.namedPlaylist.error = "DB 원시 행의 엄격한 복원 중 오류가 발생해 현재 플레이리스트를 교체하지 않았습니다.";
      renderNamedPlaylist();
    }
  }

  function failNamedComparisonRestore(record, message) {
    if (!record || state.namedPlaylist.comparisonRestorePending !== record) return;
    state.namedPlaylist.comparisonRestorePending = null;
    state.namedPlaylist.error = message;
    state.namedPlaylist.status = "error";
    finalizeNamedManualRestores();
    renderPlaylist();
    renderNamedPlaylist();
    renderPlayout();
  }

  function handleNamedComparisonRestoreResults(payload) {
    const record = state.namedPlaylist.comparisonRestorePending;
    if (!record || payload?.requestId !== record.requestId) return;
    const response = namedComparisonRestoreWorkflow.normalizeResponse(
      payload,
      record.requestId,
      record.rows);
    if (!response) {
      failNamedComparisonRestore(
        record,
        "Native Bridge의 비교 행 재검증 응답이 현재 DB 목록과 일치하지 않습니다.");
      return;
    }
    const restoration = state.namedPlaylist.restoration;
    if (!restoration || isPlaylistSnapshotLocked()) {
      failNamedComparisonRestore(
        record,
        "비교 행 재검증 중 플레이리스트 상태가 바뀌어 결과를 적용하지 않았습니다.");
      return;
    }
    const rows = [...restoration.rows];
    let restoredCount = 0;
    const failures = new Map();
    for (let index = 0; index < response.rows.length; index += 1) {
      const pending = record.rows[index];
      const outcome = response.rows[index];
      if (outcome.status === "failed") {
        failures.set(outcome.failure, (failures.get(outcome.failure) || 0) + 1);
        continue;
      }
      const storedRow = rows[pending.itemIndex];
      const entry = namedComparisonRestoreWorkflow.materialize(pending, outcome);
      if (!storedRow || storedRow.trusted || !entry ||
          state.playlist[pending.itemIndex]?.id !== pending.entry.id ||
          entry.enabled !== storedRow.rawRow.enabled ||
          !legacyNamedRowWorkflow.matchesCanonicalEntry(entry, storedRow.rawRow)) {
        failures.set("MATERIALIZATION_MISMATCH",
          (failures.get("MATERIALIZATION_MISMATCH") || 0) + 1);
        continue;
      }
      rows[pending.itemIndex] = Object.freeze({
        ...storedRow,
        entry,
        trusted: true
      });
      state.playlist[pending.itemIndex] = entry;
      restoredCount += 1;
    }
    state.namedPlaylist.restoration = Object.freeze({
      ...restoration,
      rows: Object.freeze(rows)
    });
    state.namedPlaylist.comparisonRestorePending = null;
    addLog(`DB 비교 행 재검증 · 복원 ${restoredCount}건 · 차단 ${response.rows.length - restoredCount}건`);
    if (failures.size) {
      const summary = [...failures.entries()]
        .map(([code, count]) => `${code} ${count}`)
        .join(" · ");
      addLog(`DB 비교 행 차단 사유 · ${summary}`);
    }
    finalizeNamedManualRestores();
    renderPlaylist();
    updatePreview();
    renderNamedPlaylist();
    renderPlayout();
  }

  function handleNamedComparisonRestoreError(payload) {
    const record = state.namedPlaylist.comparisonRestorePending;
    if (!record || payload?.requestId !== record.requestId) return;
    const error = namedComparisonRestoreWorkflow.normalizeError(payload, record.requestId);
    failNamedComparisonRestore(
      record,
      error?.message || "비교 행의 현재 종목 마스터 재검증을 완료하지 못했습니다.");
  }

  function clearNamedForeignStockRestoreTimeout(record) {
    if (record?.timeoutId !== null && record?.timeoutId !== undefined) {
      clearTimeout(record.timeoutId);
      record.timeoutId = null;
    }
  }

  function armNamedForeignStockRestoreTimeout(record) {
    if (!record) return;
    clearNamedForeignStockRestoreTimeout(record);
    record.timeoutId = setTimeout(() => {
      if (state.namedPlaylist.foreignStockRestorePending !== record) return;
      failNamedForeignStockRestore(
        record,
        "해외종목 identity 재검증 응답 시간이 초과되었습니다. 자동 재시도하지 않습니다.");
    }, 30000);
  }

  function failNamedForeignStockRestore(record, message) {
    if (!record || state.namedPlaylist.foreignStockRestorePending !== record) return;
    clearNamedForeignStockRestoreTimeout(record);
    state.namedPlaylist.foreignStockRestorePending = null;
    state.namedPlaylist.error = message;
    state.namedPlaylist.status = "error";
    finalizeNamedManualRestores();
    renderPlaylist();
    renderNamedPlaylist();
    renderPlayout();
  }

  function handleNamedForeignStockRestoreResults(payload) {
    const record = state.namedPlaylist.foreignStockRestorePending;
    if (!record || payload?.requestId !== record.requestId) return;
    const response = namedForeignStockRestoreWorkflow.normalizeResponse(
      payload,
      record.requestId,
      record.rows);
    if (!response) {
      failNamedForeignStockRestore(
        record,
        "Native Bridge의 해외종목 재검증 응답이 현재 DB 목록과 일치하지 않습니다.");
      return;
    }
    const restoration = state.namedPlaylist.restoration;
    if (!restoration || isPlaylistSnapshotLocked()) {
      failNamedForeignStockRestore(
        record,
        "해외종목 재검증 중 플레이리스트 상태가 바뀌어 결과를 적용하지 않았습니다.");
      return;
    }

    const rows = [...restoration.rows];
    let restoredCount = 0;
    const failures = new Map();
    for (let index = 0; index < response.rows.length; index += 1) {
      const pending = record.rows[index];
      const outcome = response.rows[index];
      if (outcome.status === "failed") {
        failures.set(outcome.failure, (failures.get(outcome.failure) || 0) + 1);
        continue;
      }
      const storedRow = rows[pending.itemIndex];
      const entry = namedForeignStockRestoreWorkflow.materialize(pending, outcome);
      if (!storedRow || storedRow.trusted || !entry ||
          !namedForeignStockRestoreWorkflow.isMaterializedEntry(entry) ||
          state.playlist[pending.itemIndex]?.id !== pending.entry.id ||
          entry.enabled !== storedRow.rawRow.enabled ||
          !namedForeignStockRestoreWorkflow.matchesRaw(entry, storedRow.rawRow)) {
        failures.set("MATERIALIZATION_MISMATCH",
          (failures.get("MATERIALIZATION_MISMATCH") || 0) + 1);
        continue;
      }
      rows[pending.itemIndex] = Object.freeze({
        ...storedRow,
        entry,
        trusted: true
      });
      state.playlist[pending.itemIndex] = entry;
      restoredCount += 1;
    }
    state.namedPlaylist.restoration = Object.freeze({
      ...restoration,
      rows: Object.freeze(rows)
    });
    clearNamedForeignStockRestoreTimeout(record);
    state.namedPlaylist.foreignStockRestorePending = null;
    addLog(`DB 해외종목 재검증 · 복원 ${restoredCount}건 · 차단 ${response.rows.length - restoredCount}건`);
    if (failures.size) {
      const summary = [...failures.entries()]
        .map(([code, count]) => `${code} ${count}`)
        .join(" · ");
      addLog(`DB 해외종목 차단 사유 · ${summary}`);
    }
    finalizeNamedManualRestores();
    renderPlaylist();
    updatePreview();
    renderNamedPlaylist();
    renderPlayout();
  }

  function handleNamedForeignStockRestoreError(payload) {
    const record = state.namedPlaylist.foreignStockRestorePending;
    if (!record || payload?.requestId !== record.requestId) return;
    const error = namedForeignStockRestoreWorkflow.normalizeError(payload, record.requestId);
    failNamedForeignStockRestore(
      record,
      error?.message || "해외종목의 현재 Oracle identity 재검증을 완료하지 못했습니다.");
  }

  function failNamedForeignIndexCandleRestore(record, message) {
    if (!record || state.namedPlaylist.foreignIndexCandleRestorePending !== record) return;
    state.namedPlaylist.foreignIndexCandleRestorePending = null;
    state.namedPlaylist.error = message;
    state.namedPlaylist.status = "error";
    finalizeNamedManualRestores();
    renderPlaylist();
    renderNamedPlaylist();
    renderPlayout();
  }

  function handleNamedForeignIndexCandleRestoreResults(payload) {
    const record = state.namedPlaylist.foreignIndexCandleRestorePending;
    if (!record || payload?.requestId !== record.requestId) return;
    const response = foreignIndexCandleRestoreWorkflow.normalizeResponse(
      payload,
      record.requestId,
      record.rows);
    if (!response) {
      failNamedForeignIndexCandleRestore(
        record,
        "Native Bridge의 해외지수 캔들 재검증 응답이 현재 DB 목록과 일치하지 않습니다.");
      return;
    }
    const restoration = state.namedPlaylist.restoration;
    if (!restoration || isPlaylistSnapshotLocked()) {
      failNamedForeignIndexCandleRestore(
        record,
        "해외지수 캔들 재검증 중 플레이리스트 상태가 바뀌어 결과를 적용하지 않았습니다.");
      return;
    }

    const rows = [...restoration.rows];
    let restoredCount = 0;
    const failures = new Map();
    for (let index = 0; index < response.rows.length; index += 1) {
      const pending = record.rows[index];
      const outcome = response.rows[index];
      if (outcome.status === "failed") {
        failures.set(outcome.failure, (failures.get(outcome.failure) || 0) + 1);
        continue;
      }
      const storedRow = rows[pending.itemIndex];
      const entry = foreignIndexCandleRestoreWorkflow.materialize(pending, outcome);
      if (!storedRow || storedRow.trusted || !entry ||
          !foreignIndexCandleRestoreWorkflow.isMaterializedEntry(entry) ||
          state.playlist[pending.itemIndex]?.id !== pending.entry.id ||
          entry.enabled !== storedRow.rawRow.enabled ||
          !legacyNamedRowWorkflow.matchesCanonicalEntry(entry, storedRow.rawRow)) {
        failures.set("MATERIALIZATION_MISMATCH",
          (failures.get("MATERIALIZATION_MISMATCH") || 0) + 1);
        continue;
      }
      rows[pending.itemIndex] = Object.freeze({
        ...storedRow,
        entry,
        trusted: true
      });
      state.playlist[pending.itemIndex] = entry;
      restoredCount += 1;
    }
    state.namedPlaylist.restoration = Object.freeze({
      ...restoration,
      rows: Object.freeze(rows)
    });
    state.namedPlaylist.foreignIndexCandleRestorePending = null;
    addLog(`DB 해외지수 캔들 재검증 · 복원 ${restoredCount}건 · 차단 ${response.rows.length - restoredCount}건`);
    if (failures.size) {
      const summary = [...failures.entries()]
        .map(([code, count]) => `${code} ${count}`)
        .join(" · ");
      addLog(`DB 해외지수 캔들 차단 사유 · ${summary}`);
    }
    finalizeNamedManualRestores();
    renderPlaylist();
    updatePreview();
    renderNamedPlaylist();
    renderPlayout();
  }

  function handleNamedForeignIndexCandleRestoreError(payload) {
    const record = state.namedPlaylist.foreignIndexCandleRestorePending;
    if (!record || payload?.requestId !== record.requestId) return;
    const error = foreignIndexCandleRestoreWorkflow.normalizeError(payload, record.requestId);
    failNamedForeignIndexCandleRestore(
      record,
      error?.message || "해외지수 캔들의 현재 master 재검증을 완료하지 못했습니다.");
  }

  function failNamedKrxThemeRestore(record, message) {
    if (!record || state.namedPlaylist.krxThemeRestorePending !== record) return;
    state.namedPlaylist.krxThemeRestorePending = null;
    state.namedPlaylist.error = message;
    state.namedPlaylist.status = "error";
    finalizeNamedManualRestores();
    renderPlaylist();
    renderNamedPlaylist();
    renderPlayout();
  }

  function handleNamedKrxThemeRestoreResults(payload) {
    const record = state.namedPlaylist.krxThemeRestorePending;
    if (!record || payload?.requestId !== record.requestId) return;
    const response = namedKrxThemeRestoreWorkflow.normalizeResponse(
      payload,
      record.requestId,
      record.rows);
    if (!response) {
      failNamedKrxThemeRestore(
        record,
        "Native Bridge의 KRX 테마 재검증 응답이 현재 DB 목록과 일치하지 않습니다.");
      return;
    }
    const restoration = state.namedPlaylist.restoration;
    if (!restoration || isPlaylistSnapshotLocked()) {
      failNamedKrxThemeRestore(
        record,
        "KRX 테마 재검증 중 플레이리스트 상태가 바뀌어 결과를 적용하지 않았습니다.");
      return;
    }

    const rows = [...restoration.rows];
    let restoredCount = 0;
    let remappedCount = 0;
    const failures = new Map();
    for (let index = 0; index < response.rows.length; index += 1) {
      const pending = record.rows[index];
      const outcome = response.rows[index];
      if (outcome.status === "failed") {
        failures.set(outcome.failure, (failures.get(outcome.failure) || 0) + 1);
        continue;
      }
      const storedRow = rows[pending.itemIndex];
      const entry = namedKrxThemeRestoreWorkflow.materialize(pending, outcome);
      if (!storedRow || storedRow.trusted || !entry ||
          !namedKrxThemeRestoreWorkflow.isMaterializedEntry(entry) ||
          state.playlist[pending.itemIndex]?.id !== pending.entry.id ||
          entry.enabled !== storedRow.rawRow.enabled ||
          !namedKrxThemeRestoreWorkflow.matchesRaw(entry, storedRow.rawRow)) {
        failures.set("MATERIALIZATION_MISMATCH",
          (failures.get("MATERIALIZATION_MISMATCH") || 0) + 1);
        continue;
      }
      rows[pending.itemIndex] = Object.freeze({
        ...storedRow,
        entry,
        trusted: true
      });
      state.playlist[pending.itemIndex] = entry;
      restoredCount += 1;
      if (outcome.codeWasRemapped) remappedCount += 1;
    }
    state.namedPlaylist.restoration = Object.freeze({
      ...restoration,
      rows: Object.freeze(rows)
    });
    state.namedPlaylist.krxThemeRestorePending = null;
    addLog(`DB KRX 테마 재검증 · 복원 ${restoredCount}건 · code 갱신 ${remappedCount}건 · 차단 ${response.rows.length - restoredCount}건`);
    if (failures.size) {
      const summary = [...failures.entries()]
        .map(([code, count]) => `${code} ${count}`)
        .join(" · ");
      addLog(`DB KRX 테마 차단 사유 · ${summary}`);
    }
    finalizeNamedManualRestores();
    renderPlaylist();
    updatePreview();
    renderNamedPlaylist();
    renderPlayout();
  }

  function handleNamedKrxThemeRestoreError(payload) {
    const record = state.namedPlaylist.krxThemeRestorePending;
    if (!record || payload?.requestId !== record.requestId) return;
    const error = namedKrxThemeRestoreWorkflow.normalizeError(payload, record.requestId);
    failNamedKrxThemeRestore(
      record,
      error?.message || "KRX 테마의 현재 Oracle identity 재검증을 완료하지 못했습니다.");
  }

  function failNamedNxtThemeRestore(record, message) {
    if (!record || state.namedPlaylist.nxtThemeRestorePending !== record) return;
    state.namedPlaylist.nxtThemeRestorePending = null;
    state.namedPlaylist.error = message;
    state.namedPlaylist.status = "error";
    finalizeNamedManualRestores();
    renderPlaylist();
    renderNamedPlaylist();
    renderPlayout();
  }

  function handleNamedNxtThemeRestoreResults(payload) {
    const record = state.namedPlaylist.nxtThemeRestorePending;
    if (!record || payload?.requestId !== record.requestId) return;
    const response = namedNxtThemeRestoreWorkflow.normalizeResponse(
      payload,
      record.requestId,
      record.rows);
    if (!response) {
      failNamedNxtThemeRestore(
        record,
        "Native Bridge의 NXT 테마 재검증 응답이 현재 DB 목록과 일치하지 않습니다.");
      return;
    }
    const restoration = state.namedPlaylist.restoration;
    if (!restoration || isPlaylistSnapshotLocked()) {
      failNamedNxtThemeRestore(
        record,
        "NXT 테마 재검증 중 플레이리스트 상태가 바뀌어 결과를 적용하지 않았습니다.");
      return;
    }

    const rows = [...restoration.rows];
    let restoredCount = 0;
    let remappedCount = 0;
    const failures = new Map();
    for (let index = 0; index < response.rows.length; index += 1) {
      const pending = record.rows[index];
      const outcome = response.rows[index];
      if (outcome.status === "failed") {
        failures.set(outcome.failure, (failures.get(outcome.failure) || 0) + 1);
        continue;
      }
      const storedRow = rows[pending.itemIndex];
      const entry = namedNxtThemeRestoreWorkflow.materialize(pending, outcome);
      if (!storedRow || storedRow.trusted || !entry ||
          !namedNxtThemeRestoreWorkflow.isMaterializedEntry(entry) ||
          state.playlist[pending.itemIndex]?.id !== pending.entry.id ||
          entry.enabled !== storedRow.rawRow.enabled ||
          !namedNxtThemeRestoreWorkflow.matchesRaw(entry, storedRow.rawRow)) {
        failures.set("MATERIALIZATION_MISMATCH",
          (failures.get("MATERIALIZATION_MISMATCH") || 0) + 1);
        continue;
      }
      rows[pending.itemIndex] = Object.freeze({
        ...storedRow,
        entry,
        trusted: true
      });
      state.playlist[pending.itemIndex] = entry;
      restoredCount += 1;
      if (outcome.codeWasRemapped) remappedCount += 1;
    }
    state.namedPlaylist.restoration = Object.freeze({
      ...restoration,
      rows: Object.freeze(rows)
    });
    state.namedPlaylist.nxtThemeRestorePending = null;
    addLog(`DB NXT 테마 재검증 · 복원 ${restoredCount}건 · code 갱신 ${remappedCount}건 · 차단 ${response.rows.length - restoredCount}건`);
    if (failures.size) {
      const summary = [...failures.entries()]
        .map(([code, count]) => `${code} ${count}`)
        .join(" · ");
      addLog(`DB NXT 테마 차단 사유 · ${summary}`);
    }
    finalizeNamedManualRestores();
    renderPlaylist();
    updatePreview();
    renderNamedPlaylist();
    renderPlayout();
  }

  function handleNamedNxtThemeRestoreError(payload) {
    const record = state.namedPlaylist.nxtThemeRestorePending;
    if (!record || payload?.requestId !== record.requestId) return;
    const error = namedNxtThemeRestoreWorkflow.normalizeError(payload, record.requestId);
    failNamedNxtThemeRestore(
      record,
      error?.message || "NXT 테마의 현재 MariaDB identity 재검증을 완료하지 못했습니다.");
  }

  function requestNamedPlaylistPagePlans() {
    const workflow = state.namedPlaylist;
    if (!nativeBridge || isPlaylistSnapshotLocked() || workflow.pending ||
        workflow.pagePlanPending || !workflow.restoration) return;
    try {
      const request = namedPlaylistWorkflow.createPagePlanRequest(
        `named-page-plan-${createId()}`,
        workflow.restoration);
      if (!request) {
        workflow.pagePlanError = null;
        workflow.status = "loaded";
        renderNamedPlaylist();
        return;
      }
      workflow.pagePlanPending = {
        operation: "page-preflight",
        requestId: request.requestId,
        request,
        restoration: workflow.restoration
      };
      workflow.pagePlanError = null;
      workflow.error = "";
      workflow.status = "page-preflight";
      postNative(namedPlaylistWorkflow.pagePlanBridgeContract.request, request);
      renderNamedPlaylist();
      renderPlayout();
    } catch (error) {
      workflow.status = "error";
      workflow.pagePlanError = Object.freeze({
        code: "INVALID_PAGE_PREFLIGHT",
        message: error?.message || "페이지 조회 요청을 만들 수 없습니다.",
        retryable: false
      });
      workflow.error = workflow.pagePlanError.message;
      renderNamedPlaylist();
    }
  }

  function failNamedPagePlanProtocol(pending) {
    if (state.namedPlaylist.pagePlanPending !== pending) return;
    state.namedPlaylist.pagePlanPending = null;
    state.namedPlaylist.status = "error";
    state.namedPlaylist.pagePlanError = Object.freeze({
      code: "INVALID_RESPONSE",
      message: "Native Bridge가 현재 요청과 정확히 일치하는 페이지 계산 결과를 보내지 않았습니다.",
      retryable: true
    });
    state.namedPlaylist.error = state.namedPlaylist.pagePlanError.message;
    renderNamedPlaylist();
    renderPlayout();
  }

  function handleNamedPlaylistPagePlanResults(payload) {
    const pending = state.namedPlaylist.pagePlanPending;
    if (!pending || payload?.requestId !== pending.requestId) return;
    const result = namedPlaylistWorkflow.normalizePagePlanResponse(payload, pending.request);
    if (!result) return failNamedPagePlanProtocol(pending);
    if (state.namedPlaylist.restoration !== pending.restoration) return;
    try {
      const restoration = namedPlaylistWorkflow.applyPagePlanResponse(
        pending.restoration,
        result);
      const materialized = materializeNamedRestoration(restoration);
      state.namedPlaylist.restoration = restoration;
      state.namedPlaylist.guardByEntryId = materialized.guards;
      state.namedPlaylist.pagePlanPending = null;
      state.namedPlaylist.pagePlanError = null;
      state.namedPlaylist.status = "loaded";
      state.namedPlaylist.error = "";
      renderPlaylist();
      renderNamedPlaylist();
      renderPlayout();
      const empty = result.plans.filter(plan => plan.itemCount === 0).length;
      const truncated = result.plans.filter(plan => plan.isTruncated).length;
      addLog(`DB 페이지 사전조회 ${result.plans.length}개 완료 · 결과 없음 ${empty}개 · 20페이지 제한 ${truncated}개`);
      showToast(empty
        ? `페이지 조회를 완료했지만 ${empty}개 장면은 DB 결과가 없어 PREPARE할 수 없습니다.`
        : "현재 DB 결과로 페이지 계산을 완료했습니다.");
    } catch {
      failNamedPagePlanProtocol(pending);
    }
  }

  function handleNamedPlaylistPagePlanError(payload) {
    const pending = state.namedPlaylist.pagePlanPending;
    if (!pending || payload?.requestId !== pending.requestId) return;
    const error = namedPlaylistWorkflow.normalizePagePlanError(payload, pending.requestId);
    if (!error) return failNamedPagePlanProtocol(pending);
    state.namedPlaylist.pagePlanPending = null;
    state.namedPlaylist.status = "error";
    state.namedPlaylist.pagePlanError = error;
    state.namedPlaylist.error = `${error.message} [${error.code}] 자동 재시도하지 않습니다.`;
    renderNamedPlaylist();
    renderPlayout();
    addLog(`DB 페이지 사전조회 오류 · ${error.code}`);
    showToast(state.namedPlaylist.error);
  }

  function handleNamedPlaylistMutationResults(payload) {
    const result = namedPlaylistWorkflow.normalizeMutationResponse(payload);
    if (!result) {
      const operation = state.namedPlaylist.pending?.operation;
      if (namedWriteOperations.has(operation)) failNamedPlaylistProtocol(operation);
      return;
    }
    if (!namedPendingMatches(result.operation, result)) return;
    state.namedPlaylist.pending = null;
    state.namedPlaylist.status = "ready";
    state.namedPlaylist.writeQuarantined = state.namedPlaylist.writeQuarantined || result.writeQuarantined;
    if (result.writeQuarantined) state.namedPlaylist.canMutate = false;
    if (result.operation === "delete") {
      state.namedPlaylist.definitions = state.namedPlaylist.definitions.filter(
        value => value.programCode !== result.programCode);
      if (state.namedPlaylist.loadedDefinition?.programCode === result.programCode) {
        state.namedPlaylist.loadedDefinition = null;
      }
      state.namedPlaylist.selectedProgramCode = "";
    } else {
      state.namedPlaylist.selectAfterRefresh = result.programCode;
    }
    addLog(`DB 플레이리스트 ${result.operation.toLocaleUpperCase("en-US")} 완료 · ${result.programCode}`);
    showToast("DB 플레이리스트 작업이 완료되었습니다.");
    requestNamedPlaylistList();
  }

  function handleNamedPlaylistError(payload) {
    const error = namedPlaylistWorkflow.normalizeError(payload);
    const pending = state.namedPlaylist.pending;
    if (!error) return;
    if (!pending || error.requestId !== pending.requestId || error.operation !== pending.operation) {
      // A load result is posted before its correlated restore batches. If the
      // native read is then canceled (for example by playout priority), the
      // primary load is no longer pending but one or more restore batches can
      // still be waiting. Close every matching batch exactly once so PREPARE
      // stays blocked by the untrusted rows instead of an orphaned busy flag.
      if (error.operation !== "load") return;
      const restoreRecords = [
        state.namedPlaylist.comparisonRestorePending,
        state.namedPlaylist.foreignStockRestorePending,
        state.namedPlaylist.foreignIndexCandleRestorePending,
        state.namedPlaylist.krxThemeRestorePending,
        state.namedPlaylist.nxtThemeRestorePending
      ];
      if (!restoreRecords.some(record => record?.requestId === error.requestId)) return;
      if (state.namedPlaylist.comparisonRestorePending?.requestId === error.requestId) {
        state.namedPlaylist.comparisonRestorePending = null;
      }
      if (state.namedPlaylist.foreignStockRestorePending?.requestId === error.requestId) {
        clearNamedForeignStockRestoreTimeout(state.namedPlaylist.foreignStockRestorePending);
        state.namedPlaylist.foreignStockRestorePending = null;
      }
      if (state.namedPlaylist.foreignIndexCandleRestorePending?.requestId === error.requestId) {
        state.namedPlaylist.foreignIndexCandleRestorePending = null;
      }
      if (state.namedPlaylist.krxThemeRestorePending?.requestId === error.requestId) {
        state.namedPlaylist.krxThemeRestorePending = null;
      }
      if (state.namedPlaylist.nxtThemeRestorePending?.requestId === error.requestId) {
        state.namedPlaylist.nxtThemeRestorePending = null;
      }
      state.namedPlaylist.status = "error";
      state.namedPlaylist.error = `${error.message} [${error.code}] 복원 배치는 자동 재시도하지 않습니다.`;
      finalizeNamedManualRestores();
      renderPlaylist();
      renderNamedPlaylist();
      renderPlayout();
      addLog(`DB 플레이리스트 LOAD 후속 복원 종료 · ${error.code}`);
      showToast(state.namedPlaylist.error);
      return;
    }
    state.namedPlaylist.pending = null;
    state.namedPlaylist.status = "error";
    state.namedPlaylist.writeQuarantined = state.namedPlaylist.writeQuarantined || error.writeQuarantined;
    if (state.namedPlaylist.writeQuarantined) state.namedPlaylist.canMutate = false;
    state.namedPlaylist.error = `${error.message} [${error.code}]${error.outcomeUnknown ? " 결과를 확인할 수 없어 자동 재시도하지 않습니다." : ""}`;
    renderNamedPlaylist();
    addLog(`DB 플레이리스트 ${error.operation.toLocaleUpperCase("en-US")} 오류 · ${error.code}`);
    showToast(state.namedPlaylist.error);
  }

  function normalizePlayoutState(value) {
    const normalized = String(value || "").trim().toLocaleLowerCase("en-US").replace(/[\s_-]+/g, "");
    if (["prepared", "ready", "cued"].includes(normalized)) return "PREPARED";
    if (["program", "onair", "takein", "live"].includes(normalized)) return "PROGRAM";
    if (["dryrun", "preview", "simulation", "simulated"].includes(normalized)) return "DRY RUN";
    return "IDLE";
  }

  function normalizePlayoutMode(value) {
    return String(value || "unknown").trim().toLocaleLowerCase("en-US").replace(/[\s_]+/g, "-");
  }

  function normalizeScenePreviewFields(value) {
    if (!Array.isArray(value) || value.length > 4096) return [];
    return value.flatMap(field => {
      if (!field || typeof field !== "object") return [];
      const kind = typeof field.kind === "string" ? field.kind : "";
      const name = typeof field.name === "string" ? field.name : "";
      const text = typeof field.value === "string" ? field.value : "";
      if (!/^[a-z-]{1,32}$/.test(kind) || !name || name.length > 128 ||
          text.length > 16384 || /[\u0000\r\n]/.test(name + text)) return [];
      return [{ kind, name, value: text }];
    });
  }

  function applyRefreshState(payload) {
    state.playout.refreshActive = payload.refreshActive === true;
    state.playout.playCompletionPending = payload.playCompletionPending === true;
    state.playout.refreshNextAt = payload.refreshNextAt || null;
    state.playout.refreshLastSuccessAt = payload.refreshLastSuccessAt || null;
    state.playout.refreshFaulted = payload.refreshFaulted === true;
    state.playout.refreshFaultCode = typeof payload.refreshFaultCode === "string"
      ? payload.refreshFaultCode
      : null;
    state.playout.refreshFaultMessage = typeof payload.refreshFaultMessage === "string"
      ? payload.refreshFaultMessage
      : null;
    const refreshBudget = playoutSafety.normalizeRefreshBudget(payload);
    state.playout.refreshCompletedCount = refreshBudget.completed;
    state.playout.refreshMaximumCount = refreshBudget.maximum;
    state.playout.refreshLimitReached = refreshBudget.limitReached;
    if (state.playout.refreshFaulted && state.playout.error?.outcomeUnknown !== true) {
      state.playout.error = {
        code: state.playout.refreshFaultCode || "REFRESH_FAULT",
        message: state.playout.refreshFaultMessage ||
          "자동 장면 갱신이 중단되었습니다. TAKE OUT 후 실제 화면을 확인하세요.",
        retryable: false,
        outcomeUnknown: false,
        nonClearable: true,
        isRefreshFault: true
      };
    } else if (playoutSafety.shouldClearRefreshError(
        state.playout.error,
        state.playout.refreshFaulted)) {
      state.playout.error = null;
    }
  }

  function renderSceneDataPreview() {
    elements.sceneDataPreview.replaceChildren();
    if (state.playout.previewFields.length === 0) {
      const empty = document.createElement("div");
      empty.className = "scene-data-empty";
      empty.textContent = "PREPARE 후 실제 DTO → mutation 값을 표시합니다.";
      elements.sceneDataPreview.append(empty);
      return;
    }

    const fragment = document.createDocumentFragment();
    for (const field of state.playout.previewFields) {
      const row = document.createElement("div");
      row.className = "scene-data-row";
      const kind = document.createElement("span");
      kind.textContent = field.kind;
      const name = document.createElement("strong");
      name.textContent = field.name;
      const value = document.createElement("code");
      value.textContent = field.value;
      row.append(kind, name, value);
      fragment.append(row);
    }
    elements.sceneDataPreview.append(fragment);
  }

  function isDryRunMode(mode = state.playout.mode) {
    return /dry-?run|preview|simulat/.test(normalizePlayoutMode(mode));
  }

  function isLiveMode(mode = state.playout.mode) {
    return /live|program/.test(normalizePlayoutMode(mode));
  }

  function isTestMode(mode = state.playout.mode) {
    return /test/.test(normalizePlayoutMode(mode));
  }

  function playoutDisplayState() {
    if (isDryRunMode()) return "DRY RUN";
    if (isTestMode() && state.playout.engineState === "PROGRAM") return "TEST ON AIR";
    return state.playout.engineState;
  }

  function setStatusDot(element, status) {
    element.classList.remove("pending", "healthy", "error", "warn", "ok", "unconfigured");
    element.classList.add(status);
  }

  function cueFromItem(item) {
    if (!item) return null;
    return { code: String(item.code), title: String(item.title), detail: String(item.detail) };
  }

  function workflowEntryFromItem(item) {
    const selection = item?.selection && typeof item.selection === "object"
      ? item.selection
      : {
          groupCode: String(item?.groupCode || item?.market || ""),
          subject: String(item?.subject || item?.title || ""),
          graphicType: String(item?.graphicType || item?.detail || ""),
          subtype: String(item?.subtype || ""),
          dataCode: String(item?.dataCode || "")
        };
    return {
      id: String(item.id),
      code: String(item.code),
      enabled: item.enabled !== false,
      fadeDuration: Number.isInteger(Number(item.fadeDuration))
        ? Number(item.fadeDuration)
        : DEFAULT_FADE_DURATION,
      selection: {
        groupCode: String(selection.groupCode || ""),
        subject: String(selection.subject || ""),
        graphicType: String(selection.graphicType || ""),
        subtype: String(selection.subtype || ""),
        dataCode: String(selection.dataCode || "")
      }
    };
  }

  function findCueByCode(code) {
    if (!code) return null;
    const current = state.playlist[state.currentIndex];
    if (String(current?.code) === String(code)) return cueFromItem(current);
    return cueFromItem(state.playlist.find(item => String(item.code) === String(code)));
  }

  function renderPlayout() {
    const displayState = playoutDisplayState();
    const pending = state.playout.pending;
    const dryRun = isDryRunMode();
    const liveMode = isLiveMode();
    const testMode = isTestMode();
    const bridgeReady = Boolean(nativeBridge);
    const outcomeUnknown = state.playout.nativeOutcomeUnknown ||
      state.playout.sessionQuarantined ||
      state.playout.error?.outcomeUnknown === true;
    const commandReady = bridgeReady && !pending && !outcomeUnknown && state.playout.commandAvailable;
    const mutationCommandReady = commandReady && !state.playout.refreshFaulted;
    const namedPrepareBlocked = namedPlaylistBusy() ||
      namedPlaylistPrepareBlockers().length > 0;
    const candlePrepareBlocked = state.candleOptions.invalidIds.length > 0;
    const manualFinancialBlocked = manualFinancialPrepareBlocked();
    const currentCue = cueFromItem(state.playlist[state.currentIndex]);
    const prepared = state.playout.engineState === "PREPARED" || Boolean(state.playout.preparedCode);
    const onAir = state.playout.engineState === "PROGRAM" || Boolean(state.playout.onAirCode);
    const takeInSafe = dryRun || ((testMode || liveMode) && state.playout.liveTakeInAllowed);

    elements.playoutState.textContent = displayState;
    elements.playoutState.classList.remove("neutral", "live", "test-on-air", "dry-run", "error");
    if (displayState === "IDLE") elements.playoutState.classList.add("neutral");
    if (displayState === "PROGRAM") elements.playoutState.classList.add("live");
    if (displayState === "TEST ON AIR") elements.playoutState.classList.add("test-on-air");
    if (displayState === "DRY RUN") elements.playoutState.classList.add("dry-run");

    elements.tornadoProcessState.textContent = state.playout.processDetected ? "감지됨" : "미감지";
    setStatusDot(elements.tornadoProcessDot, state.playout.processDetected ? "healthy" : (bridgeReady ? "error" : "unconfigured"));
    const transitionalConnection = ["connecting", "reconnecting", "disconnecting"].includes(state.playout.connectionState);
    const connectionLabels = {
      "disabled": "Disabled",
      "dry-run-ready": "DryRunReady",
      "disconnected": "Disconnected",
      "connecting": "Connecting",
      "connected": "Connected",
      "reconnecting": "Reconnecting",
      "disconnecting": "Disconnecting",
      "faulted": "Faulted",
      "outcome-unknown": "OutcomeUnknown",
      "disposed": "Disposed",
      "unavailable": "Unavailable"
    };
    const lastKtapLabels = {
      "attempted": "마지막 KTAP 결과 미확인",
      "accepted-unconfirmed": "마지막 KTAP 수락 · OnHello 미확인",
      "failed": "마지막 KTAP 실패"
    };
    const currentConnectionLabel = connectionLabels[state.playout.connectionState] || "Unavailable";
    elements.playoutConnectionState.textContent = dryRun
      ? "KTAP 미호출"
      : (state.playout.connected && state.playout.lastKtapConnectState === "accepted-unconfirmed"
        ? "KTAP 수락 · OnHello 미확인"
        : `${currentConnectionLabel}${lastKtapLabels[state.playout.lastKtapConnectState]
          ? ` · ${lastKtapLabels[state.playout.lastKtapConnectState]}`
          : ""}`);
    setStatusDot(elements.playoutConnectionDot, state.playout.connected
      ? (state.playout.ktapHelloObserved === true ? "healthy" : "warn")
      : (transitionalConnection ? "warn" : (bridgeReady ? "error" : "unconfigured")));

    const safetyContainer = elements.playoutSafetyMode.closest(".playout-health-item");
    safetyContainer.classList.remove("live-allowed", "live-locked");
    if (dryRun) {
      elements.playoutSafetyMode.textContent = "DRY RUN · PROGRAM 차단";
    } else if (testMode && state.playout.liveTakeInAllowed) {
      elements.playoutSafetyMode.textContent = "TEST · 전용 출력 허용";
      safetyContainer.classList.add("live-allowed");
    } else if (testMode) {
      elements.playoutSafetyMode.textContent = "TEST TAKE IN 잠금";
      safetyContainer.classList.add("live-locked");
    } else if (liveMode && state.playout.liveTakeInAllowed) {
      elements.playoutSafetyMode.textContent = "LIVE 허용";
      safetyContainer.classList.add("live-allowed");
    } else if (liveMode) {
      elements.playoutSafetyMode.textContent = "LIVE TAKE IN 잠금";
      safetyContainer.classList.add("live-locked");
    } else {
      elements.playoutSafetyMode.textContent = bridgeReady ? "모드 확인 중" : "미리보기 전용";
    }

    const changedLabel = state.playout.changedAtMs
      ? ` · ${new Date(state.playout.changedAtMs).toLocaleTimeString("ko-KR", { hour12: false })}`
      : "";
    const pageLabel = state.playout.pageCount > 0
      ? ` · 페이지 ${state.playout.pageIndex + 1}/${state.playout.pageCount}`
      : "";
    elements.playoutStatusMessage.textContent = pending
      ? `${pending.command.toUpperCase()} 요청 처리 중 · ${pending.cue?.code || "송출 엔진"}`
      : `${state.playout.message || "송출 상태를 확인할 수 없습니다."}${pageLabel}${changedLabel}`;
    elements.playoutStatusMessage.classList.toggle("pending", Boolean(pending));
    elements.playoutStatusMessage.classList.toggle(
      "error",
      Boolean(state.playout.error) || state.playout.nativeOutcomeUnknown || state.playout.refreshFaulted);
    elements.playoutSceneCode.textContent = state.playout.onAirCode || state.playout.preparedCode || "—";
    elements.playoutEntryId.textContent = state.playout.currentEntryId || "—";
    elements.playoutBuilderKey.textContent = state.playout.builderKey || "—";
    elements.playoutPageNumber.textContent = state.playout.pageCount > 0
      ? `${state.playout.pageIndex + 1}/${state.playout.pageCount}`
      : "—";
    elements.playoutPageSize.textContent = state.playout.pageSize > 0
      ? String(state.playout.pageSize)
      : "—";
    elements.playoutItemCount.textContent = String(state.playout.itemCount);
    elements.playoutCurrentRows.textContent = String(state.playout.currentPageItemCount);
    elements.playoutLastPage.textContent = state.playout.isLastPage ? "YES" : "NO";
    elements.playoutNextKind.textContent = state.playout.nextKind.replaceAll("-", " ").toUpperCase();
    const refreshNextMs = parseChangedAt(state.playout.refreshNextAt);
    const refreshNextLabel = refreshNextMs
      ? new Date(refreshNextMs).toLocaleTimeString("ko-KR", { hour12: false })
      : "WAIT";
    elements.playoutRefreshState.textContent = playoutSafety.formatRefreshBudgetState(
      state.playout,
      refreshNextLabel);
    renderSceneDataPreview();

    elements.playoutSummary.classList.remove("ready", "dry-run", "error");
    if (outcomeUnknown) {
      elements.playoutSummary.classList.add("error");
      elements.playoutSummaryBadge.textContent = "UNKNOWN";
      elements.playoutSummaryDetail.textContent = "출력 상태 확인 후 앱 재시작 필요";
    } else if (state.playout.refreshFaulted) {
      elements.playoutSummary.classList.add("error");
      elements.playoutSummaryBadge.textContent = "REFRESH FAULT";
      elements.playoutSummaryDetail.textContent = "자동 갱신 중단 · TAKE OUT 후 확인 필요";
    } else if (dryRun) {
      elements.playoutSummary.classList.add("dry-run");
      elements.playoutSummaryBadge.textContent = "DRY RUN";
      elements.playoutSummaryDetail.textContent = bridgeReady ? "KTAP 미호출 · 모니터 기록 없음(정상)" : "브라우저 미리보기 전용";
    } else if (state.playout.connected) {
      elements.playoutSummary.classList.add("ready");
      elements.playoutSummaryBadge.textContent = testMode
        ? (state.playout.liveTakeInAllowed ? "TEST READY" : "TEST LOCKED")
        : (state.playout.liveTakeInAllowed ? "LIVE READY" : "CONNECTED");
      elements.playoutSummaryDetail.textContent = state.playout.lastKtapConnectState === "accepted-unconfirmed"
        ? "KTAP 수락 · OnHello/[R]/[S] 확인 필요"
        : (testMode
          ? "테스트 전용 출력 · 안전 게이트 적용"
          : (state.playout.processDetected ? "Tornado2 프로세스 · 엔진 연결됨" : "엔진 연결됨 · 프로세스 미감지"));
    } else {
      elements.playoutSummary.classList.add("error");
      elements.playoutSummaryBadge.textContent = bridgeReady ? "OFFLINE" : "PREVIEW";
      elements.playoutSummaryDetail.textContent = bridgeReady ? "Tornado 어댑터 연결 필요" : "Native Bridge 없음";
    }

    elements.prepareButton.disabled = !mutationCommandReady || !currentCue || namedPrepareBlocked ||
      candlePrepareBlocked || manualFinancialBlocked;
    elements.takeInButton.disabled = !mutationCommandReady || !currentCue || !takeInSafe || onAir;
    elements.nextButton.disabled = !mutationCommandReady || !onAir || state.playlist.length === 0 ||
      state.playout.nextKind === "end-of-playlist";
    elements.takeOutButton.disabled = !commandReady || (state.playout.engineState === "IDLE" && !state.playout.preparedCode && !state.playout.onAirCode);
    const playlistLocked = isPlaylistSnapshotLocked();
    const operatorLocked = operatorUiLocked();
    manualListsUi?.setLocked(operatorLocked);
    manualFinancialUi?.setLocked(operatorLocked);
    operatorCatalogUi?.setLocked(operatorLocked);
    elements.toggleAllEnabled.disabled = playlistLocked;
    elements.selectAll.disabled = playlistLocked;
    elements.moveUpButton.disabled = playlistLocked;
    elements.moveDownButton.disabled = playlistLocked;
    elements.deleteButton.disabled = playlistLocked;
    elements.deleteAllButton.disabled = playlistLocked;
    elements.loadButton.disabled = playlistLocked || namedPlaylistBusy();
    renderGlobalCandleOptions();
    renderNamedPlaylist();
    renderStockWorkflow();
    renderFixedWorkflow();
    renderIndustryWorkflow();
    renderComparisonWorkflow();
    renderThemeWorkflow();
    renderOverseasWorkflow();
    renderExpertWorkflow();
    renderTradingHaltWorkflow();
    renderBackgroundControls();
    renderCatalog();
    for (const button of [elements.prepareButton, elements.takeInButton, elements.nextButton, elements.takeOutButton]) {
      button.setAttribute("aria-busy", String(Boolean(pending)));
    }

    elements.playoutError.hidden = !state.playout.error;
    elements.playoutErrorMessage.textContent = state.playout.error?.message || "";
    document.querySelector("#clearPlayoutErrorButton").textContent = outcomeUnknown
      ? "닫기 · 잠금 유지"
      : "닫기";
  }

  function clearPlayoutError(render = true) {
    if (!playoutSafety.canClearCommandError(
        state.playout.error,
        state.playout.sessionQuarantined || state.playout.nativeOutcomeUnknown)) {
      if (render) showToast("결과 미확인 잠금은 앱을 다시 시작하기 전까지 해제할 수 없습니다.");
      return;
    }
    state.playout.error = null;
    if (render) renderPlayout();
  }

  function showPlayoutError({ code = "PLAYOUT_ERROR", message, retryable = false, outcomeUnknown = false }) {
    const details = [message || "송출 명령을 완료하지 못했습니다."];
    if (code) details.push(`[${code}]`);
    if (outcomeUnknown) details.push("실제 송출 결과를 확인하세요.");
    else if (retryable) details.push("상태 확인 후 재시도할 수 있습니다.");
    state.playout.error = { code, message: details.join(" "), retryable, outcomeUnknown };
    if (outcomeUnknown || code === "WEB_TIMEOUT") state.playout.sessionQuarantined = true;
    renderPlayout();
    showToast(state.playout.error.message);
  }

  function resolveCommand(command) {
    const current = state.playlist[state.currentIndex];
    if (command === "prepare") {
      return current ? { cue: cueFromItem(current), targetIndex: state.currentIndex } : null;
    }
    if (command === "take-in") {
      const cue = state.playout.preparedCue || findCueByCode(state.playout.preparedCode);
      return cue ? { cue, targetIndex: state.currentIndex } : null;
    }
    if (command === "next") {
      const cue = findCueByCode(state.playout.onAirCode) || current;
      return cue ? { cue: cueFromItem(cue), targetIndex: state.currentIndex } : null;
    }
    if (command === "take-out") return { cue: null, targetIndex: state.currentIndex };
    return null;
  }

  function requestPlayoutCommand(command) {
    if (!nativeBridge) {
      showToast("브라우저 미리보기에서는 송출 명령을 실행하지 않습니다.");
      return false;
    }
    if (state.playout.pending) return false;
    const blockReason = playoutSafety.commandBlockReason({
      pending: state.playout.pending,
      outcomeUnknown: state.playout.nativeOutcomeUnknown ||
        state.playout.sessionQuarantined ||
        state.playout.error?.outcomeUnknown === true,
      commandAvailable: state.playout.commandAvailable,
      refreshFaulted: state.playout.refreshFaulted,
      engineState: state.playout.engineState,
      preparedCode: state.playout.preparedCode,
      onAirCode: state.playout.onAirCode
    }, command);
    if (blockReason) {
      const message = {
        "outcome-unknown": "출력 상태가 불명확합니다. 실제 출력 확인 후 앱을 다시 시작하세요.",
        "unavailable": "Tornado 송출 명령을 사용할 수 없는 상태입니다.",
        "refresh-fault": "자동 장면 갱신이 중단되었습니다. TAKE OUT 후 실제 화면을 확인하세요.",
        "next-requires-on-air": "TAKE IN 이후에만 NEXT를 실행할 수 있습니다.",
        "take-in-requires-prepare": "먼저 PREPARE를 완료하세요.",
        "nothing-to-take-out": "종료할 송출 장면이 없습니다."
      }[blockReason] || "다른 송출 명령을 처리하고 있습니다.";
      showToast(message);
      return false;
    }

    if (command === "prepare") {
      if (namedPlaylistBusy()) {
        showToast("DB 플레이리스트 및 현재 페이지 조회가 끝난 뒤 PREPARE를 실행하세요.");
        return false;
      }
      const namedBlockers = namedPlaylistPrepareBlockers();
      if (namedBlockers.length) {
        state.namedPlaylist.open = true;
        renderNamedPlaylist();
        showToast("DB에서 불러온 행의 컷 복원 또는 현재 페이지 재계산이 끝나지 않아 PREPARE를 차단했습니다.");
        return false;
      }
      if (manualFinancialPrepareBlocked()) {
        showToast("수동 재무 항목의 현재 DB 행·종목·장면 선택 재검증이 끝나지 않아 PREPARE를 차단했습니다.");
        return false;
      }
      if (!synchronizeGlobalCandleOptions(false)) {
        showToast("신뢰할 수 없는 캔들 항목이 있어 PREPARE를 차단했습니다.");
        return false;
      }
      if (!synchronizeFixedEntries(false)) return false;
      if (!synchronizeIndustryEntries(false)) return false;
      if (!synchronizeComparisonEntries()) return false;
      if (!synchronizeThemeEntries()) return false;
      if (!synchronizeOverseasEntries()) return false;
      if (!synchronizeExpertEntries()) return false;
      if (!synchronizeTradingHaltEntries()) return false;
    }

    const resolved = resolveCommand(command);
    if (!resolved) {
      const message = command === "take-in" ? "먼저 PREPARE를 완료하세요." : "먼저 그래픽 항목을 선택하세요.";
      showToast(message);
      return false;
    }

    const requestId = createId();
    const pending = {
      requestId,
      command,
      cue: resolved.cue,
      targetIndex: resolved.targetIndex,
      timeoutId: null
    };
    state.playout.pending = pending;
    clearPlayoutError(false);
    renderPlayout();
    addLog(`${resolved.cue?.code || "ENGINE"} ${command.toUpperCase()} 요청`);

    const payload = { requestId, command };
    if (command === "prepare") {
      payload.playlist = state.playlist.map(workflowEntryFromItem);
      payload.selectedIndex = state.currentIndex;
    }
    postNative("playout-command", payload);

    pending.timeoutId = setTimeout(() => {
      if (state.playout.pending?.requestId !== requestId) return;
      state.playout.pending = null;
      if (command === "prepare") state.playout.takeInAfterPrepare = false;
      postNative("playout-timeout-quarantine", { requestId, command });
      showPlayoutError({
        code: "WEB_TIMEOUT",
        message: "송출 엔진 응답 시간이 초과되었습니다.",
        retryable: false,
        outcomeUnknown: true
      });
      addLog(`${command.toUpperCase()} 응답 시간 초과 · 결과 미확인`);
      requestPlayoutStatus();
    }, state.playout.responseTimeoutMs);
    return true;
  }

  function requestLegacyTakeInShortcut() {
    const action = playoutSafety.legacyTakeInShortcutAction({
      pending: Boolean(state.playout.pending),
      takeInAllowed: isDryRunMode() ||
        ((isTestMode() || isLiveMode()) && state.playout.liveTakeInAllowed),
      engineState: state.playout.engineState,
      preparedCode: state.playout.preparedCode,
      onAirCode: state.playout.onAirCode
    });
    if (action === "blocked") return false;
    if (action === "take-in-locked") {
      showToast("현재 검증 회차는 TAKE IN 승인이 잠겨 있어 PREPARE를 자동 실행하지 않습니다.");
      return false;
    }
    if (action === "next-required") {
      showToast("현재 장면이 송출 중입니다. 다음 장면은 NEXT를 사용하세요.");
      return false;
    }
    if (action === "take-in") {
      state.playout.takeInAfterPrepare = false;
      return requestPlayoutCommand("take-in");
    }

    state.playout.takeInAfterPrepare = true;
    const started = requestPlayoutCommand("prepare");
    if (!started) state.playout.takeInAfterPrepare = false;
    else addLog("F8/TAKE IN · PREPARE 성공 후 1회 TAKE IN 예약");
    return started;
  }

  function renderBackgroundControls() {
    const background = state.background;
    const locked = isPlaylistSnapshotLocked();
    elements.backgroundFileName.textContent = background.enabled
      ? (background.fileName || "선택된 배경")
      : "배경 없음";
    elements.backgroundStateText.textContent = background.error || background.message ||
      (background.enabled ? `${background.kind.toLocaleUpperCase("en-US")} 배경 사용` : "배경 사용 안 함");
    elements.chooseBackgroundButton.disabled = !nativeBridge || locked ||
      background.pending || !background.canChange;
    elements.toggleBackgroundButton.disabled = !nativeBridge || locked ||
      background.pending || !background.canChange;
    elements.chooseBackgroundButton.setAttribute("aria-busy", String(background.pending));
    elements.toggleBackgroundButton.setAttribute("aria-busy", String(background.pending));
  }

  function beginBackgroundRequest(type) {
    if (!nativeBridge) {
      showToast("브라우저 미리보기에서는 네이티브 배경 파일을 선택할 수 없습니다.");
      return false;
    }
    if (isPlaylistSnapshotLocked() || state.background.pending || !state.background.canChange) {
      showToast("배경은 IDLE 상태에서 다음 PREPARE 전에만 변경할 수 있습니다.");
      return false;
    }
    const requestId = createId();
    state.background.requestId = requestId;
    state.background.pending = true;
    state.background.error = "";
    state.background.message = type === "choose-background"
      ? "배경 파일 선택 창을 기다리고 있습니다."
      : "배경 사용 상태를 변경하고 있습니다.";
    renderBackgroundControls();
    postNative(type, { requestId });
    return true;
  }

  function requestBackgroundStatus() {
    if (!nativeBridge) {
      state.background.canChange = false;
      state.background.pending = false;
      state.background.message = "브라우저 미리보기 · 배경 변경 없음";
      renderBackgroundControls();
      return;
    }
    const requestId = createId();
    state.background.requestId = requestId;
    postNative("request-background-status", { requestId });
  }

  function requestBackgroundSelection() {
    return beginBackgroundRequest("choose-background");
  }

  function requestBackgroundToggle() {
    return beginBackgroundRequest("toggle-background");
  }

  function notifyLegacyBackgroundShortcut() {
    return requestBackgroundSelection();
  }

  function handleBackgroundStatus(payload) {
    if (!payload || typeof payload !== "object" ||
        (payload.requestId && payload.requestId !== state.background.requestId)) return;
    const kind = payload.kind;
    const fileName = payload.fileName;
    const message = payload.message;
    const validText = value => typeof value === "string" && value.length <= 260 &&
      !/[\u0000-\u001f\u007f]/.test(value);
    if (typeof payload.enabled !== "boolean" ||
        !["none", "texture", "video"].includes(kind) ||
        !validText(fileName) || !validText(message) ||
        typeof payload.canChange !== "boolean" ||
        (payload.enabled && (kind === "none" || !fileName)) ||
        (!payload.enabled && kind !== "none")) {
      state.background.pending = false;
      state.background.error = "배경 상태 응답 형식이 올바르지 않습니다.";
      renderBackgroundControls();
      return;
    }
    Object.assign(state.background, {
      enabled: payload.enabled,
      kind,
      fileName,
      canChange: payload.canChange,
      pending: false,
      message,
      error: ""
    });
    renderBackgroundControls();
  }

  function handleBackgroundError(payload) {
    if (!payload || typeof payload !== "object" ||
        payload.requestId !== state.background.requestId) return;
    const message = typeof payload.message === "string" && payload.message.length <= 512 &&
      !/[\u0000-\u001f\u007f]/.test(payload.message)
      ? payload.message
      : "배경 설정을 변경하지 못했습니다.";
    state.background.pending = false;
    state.background.error = message;
    renderBackgroundControls();
    showToast(message);
  }

  function requestPlayoutStatus() {
    if (!nativeBridge) return;
    const requestId = createId();
    state.playout.latestStatusRequestId = requestId;
    postNative("request-playout-status", { requestId });
  }

  function parseChangedAt(value) {
    const parsed = typeof value === "string" || typeof value === "number" ? Date.parse(value) : NaN;
    return Number.isFinite(parsed) ? parsed : 0;
  }

  function handlePlayoutStatus(payload) {
    if (!payload || typeof payload !== "object") return;
    if (payload.requestId && payload.requestId !== state.playout.latestStatusRequestId) return;
    const previousCueIndex = state.currentIndex;
    const previousSnapshotLocked = isPlaylistSnapshotLocked();

    const changedAtMs = parseChangedAt(payload.changedAt);
    if (changedAtMs && state.playout.changedAtMs && changedAtMs < state.playout.changedAtMs) return;

    state.playout.mode = normalizePlayoutMode(payload.mode);
    state.playout.engineState = normalizePlayoutState(payload.state);
    state.playout.connectionState = playoutSafety.normalizeConnectionState(payload.connectionState);
    state.playout.processDetected = payload.processDetected === true;
    state.playout.connected = payload.connected === true;
    state.playout.lastKtapConnectState = ["not-attempted", "attempted", "accepted-unconfirmed", "failed"]
      .includes(payload.lastKtapConnectState) ? payload.lastKtapConnectState : "not-attempted";
    state.playout.ktapConnectAttempted = payload.ktapConnectAttempted === true;
    state.playout.ktapConnectAccepted = payload.ktapConnectAccepted === true
      ? true
      : (payload.ktapConnectAccepted === false ? false : null);
    state.playout.ktapHelloObserved = payload.ktapHelloObserved === true
      ? true
      : (payload.ktapHelloObserved === false ? false : null);
    state.playout.networkMonitoringRecordExpected = payload.networkMonitoringRecordExpected === true
      ? true
      : (payload.networkMonitoringRecordExpected === false ? false : null);
    state.playout.networkMonitoringCheckRequired = payload.networkMonitoringCheckRequired === true;
    state.playout.commandAvailable = payload.commandAvailable === true;
    state.playout.liveTakeInAllowed = payload.liveTakeInAllowed === true;
    state.playout.browserCorrelationQuarantined =
      payload.browserCorrelationQuarantined === true;
    state.playout.nativeOutcomeUnknown = payload.outcomeUnknown === true ||
      state.playout.browserCorrelationQuarantined ||
      state.playout.connectionState === "outcome-unknown";
    state.playout.operationTimeoutMs = Number.isFinite(Number(payload.operationTimeoutMilliseconds))
      ? Number(payload.operationTimeoutMilliseconds)
      : state.playout.operationTimeoutMs;
    state.playout.responseTimeoutMs = playoutSafety.responseTimeoutFromNative(payload.operationTimeoutMilliseconds);
    state.playout.message = typeof payload.message === "string" && payload.message.trim()
      ? payload.message.trim()
      : "Tornado 송출 상태를 수신했습니다.";
    state.playout.preparedCode = payload.preparedCode ? String(payload.preparedCode) : null;
    state.playout.onAirCode = payload.onAirCode ? String(payload.onAirCode) : null;
    state.playout.preparedCue = findCueByCode(state.playout.preparedCode);
    state.playout.currentCueIndex = Number.isInteger(payload.currentCueIndex) ? payload.currentCueIndex : -1;
    state.playout.currentEntryId = typeof payload.currentEntryId === "string"
      ? payload.currentEntryId
      : null;
    state.playout.builderKey = /^s[0-9]+$/.test(String(payload.builderKey || ""))
      ? String(payload.builderKey)
      : null;
    state.playout.pageIndex = Number.isInteger(payload.pageIndex) && payload.pageIndex >= 0 ? payload.pageIndex : 0;
    state.playout.pageCount = Number.isInteger(payload.pageCount) && payload.pageCount >= 0 ? payload.pageCount : 0;
    state.playout.pageSize = [0, 5, 6, 12].includes(payload.pageSize) ? payload.pageSize : 0;
    state.playout.itemCount = Number.isInteger(payload.itemCount) && payload.itemCount >= 0 ? payload.itemCount : 0;
    state.playout.currentPageItemCount = Number.isInteger(payload.currentPageItemCount) &&
      payload.currentPageItemCount >= 0 ? payload.currentPageItemCount : 0;
    state.playout.isLastPage = payload.isLastPage !== false;
    state.playout.nextKind = ["none", "page-next", "playlist-next", "end-of-playlist"]
      .includes(payload.nextKind) ? payload.nextKind : "none";
    state.playout.previewFields = normalizeScenePreviewFields(payload.previewFields);
    applyRefreshState(payload);
    const authoritativeIndex = state.playout.currentEntryId
      ? state.playlist.findIndex(item => item.id === state.playout.currentEntryId)
      : state.playout.currentCueIndex;
    if (authoritativeIndex >= 0 && authoritativeIndex < state.playlist.length) {
      state.currentIndex = authoritativeIndex;
    }
    state.playout.changedAt = payload.changedAt || null;
    state.playout.changedAtMs = changedAtMs || state.playout.changedAtMs;
    if (payload.requestId === state.playout.latestStatusRequestId) state.playout.latestStatusRequestId = null;

    if (state.playout.nativeOutcomeUnknown && state.playout.error?.outcomeUnknown !== true) {
      state.playout.error = {
        code: "OUTCOME_UNKNOWN",
        message: "송출 결과를 확인할 수 없습니다. 실제 출력 확인 후 앱을 다시 시작하세요.",
        retryable: false,
        outcomeUnknown: true
      };
    }

    if (state.currentIndex !== previousCueIndex ||
        isPlaylistSnapshotLocked() !== previousSnapshotLocked) {
      renderPlaylist();
      updatePreview();
    } else {
      renderPlayout();
    }
    const signature = [state.playout.mode, state.playout.engineState, state.playout.connectionState, state.playout.processDetected, state.playout.connected, state.playout.lastKtapConnectState, state.playout.commandAvailable, state.playout.liveTakeInAllowed, state.playout.nativeOutcomeUnknown].join("|");
    if (signature !== state.playout.lastSignature) {
      addLog(`Tornado 상태 · ${playoutDisplayState()} · ${state.playout.connected ? "연결" : "미연결"}`);
      state.playout.lastSignature = signature;
    }
  }

  function finishPendingCommand(payload) {
    const pending = state.playout.pending;
    if (!playoutSafety.matchesPendingCommand(pending, payload)) return null;
    clearTimeout(pending.timeoutId);
    state.playout.pending = null;
    return pending;
  }

  function handlePlayoutCommandResult(payload) {
    const pending = finishPendingCommand(payload);
    if (!pending) return;
    const takeInAfterPrepare = pending.command === "prepare" && state.playout.takeInAfterPrepare;
    if (pending.command === "prepare") state.playout.takeInAfterPrepare = false;
    if (payload.succeeded !== true) {
      showPlayoutError({ message: payload.message || "송출 명령이 거부되었습니다.", retryable: true });
      addLog(`${pending.command.toUpperCase()} 실패 · ${payload.message || "명령 거부"}`);
      requestPlayoutStatus();
      return;
    }

    clearPlayoutError(false);
    if (payload.dryRun === true) state.playout.mode = "dry-run";
    state.playout.engineState = normalizePlayoutState(payload.state);
    state.playout.message = typeof payload.message === "string" && payload.message.trim()
      ? payload.message.trim()
      : `${pending.command.toUpperCase()} 완료`;
    state.playout.preparedCode = payload.preparedCode ? String(payload.preparedCode) : null;
    state.playout.onAirCode = payload.onAirCode ? String(payload.onAirCode) : null;
    state.playout.currentCueIndex = Number.isInteger(payload.currentCueIndex)
      ? payload.currentCueIndex
      : state.playout.currentCueIndex;
    state.playout.currentEntryId = typeof payload.currentEntryId === "string"
      ? payload.currentEntryId
      : state.playout.currentEntryId;
    state.playout.builderKey = /^s[0-9]+$/.test(String(payload.builderKey || ""))
      ? String(payload.builderKey)
      : state.playout.builderKey;
    state.playout.pageIndex = Number.isInteger(payload.pageIndex) ? payload.pageIndex : state.playout.pageIndex;
    state.playout.pageCount = Number.isInteger(payload.pageCount) ? payload.pageCount : state.playout.pageCount;
    state.playout.pageSize = [0, 5, 6, 12].includes(payload.pageSize) ? payload.pageSize : state.playout.pageSize;
    state.playout.itemCount = Number.isInteger(payload.itemCount) ? payload.itemCount : state.playout.itemCount;
    state.playout.currentPageItemCount = Number.isInteger(payload.currentPageItemCount)
      ? payload.currentPageItemCount
      : state.playout.currentPageItemCount;
    state.playout.isLastPage = payload.isLastPage !== false;
    state.playout.nextKind = ["none", "page-next", "playlist-next", "end-of-playlist"]
      .includes(payload.nextKind) ? payload.nextKind : state.playout.nextKind;
    state.playout.previewFields = normalizeScenePreviewFields(payload.previewFields);
    applyRefreshState(payload);

    if (pending.command === "prepare") {
      state.playout.preparedCue = pending.cue;
    } else if (!state.playout.preparedCode) {
      state.playout.preparedCue = null;
    }
    const authoritativeIndex = state.playout.currentEntryId
      ? state.playlist.findIndex(item => item.id === state.playout.currentEntryId)
      : state.playout.currentCueIndex;
    if (authoritativeIndex >= 0 && authoritativeIndex < state.playlist.length) {
      state.currentIndex = authoritativeIndex;
    }
    renderPlaylist();
    updatePreview();

    renderPlayout();
    const safetyLabel = payload.dryRun === true ? "DRY RUN · " : "";
    addLog(`${pending.cue?.code || "ENGINE"} ${safetyLabel}${pending.command.toUpperCase()} 완료`);
    showToast(state.playout.message);
    if (takeInAfterPrepare) {
      addLog("PREPARE 확인 · 예약된 TAKE IN 1회 실행");
      setTimeout(() => requestPlayoutCommand("take-in"), 0);
    } else {
      requestPlayoutStatus();
    }
  }

  function handlePlayoutCommandError(payload) {
    const pending = finishPendingCommand(payload);
    if (!pending) return;
    if (pending.command === "prepare") state.playout.takeInAfterPrepare = false;
    if (payload.outcomeUnknown === true) state.playout.nativeOutcomeUnknown = true;
    showPlayoutError({
      code: payload.code,
      message: payload.message,
      retryable: payload.retryable === true,
      outcomeUnknown: payload.outcomeUnknown === true
    });
    addLog(`${pending.command.toUpperCase()} 오류 · ${payload.code || "PLAYOUT_ERROR"} · ${payload.message || "명령 실패"}`);
    requestPlayoutStatus();
  }

  function addLog(message) {
    const item = document.createElement("li");
    const time = document.createElement("time");
    time.textContent = new Date().toLocaleTimeString("ko-KR", { hour12: false });
    item.append(time, document.createTextNode(message));
    elements.eventLog.prepend(item);
    while (elements.eventLog.children.length > 30) elements.eventLog.lastElementChild?.remove();
  }

  let toastTimer;
  function showToast(message) {
    elements.toast.textContent = message;
    elements.toast.classList.add("show");
    clearTimeout(toastTimer);
    toastTimer = setTimeout(() => elements.toast.classList.remove("show"), 2600);
  }

  function postNative(type, payload = {}) {
    nativeBridge?.postMessage({ type, payload });
  }

  function normalizeSourceName(source) {
    const value = String(source || "").toLocaleLowerCase("en-US");
    if (value === "oracle") return "oracle";
    if (value === "mariadb" || value === "maria_db" || value === "maria-db") return "mariaDb";
    return String(source || "unknown");
  }

  function normalizedDatabaseSources(sources) {
    const received = new Map((Array.isArray(sources) ? sources : []).map(source => [normalizeSourceName(source?.source), source]));
    return ["oracle", "mariaDb"].map(name => {
      const source = received.get(name);
      return {
        source: name,
        configured: Boolean(source?.configured),
        healthy: Boolean(source?.healthy),
        message: typeof source?.message === "string" ? source.message : "",
        checkedAt: source?.checkedAt || null,
        latencyMs: source?.latencyMs !== null && source?.latencyMs !== undefined && Number.isFinite(Number(source.latencyMs))
          ? Number(source.latencyMs)
          : null
      };
    });
  }

  function sourceHealthClass(source) {
    if (state.database.loading && !state.database.sources.length) return "pending";
    if (!source.configured) return "unconfigured";
    return source.healthy ? "healthy" : "error";
  }

  function sourceHealthText(source) {
    if (state.database.loading && !state.database.sources.length) return "확인 중";
    if (!source.configured) return "미설정";
    if (!source.healthy) return "연결 오류";
    return source.latencyMs === null ? "정상" : `정상 · ${Math.round(source.latencyMs)}ms`;
  }

  function renderDatabaseSources() {
    elements.databaseSources.replaceChildren();
    const sources = state.database.sources.length
      ? state.database.sources
      : normalizedDatabaseSources([]);

    for (const source of sources) {
      const chip = document.createElement("div");
      const healthClass = sourceHealthClass(source);
      chip.className = `database-source ${healthClass}`;
      if (source.message) chip.title = source.message;

      const dot = document.createElement("span");
      dot.className = "source-dot";
      const label = document.createElement("strong");
      label.textContent = sourceLabels[source.source] || source.source;
      const status = document.createElement("small");
      status.textContent = sourceHealthText(source);
      chip.append(dot, label, status);
      elements.databaseSources.append(chip);
    }
  }

  function renderDatabaseStatus() {
    const sources = state.database.sources;
    const configured = sources.filter(source => source.configured);
    const healthy = configured.filter(source => source.healthy);
    let stateClass = "pending";
    let stateText = "확인 중";
    let detail = "연결 상태 확인 중";
    let badge = "CHECKING";

    if (!state.database.loading || sources.length) {
      if (!configured.length) {
        stateClass = "unconfigured";
        stateText = "미설정";
        detail = "Oracle 미설정 · MariaDB 미설정";
        badge = "SETUP";
      } else if (healthy.length === sources.length && sources.length === 2) {
        stateClass = "healthy";
        stateText = "정상 (2/2)";
        detail = sources.map(source => `${sourceLabels[source.source]} ${sourceHealthText(source)}`).join(" · ");
        badge = "CONNECTED";
      } else if (configured.some(source => !source.healthy)) {
        stateClass = "error";
        stateText = `오류 (${healthy.length}/${configured.length})`;
        detail = sources.map(source => `${sourceLabels[source.source]} ${sourceHealthText(source)}`).join(" · ");
        badge = "ERROR";
      } else {
        stateClass = "partial";
        stateText = `일부 연결 (${healthy.length}/2)`;
        detail = sources.map(source => `${sourceLabels[source.source]} ${sourceHealthText(source)}`).join(" · ");
        badge = "PARTIAL";
      }
    }

    elements.databaseState.textContent = stateText;
    elements.databaseStateDot.className = `status-dot ${stateClass}`;
    elements.databaseSummary.className = `database-summary ${stateClass}`;
    elements.databaseSummaryIcon.className = `metric-icon database-icon ${stateClass}`;
    elements.databaseSummaryDetail.textContent = detail;
    elements.databaseSummaryBadge.textContent = badge;
    renderDatabaseSources();
  }

  function requestDatabaseStatus() {
    if (!nativeBridge) return;
    state.database.loading = true;
    renderDatabaseStatus();
    postNative("request-database-status", { requestId: createId() });
  }

  function handleDatabaseStatus(payload) {
    const sequence = payload?.sequence;
    if (!playoutSafety.isNewerDatabaseStatusSequence(
        state.database.latestSequence,
        sequence)) return;

    state.database.latestSequence = sequence;
    state.database.sources = normalizedDatabaseSources(payload?.sources);
    state.database.loading = false;
    renderDatabaseStatus();

    const signature = state.database.sources.map(source => `${source.source}:${source.configured}:${source.healthy}`).join("|");
    if (signature !== state.database.lastSignature) {
      const message = state.database.sources
        .map(source => `${sourceLabels[source.source]} ${sourceHealthText(source)}`)
        .join(" · ");
      addLog(`DB 상태 · ${message}`);
      state.database.lastSignature = signature;
    }
  }

  function clearLiveDataTimeout() {
    if (state.liveData.timeoutId !== null) clearTimeout(state.liveData.timeoutId);
    state.liveData.timeoutId = null;
  }

  function formatDateTime(value) {
    if (!value) return "";
    const date = new Date(value);
    if (Number.isNaN(date.getTime())) return String(value);
    return new Intl.DateTimeFormat("ko-KR", {
      month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false
    }).format(date);
  }

  function normalizeColumns(table) {
    let columns = Array.isArray(table?.columns) ? table.columns : [];
    const firstRow = Array.isArray(table?.rows) ? table.rows[0] : null;
    if (!columns.length && firstRow && !Array.isArray(firstRow) && typeof firstRow === "object") {
      columns = Object.keys(firstRow);
    }
    if (!columns.length && Array.isArray(firstRow)) {
      columns = firstRow.map((_, index) => `열 ${index + 1}`);
    }
    return columns.map((column, index) => {
      if (typeof column === "string") return { key: column, label: column };
      const key = column?.key ?? column?.name ?? column?.field ?? column?.dataPropertyName ?? `column-${index}`;
      const label = column?.label ?? column?.displayName ?? column?.title ?? column?.name ?? key;
      return { key: String(key), label: String(label) };
    });
  }

  function cellValue(row, column, index) {
    if (Array.isArray(row)) return row[index];
    if (row && typeof row === "object") return row[column.key];
    return index === 0 ? row : null;
  }

  function formatCellValue(value) {
    if (value === null || value === undefined || value === "") return "—";
    if (typeof value === "number") return value.toLocaleString("ko-KR");
    if (typeof value === "boolean") return value ? "예" : "아니요";
    if (typeof value === "object") {
      try { return JSON.stringify(value); } catch { return String(value); }
    }
    return String(value);
  }

  function liveRowValues(row, columns) {
    const values = new Map();
    columns.forEach((column, index) => {
      values.set(String(column.key).toLocaleUpperCase("en-US"), cellValue(row, column, index));
    });
    return values;
  }

  function firstLiveValue(values, keys) {
    for (const key of keys) {
      const value = values.get(key);
      if (value !== null && value !== undefined && String(value).trim()) return String(value).trim();
    }
    return "";
  }

  function liveSelectionGroup(table, item) {
    const name = String(table?.name || "").toLocaleUpperCase("ko-KR");
    let group;
    if (name.includes("NXT") && state.activeMarket === "kospi") group = "NXT_KOSPI";
    else if (name.includes("NXT") && state.activeMarket === "kosdaq") group = "NXT_KOSDAQ";
    else if (state.activeMarket === "kospi") group = "KOSPI_STOCK";
    else if (state.activeMarket === "kosdaq") group = "KOSDAQ_STOCK";
    else if (state.activeMarket === "overseas") group = name.includes("종목") ? "FOREIGN_STOCK" : "FOREIGN_INDEX";
    else if (name.includes("코스닥")) group = "KOSDAQ_INDEX";
    else if (name.includes("200")) group = "KOSPI200_INDEX";
    else if (name.includes("KRX100")) group = "KRX100_INDEX";
    else group = "KOSPI_INDEX";

    if (["s5037", "s5079", "s5080", "s8003"].includes(item?.builderKey)) {
      if (group === "KOSPI_STOCK") return "KOSPI";
      if (group === "KOSDAQ_STOCK") return "KOSDAQ";
    }
    return group;
  }

  function applyLiveRowToCurrentCut(table, row, columns) {
    if (!requirePlaylistEditable()) return;
    const item = state.playlist[state.currentIndex];
    if (!item) {
      showToast("먼저 적용할 플레이리스트 장면을 선택하세요.");
      return;
    }
    if (playlistEntryManualEditBlockReason(item)) {
      showToast("검증된 항목에는 DB 행을 직접 덮어쓸 수 없습니다. 원본 입력 화면에서 다시 생성하세요.");
      return;
    }

    const values = liveRowValues(row, columns);
    const subject = firstLiveValue(values, [
      "F_STOCK_WANNAME", "F_STOCK_NAME", "STOCK_NAME", "F_KNAM",
      "F_INPUT_NAME", "NAME", "WANNAME"
    ]);
    const dataCode = firstLiveValue(values, [
      "F_STOCK_CODE", "STOCK_CODE", "F_SYMB", "SYMB", "F_PART_CODE", "CODE"
    ]);
    if (!subject && !dataCode) {
      showToast("이 행에는 장면 조회에 사용할 종목명이나 코드가 없습니다.");
      return;
    }

    const previousSelection = item.selection || {};
    const replacement = recreatePlaylistEntryWithLiveSelection(item, {
      groupCode: liveSelectionGroup(table, item),
      subject: subject || dataCode,
      graphicType: String(previousSelection.graphicType || item.graphicType || ""),
      subtype: String(previousSelection.subtype || item.subtype || ""),
      dataCode
    });
    if (!replacement || !replacePlaylistEntryAt(state.currentIndex, replacement)) {
      showToast("선택한 DB 행을 현재 장면에 안전하게 적용할 수 없습니다.");
      return;
    }
    renderPlaylist();
    updatePreview();
    addLog(`${replacement.code} DB 선택 적용 · ${replacement.selection.subject}`);
    showToast("선택한 DB 행을 현재 장면에 적용했습니다.");
  }

  function renderLiveTables() {
    elements.liveDataTables.replaceChildren();
    for (const table of state.liveData.tables) {
      const section = document.createElement("section");
      section.className = "live-table-card";

      const header = document.createElement("header");
      const heading = document.createElement("div");
      const name = document.createElement("h4");
      name.textContent = table?.name || "시장 데이터";
      const meta = document.createElement("small");
      const rows = Array.isArray(table?.rows) ? table.rows : [];
      const total = Number.isFinite(Number(table?.totalRowCount)) ? Number(table.totalRowCount) : rows.length;
      meta.textContent = `전체 ${total.toLocaleString("ko-KR")}건 · 표시 ${rows.length.toLocaleString("ko-KR")}건`;
      heading.append(name, meta);

      const source = document.createElement("span");
      source.className = "live-source-badge";
      const sourceName = normalizeSourceName(table?.source);
      source.textContent = sourceLabels[sourceName] || table?.source || "DB";
      header.append(heading, source);
      section.append(header);

      if (!rows.length) {
        const empty = document.createElement("p");
        empty.className = "live-table-empty";
        empty.textContent = "조회된 행이 없습니다.";
        section.append(empty);
        elements.liveDataTables.append(section);
        continue;
      }

      const columns = normalizeColumns(table);
      const scroll = document.createElement("div");
      scroll.className = "live-table-scroll";
      const grid = document.createElement("table");
      grid.className = "live-table";
      const head = document.createElement("thead");
      const headRow = document.createElement("tr");
      for (const column of columns) {
        const th = document.createElement("th");
        th.textContent = column.label;
        headRow.append(th);
      }
      head.append(headRow);

      const body = document.createElement("tbody");
      for (const row of rows) {
        const tr = document.createElement("tr");
        tr.title = "시장 현황 조회 전용입니다. 종목 컷은 위 종목 검색에서 선택하세요.";
        columns.forEach((column, index) => {
          const td = document.createElement("td");
          td.textContent = formatCellValue(cellValue(row, column, index));
          tr.append(td);
        });
        body.append(tr);
      }
      grid.append(head, body);
      scroll.append(grid);
      section.append(scroll);
      elements.liveDataTables.append(section);
    }
  }

  function renderLiveData() {
    const visible = liveDataViews.has(state.activeMarket);
    elements.liveDataPanel.hidden = !visible;
    elements.catalogModeBadge.textContent = visible ? "DB LIVE" : "Web UI";
    elements.catalogModeBadge.classList.toggle("live", visible);
    if (!visible) return;

    elements.liveDataTitle.textContent = `${marketTitles[state.activeMarket]} · DB 조회`;
    elements.liveDataState.replaceChildren();
    elements.liveDataState.className = `live-data-state ${state.liveData.status}`;

    if (state.liveData.status === "loading") {
      const spinner = document.createElement("span");
      spinner.className = "live-spinner";
      const copy = document.createElement("div");
      const strong = document.createElement("strong");
      const small = document.createElement("small");
      strong.textContent = "데이터베이스에서 조회 중입니다.";
      small.textContent = "연결 상태와 시장 데이터를 확인하고 있습니다.";
      copy.append(strong, small);
      elements.liveDataState.append(spinner, copy);
    } else if (state.liveData.status === "error") {
      const icon = document.createElement("span");
      icon.className = "live-error-icon";
      icon.textContent = "!";
      const copy = document.createElement("div");
      const strong = document.createElement("strong");
      const small = document.createElement("small");
      strong.textContent = "시장 데이터를 조회하지 못했습니다.";
      small.textContent = state.liveData.error || "데이터베이스 연결을 확인한 뒤 다시 조회하세요.";
      copy.append(strong, small);
      elements.liveDataState.append(icon, copy);
    } else if (state.liveData.status === "ready") {
      const totalRows = state.liveData.tables.reduce((sum, table) => {
        const count = Number(table?.totalRowCount);
        return sum + (Number.isFinite(count) ? count : (Array.isArray(table?.rows) ? table.rows.length : 0));
      }, 0);
      const strong = document.createElement("strong");
      const small = document.createElement("small");
      strong.textContent = state.liveData.tables.length
        ? `${state.liveData.tables.length}개 데이터셋 · 전체 ${totalRows.toLocaleString("ko-KR")}건`
        : "조회 결과가 없습니다.";
      small.textContent = state.liveData.retrievedAt ? `조회 시각 ${formatDateTime(state.liveData.retrievedAt)}` : "조회 완료";
      elements.liveDataState.append(strong, small);
    }

    renderDatabaseSources();
    renderLiveTables();
  }

  function requestMarketData(view = state.activeMarket) {
    clearLiveDataTimeout();
    if (!liveDataViews.has(view)) {
      state.liveData.requestId = null;
      state.liveData.view = null;
      state.liveData.status = "idle";
      state.liveData.tables = [];
      renderLiveData();
      return;
    }

    const requestId = createId();
    state.liveData.requestId = requestId;
    state.liveData.view = view;
    state.liveData.status = "loading";
    state.liveData.tables = [];
    state.liveData.retrievedAt = null;
    state.liveData.error = "";
    renderLiveData();

    if (!nativeBridge) {
      state.liveData.status = "error";
      state.liveData.error = "브라우저 미리보기에서는 실제 DB 데이터를 조회할 수 없습니다.";
      renderLiveData();
      return;
    }

    postNative("request-market-data", { requestId, view, maximumRowsPerTable: 250 });
    state.liveData.timeoutId = setTimeout(() => {
      if (state.liveData.requestId !== requestId || state.liveData.status !== "loading") return;
      state.liveData.status = "error";
      state.liveData.error = "DB 응답 시간이 초과되었습니다. 연결 상태를 확인하고 다시 조회하세요.";
      renderLiveData();
      addLog(`${marketTitles[view]} DB 조회 시간 초과`);
    }, 30000);
  }

  function handleMarketData(payload) {
    if (!payload || payload.requestId !== state.liveData.requestId || payload.view !== state.liveData.view) return;
    clearLiveDataTimeout();
    state.liveData.status = "ready";
    state.liveData.tables = Array.isArray(payload.tables) ? payload.tables : [];
    state.liveData.retrievedAt = payload.retrievedAt || new Date().toISOString();
    state.liveData.error = "";
    renderLiveData();
    const shownRows = state.liveData.tables.reduce((sum, table) => sum + (Array.isArray(table?.rows) ? table.rows.length : 0), 0);
    addLog(`${marketTitles[payload.view]} DB 조회 완료 · ${shownRows.toLocaleString("ko-KR")}행 표시`);
  }

  function handleMarketDataError(payload) {
    if (!payload || payload.requestId !== state.liveData.requestId || payload.view !== state.liveData.view) return;
    clearLiveDataTimeout();
    state.liveData.status = "error";
    state.liveData.tables = [];
    state.liveData.error = typeof payload.message === "string" && payload.message.trim()
      ? payload.message.trim()
      : "데이터베이스 연결을 확인한 뒤 다시 조회하세요.";
    renderLiveData();
    addLog(`${marketTitles[payload.view]} DB 조회 실패 · ${state.liveData.error}`);
  }

  function handleBridgeError(payload) {
    const rawMessage = typeof payload?.message === "string" ? payload.message.trim() : "";
    const message = rawMessage && rawMessage.length <= 512 &&
      !/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u.test(rawMessage)
      ? rawMessage
      : "Native Bridge가 올바르지 않은 요청을 거부했습니다.";
    addLog(`Native Bridge 요청 오류 · ${message}`);
    showToast(message);
  }

  function handleNativeMessage(event) {
    let message = event.data;
    if (typeof message === "string") {
      try { message = JSON.parse(message); } catch { return; }
    }
    if (!message || typeof message.type !== "string") return;

    if (manualFinancialUi?.handleMessage(message.type, message.payload)) return;
    if (manualListsUi?.handleMessage(message.type, message.payload)) return;
    if (operatorCatalogUi?.handleMessage(message.type, message.payload)) return;

    switch (message.type) {
      case "bridge-error":
        handleBridgeError(message.payload);
        break;
      case "app-info": {
        const info = message.payload || {};
        elements.bridgeState.textContent = "연결됨";
        elements.runtimeLabel.textContent = `${info.framework || ".NET 8"} · ${info.architecture || "x64"}`;
        elements.packageLabel.textContent = `${info.package || "MSIX"}${info.version ? ` · v${info.version}` : ""}`;
        addLog(`Native Bridge 연결${info.windowsAppSdk ? ` · Windows App SDK ${info.windowsAppSdk}` : ""}`);
        break;
      }
      case "database-status":
        handleDatabaseStatus(message.payload);
        break;
      case "market-data":
        handleMarketData(message.payload);
        break;
      case "market-data-error":
        handleMarketDataError(message.payload);
        break;
      case "stock-search-results":
        if (!handleManualFinancialRestoreStockResults(message.payload) &&
            !handleComparisonDomesticResults(message.payload)) handleStockSearchResults(message.payload);
        break;
      case "stock-search-error":
        if (!handleManualFinancialRestoreStockError(message.payload) &&
            !handleComparisonDomesticError(message.payload)) handleStockSearchError(message.payload);
        break;
      case "manual-financial-load-results":
        handleManualFinancialRestoreLoadResults(message.payload);
        break;
      case "manual-financial-load-error":
        handleManualFinancialRestoreLoadError(message.payload);
        break;
      case "manual-financial-selection-results":
        handleManualFinancialRestoreSelectionResults(message.payload);
        break;
      case "manual-financial-selection-error":
        handleManualFinancialRestoreSelectionError(message.payload);
        break;
      case "manual-financial-request-error":
        handleManualFinancialRestoreRequestError(message.payload);
        break;
      case "manual-net-sell-data":
      case "vi-manual-list-data":
        handleNamedManualListResults(message.payload);
        break;
      case "manual-operator-data-error":
        handleNamedManualListError(message.payload);
        break;
      case "world-stock-search-results":
        if (!handleOverseasSearchResults("stock", message.payload)) {
          handleComparisonWorldResults(message.payload);
        }
        break;
      case "world-stock-search-error":
        if (!handleOverseasSearchError("stock", message.payload)) {
          handleComparisonWorldError(message.payload);
        }
        break;
      case "legacy-comparison-import-results":
        handleLegacyComparisonImportResults(message.payload);
        break;
      case "legacy-comparison-import-error":
        handleLegacyComparisonImportError(message.payload);
        break;
      case "overseas-industry-results":
        handleOverseasSearchResults("industry", message.payload);
        break;
      case "overseas-industry-error":
        handleOverseasSearchError("industry", message.payload);
        break;
      case "expert-search-results":
        handleExpertSearchResults(message.payload);
        break;
      case "expert-search-error":
        handleExpertSearchError(message.payload);
        break;
      case "expert-preview-results":
        handleExpertPreviewResults(message.payload);
        break;
      case "expert-preview-error":
        handleExpertPreviewError(message.payload);
        break;
      case "theme-search-results":
        handleThemeSearchResults(message.payload);
        break;
      case "theme-search-error":
        handleThemeSearchError(message.payload);
        break;
      case "theme-preview-results":
        handleThemePreviewResults(message.payload);
        break;
      case "theme-preview-error":
        handleThemePreviewError(message.payload);
        break;
      case "trading-halt-results":
        handleTradingHaltResults(message.payload);
        break;
      case "trading-halt-error":
        handleTradingHaltError(message.payload);
        break;
      case "background-status":
        handleBackgroundStatus(message.payload);
        break;
      case "background-error":
        handleBackgroundError(message.payload);
        break;
      case "industry-results":
        handleIndustryResults(message.payload);
        break;
      case "industry-error":
        handleIndustryError(message.payload);
        break;
      case "named-playlist-list-results":
        handleNamedPlaylistListResults(message.payload);
        break;
      case "named-playlist-load-results":
        handleNamedPlaylistLoadResults(message.payload);
        break;
      case "named-comparison-restore-results":
        handleNamedComparisonRestoreResults(message.payload);
        break;
      case "named-comparison-restore-error":
        handleNamedComparisonRestoreError(message.payload);
        break;
      case "named-foreign-stock-restore-results":
        handleNamedForeignStockRestoreResults(message.payload);
        break;
      case "named-foreign-stock-restore-error":
        handleNamedForeignStockRestoreError(message.payload);
        break;
      case "named-foreign-index-candle-restore-results":
        handleNamedForeignIndexCandleRestoreResults(message.payload);
        break;
      case "named-foreign-index-candle-restore-error":
        handleNamedForeignIndexCandleRestoreError(message.payload);
        break;
      case "named-krx-theme-restore-results":
        handleNamedKrxThemeRestoreResults(message.payload);
        break;
      case "named-krx-theme-restore-error":
        handleNamedKrxThemeRestoreError(message.payload);
        break;
      case "named-nxt-theme-restore-results":
        handleNamedNxtThemeRestoreResults(message.payload);
        break;
      case "named-nxt-theme-restore-error":
        handleNamedNxtThemeRestoreError(message.payload);
        break;
      case "named-playlist-mutation-results":
        handleNamedPlaylistMutationResults(message.payload);
        break;
      case "named-playlist-error":
        handleNamedPlaylistError(message.payload);
        break;
      case "playlist-page-plans-results":
        handleNamedPlaylistPagePlanResults(message.payload);
        break;
      case "playlist-page-plans-error":
        handleNamedPlaylistPagePlanError(message.payload);
        break;
      case "playout-status":
        handlePlayoutStatus(message.payload);
        break;
      case "playout-command-result":
        handlePlayoutCommandResult(message.payload);
        break;
      case "playout-command-error":
        handlePlayoutCommandError(message.payload);
        break;
    }
  }

  function bindEvents() {
    const marketNav = document.querySelector("#marketNav");
    const marketNavReorderController = marketNavReorder.createController({
      nav: marketNav,
      isBlocked: () => hasOpenOperatorModal() || isPlaylistSnapshotLocked()
    });
    if (!marketNavReorderController.mount()) {
      throw new Error("Market-nav identity contract is invalid.");
    }

    marketNav.addEventListener("click", event => {
      const button = event.target.closest("button[data-market]");
      if (!button) return;
      const previousMarket = state.activeMarket;
      document.querySelectorAll(".nav-item").forEach(item => item.classList.toggle("active", item === button));
      state.activeMarket = button.dataset.market;
      if (previousMarket !== state.activeMarket && industryWorkflow.markets.includes(state.activeMarket)) {
        state.industryWorkflowCollapsed = false;
      }
      elements.workspaceTitle.textContent = marketTitles[state.activeMarket];
      renderStockWorkflow();
      renderFixedWorkflow();
      renderIndustryWorkflow();
      renderComparisonWorkflow();
      renderThemeWorkflow();
      renderOverseasWorkflow();
      renderExpertWorkflow();
      renderTradingHaltWorkflow();
      renderCatalog();
      requestMarketData(state.activeMarket);
      if (industryWorkflow.markets.includes(state.activeMarket)) requestIndustries(state.activeMarket);
      if (state.activeMarket === "theme" && state.theme.search.status === "idle") requestThemeSearch();
      if (state.activeMarket === "foreign-stock" && currentOverseasStore().status === "idle") requestOverseasSearch();
      if (state.activeMarket === "expert" && state.expert.search.status === "idle") requestExpertSearch();
      if (state.activeMarket === "halted" && state.tradingHalt.status === "idle") requestTradingHaltSearch();
      if (databaseBackedViews.has(state.activeMarket)) requestDatabaseStatus();
    });

    elements.catalogTabs.addEventListener("click", event => {
      const button = event.target.closest("button[data-category]");
      if (!button) return;
      elements.catalogTabs.querySelectorAll("button").forEach(item => item.classList.toggle("active", item === button));
      state.activeCategory = button.dataset.category;
      renderCatalog();
    });

    elements.stockSearchForm.addEventListener("submit", requestStockSearch);
    elements.stockCutBatchAdd.addEventListener("click", addSelectedStockCuts);
    elements.stockCutSelectionClear.addEventListener("click", clearStockCutSelection);
    elements.stockWorkflowToggle.addEventListener("click", () => {
      state.stockWorkflowCollapsed = !state.stockWorkflowCollapsed;
      renderStockWorkflow();
    });
    elements.legacyFixedToggle.addEventListener("click", () => {
      state.fixedWorkflowCollapsed = !state.fixedWorkflowCollapsed;
      renderFixedWorkflow();
    });
    elements.legacyFixedSearch.addEventListener("input", event => {
      state.fixedSearch = event.target.value;
      renderFixedWorkflow();
    });
    elements.industryRetry.addEventListener("click", () => requestIndustries(state.activeMarket));
    elements.industryWorkflowToggle.addEventListener("click", () => {
      state.industryWorkflowCollapsed = !state.industryWorkflowCollapsed;
      renderIndustryWorkflow();
    });
    elements.industrySearch.addEventListener("input", event => {
      state.industry.search = event.target.value;
      renderIndustryWorkflow();
    });
    elements.industrySwap.addEventListener("click", () => {
      if (isPlaylistSnapshotLocked() || !state.industry.pairFirst || !state.industry.pairSecond) return;
      [state.industry.pairFirst, state.industry.pairSecond] =
        [state.industry.pairSecond, state.industry.pairFirst];
      renderIndustryWorkflow();
      addLog("업종 비교 순서 교환");
    });
    elements.industryClear.addEventListener("click", () => {
      if (isPlaylistSnapshotLocked()) return;
      state.industry.highlighted = null;
      state.industry.pairFirst = null;
      state.industry.pairSecond = null;
      renderIndustryWorkflow();
    });
    elements.comparisonSourceTabs.addEventListener("click", event => {
      const button = event.target.closest("button[data-comparison-source]");
      if (!button || isPlaylistSnapshotLocked()) return;
      state.comparison.source = button.dataset.comparisonSource;
      renderComparisonWorkflow();
    });
    elements.comparisonDomesticSearchForm.addEventListener("submit", requestComparisonDomesticSearch);
    elements.comparisonWorldSearchForm.addEventListener("submit", requestComparisonWorldSearch);
    elements.comparisonImportLegacy.addEventListener("click", requestLegacyComparisonImport);
    elements.comparisonSwap.addEventListener("click", () => {
      if (isPlaylistSnapshotLocked()) return;
      const swapped = comparisonWorkflow.swapPair([
        state.comparison.draftFirst,
        state.comparison.draftSecond
      ]);
      if (!swapped) return;
      [state.comparison.draftFirst, state.comparison.draftSecond] = swapped;
      renderComparisonWorkflow();
    });
    elements.comparisonClear.addEventListener("click", () => {
      if (isPlaylistSnapshotLocked()) return;
      [state.comparison.draftFirst, state.comparison.draftSecond] = comparisonWorkflow.clearPair();
      renderComparisonWorkflow();
    });
    elements.comparisonSavePair.addEventListener("click", saveComparisonDraftPair);
    elements.comparisonMoveUp.addEventListener("click", () => moveComparisonPair("up"));
    elements.comparisonMoveDown.addEventListener("click", () => moveComparisonPair("down"));
    elements.comparisonDeletePair.addEventListener("click", deleteSelectedComparisonPair);
    elements.comparisonDeleteAll.addEventListener("click", deleteAllComparisonPairs);
    elements.comparisonYieldBatch.addEventListener("click", addComparisonYieldBatch);
    elements.themeSearchForm.addEventListener("submit", requestThemeSearch);
    elements.themeNxtSession.addEventListener("change", () => {
      if (isPlaylistSnapshotLocked()) return;
      state.theme.nxtSession = elements.themeNxtSession.value;
      requestThemeSearch();
    });
    elements.themeSort.addEventListener("change", () => {
      const sort = themeWorkflow.normalizeThemeSort(elements.themeSort.value);
      if (!sort || isPlaylistSnapshotLocked()) return;
      state.theme.sort = sort.id;
      renderThemeWorkflow();
    });
    elements.themeCatalogManageButton.addEventListener("click", () => {
      const context = state.theme.preview.catalogContext;
      operatorCatalogUi?.openTheme(context || undefined);
    });
    elements.overseasSourceTabs.addEventListener("click", event => {
      const button = event.target.closest("button[data-overseas-source]");
      if (!button || isPlaylistSnapshotLocked()) return;
      state.overseas.source = button.dataset.overseasSource;
      elements.overseasSearch.value = currentOverseasStore().request?.query || "";
      renderOverseasWorkflow();
      if (currentOverseasStore().status === "idle") requestOverseasSearch();
    });
    elements.overseasSearchForm.addEventListener("submit", requestOverseasSearch);
    elements.expertSearchForm.addEventListener("submit", requestExpertSearch);
    elements.expertCatalogManageButton.addEventListener("click", () => {
      const context = state.expert.preview.catalogContext;
      operatorCatalogUi?.openExpert(context || undefined);
    });
    elements.tradingHaltSearchForm.addEventListener("submit", requestTradingHaltSearch);
    elements.tradingHaltNameSort.addEventListener("click", toggleTradingHaltNameSort);
    elements.globalMa5.addEventListener("change", () =>
      applyGlobalCandleOptions(elements.globalMa5.checked, elements.globalMa20.checked, true));
    elements.globalMa20.addEventListener("change", () =>
      applyGlobalCandleOptions(elements.globalMa5.checked, elements.globalMa20.checked, true));
    elements.stockMa5.addEventListener("change", () => synchronizeStockCandleOptions(true));
    elements.stockMa20.addEventListener("change", () => synchronizeStockCandleOptions(true));
    elements.overseasMa5.addEventListener("change", () =>
      applyGlobalCandleOptions(elements.overseasMa5.checked, elements.overseasMa20.checked, true));
    elements.overseasMa20.addEventListener("change", () =>
      applyGlobalCandleOptions(elements.overseasMa5.checked, elements.overseasMa20.checked, true));
    elements.tradingHaltMa5.addEventListener("change", () =>
      applyGlobalCandleOptions(elements.tradingHaltMa5.checked, elements.tradingHaltMa20.checked, true));
    elements.tradingHaltMa20.addEventListener("change", () =>
      applyGlobalCandleOptions(elements.tradingHaltMa5.checked, elements.tradingHaltMa20.checked, true));
    elements.catalogSearch.addEventListener("input", event => { state.search = event.target.value; renderCatalog(); });
    elements.toggleAllEnabled.addEventListener("click", toggleAllEntriesEnabled);
    document.querySelector("#selectAllButton").addEventListener("click", toggleSelectAll);
    document.querySelector("#moveUpButton").addEventListener("click", () => moveSelected(-1));
    document.querySelector("#moveDownButton").addEventListener("click", () => moveSelected(1));
    document.querySelector("#deleteButton").addEventListener("click", deleteSelected);
    elements.deleteAllButton.addEventListener("click", deleteAllPlaylistEntries);
    elements.saveButton.addEventListener("click", savePlaylist);
    elements.loadButton.addEventListener("click", loadPlaylist);
    elements.namedPlaylistOpenButton.addEventListener("click", () => {
      state.namedPlaylist.open = !state.namedPlaylist.open;
      renderNamedPlaylist();
      if (state.namedPlaylist.open && !namedPlaylistBusy() && !state.namedPlaylist.definitions.length) {
        requestNamedPlaylistList();
      }
    });
    elements.namedPlaylistCloseButton.addEventListener("click", () => {
      state.namedPlaylist.open = false;
      renderNamedPlaylist();
    });
    elements.namedPlaylistRefreshButton.addEventListener("click", requestNamedPlaylistList);
    elements.namedPlaylistDefinitions.addEventListener("change", () => {
      state.namedPlaylist.selectedProgramCode = elements.namedPlaylistDefinitions.value;
      renderNamedPlaylist();
    });
    elements.namedPlaylistLoadButton.addEventListener("click", requestNamedPlaylistLoad);
    elements.namedPlaylistPageRetryButton.addEventListener("click", requestNamedPlaylistPagePlans);
    elements.namedPlaylistRestoreBackupButton.addEventListener("click", restoreNamedPlaylistBackup);
    elements.namedPlaylistCreateButton.addEventListener("click", requestNamedPlaylistCreate);
    elements.namedPlaylistReplaceButton.addEventListener("click", requestNamedPlaylistReplace);
    elements.namedPlaylistDeleteButton.addEventListener("click", requestNamedPlaylistDelete);
    elements.sceneSelectionForm.addEventListener("submit", applySceneSelection);
    elements.sceneCutAlias.addEventListener("change", applyCutAliasPreset);
    elements.chooseBackgroundButton.addEventListener("click", requestBackgroundSelection);
    elements.toggleBackgroundButton.addEventListener("click", requestBackgroundToggle);
    elements.prepareButton.addEventListener("click", () => requestPlayoutCommand("prepare"));
    elements.takeInButton.addEventListener("click", requestLegacyTakeInShortcut);
    elements.nextButton.addEventListener("click", () => requestPlayoutCommand("next"));
    elements.takeOutButton.addEventListener("click", () => requestPlayoutCommand("take-out"));
    document.querySelector("#clearPlayoutErrorButton").addEventListener("click", () => clearPlayoutError());
    document.querySelector("#clearLogButton").addEventListener("click", () => elements.eventLog.replaceChildren());
    document.querySelector("#requestInfoButton").addEventListener("click", () => {
      postNative("request-app-info");
      requestDatabaseStatus();
      requestPlayoutStatus();
      requestBackgroundStatus();
      if (liveDataViews.has(state.activeMarket)) requestMarketData(state.activeMarket);
      if (industryWorkflow.markets.includes(state.activeMarket)) requestIndustries(state.activeMarket);
      if (state.activeMarket === "foreign-stock") requestOverseasSearch();
      if (state.activeMarket === "expert") requestExpertSearch();
      if (state.activeMarket === "halted") requestTradingHaltSearch();
    });
    document.querySelector("#retryLiveDataButton").addEventListener("click", () => {
      requestDatabaseStatus();
      requestMarketData(state.activeMarket);
    });

    function hasOpenOperatorModal() {
      return [...document.querySelectorAll(
        'dialog[open], [role="dialog"][aria-modal="true"]')]
        .some(element => !element.hidden && !element.closest("[hidden]"));
    }

    function isolateOperatorModalShortcut(event) {
      if (!hasOpenOperatorModal()) return false;

      const key = String(event.key || "");
      const commandShortcut = ["F2", "F3", "F8", "Escape"].includes(key);
      const playlistShortcut = ["Home", "End", "Delete", " "].includes(key);
      const focusShortcut = event.ctrlKey && key.toLowerCase() === "k";
      if (commandShortcut || focusShortcut) {
        event.preventDefault();
      }
      if (commandShortcut || playlistShortcut || focusShortcut) {
        event.stopPropagation();
        if (!event.repeat && ["F8", "Escape"].includes(key)) {
          showToast("편집 창을 먼저 닫은 뒤 송출 명령을 실행하세요.");
        }
      }

      // Keep typing and native input editing inside the modal, but never let a key continue
      // into the workspace-level playout, playlist, or focus shortcuts below.
      return true;
    }

    document.addEventListener("keydown", event => {
      if (isolateOperatorModalShortcut(event)) return;
      if (["F2", "F3", "F8", "Escape"].includes(event.key)) {
        event.preventDefault();
        if (event.repeat) return;
        if (event.key === "F2") notifyLegacyBackgroundShortcut();
        else if (event.key === "F3") requestBackgroundToggle();
        else if (event.key === "F8") requestLegacyTakeInShortcut();
        else requestPlayoutCommand("take-out");
        return;
      }
      if (event.ctrlKey && event.key.toLowerCase() === "k") {
        event.preventDefault();
        const comparisonTarget = state.comparison.source === "world"
          ? elements.comparisonWorldSearch
          : (state.comparison.source === "domestic"
            ? elements.comparisonDomesticSearch
            : elements.comparisonFixedTargets.querySelector("button"));
        const target = !elements.tradingHaltWorkflow.hidden
          ? elements.tradingHaltSearch
          : (!elements.expertWorkflow.hidden
          ? elements.expertSearch
          : (!elements.overseasWorkflow.hidden
          ? elements.overseasSearch
          : (!elements.themeWorkflow.hidden
          ? elements.themeSearch
          : (!elements.comparisonWorkflow.hidden
          ? (comparisonTarget || elements.catalogSearch)
          : (!elements.industryWorkflow.hidden && !state.industryWorkflowCollapsed
          ? elements.industrySearch
          : (!elements.stockWorkflow.hidden && !state.stockWorkflowCollapsed
          ? elements.stockSearchInput
          : (!elements.legacyFixedWorkflow.hidden ? elements.legacyFixedSearch : elements.catalogSearch)))))));
        target.focus();
        return;
      }
      if (event.target instanceof HTMLInputElement ||
          event.target instanceof HTMLTextAreaElement ||
          event.target instanceof HTMLSelectElement) return;
      if (event.key === "Home") {
        event.preventDefault();
        selectPlaylistBoundary(0, event.shiftKey);
        return;
      }
      if (event.key === "End") {
        event.preventDefault();
        selectPlaylistBoundary(state.playlist.length - 1, event.shiftKey);
        return;
      }
      if (event.key === "Delete") { event.preventDefault(); deleteSelected(); }
      if (event.key === " ") { event.preventDefault(); toggleCurrentEntryEnabled(); }
    });
    window.addEventListener("pagehide", () => operatorCatalogUi?.loseCorrelation());
  }

  function initializeOperatorUiControllers() {
    const shared = {
      postNative: (type, payload) => postNative(type, payload),
      appendPlaylistEntry: appendTrustedOperatorEntry,
      isLocked: operatorUiLocked,
      showToast,
      addLog,
      createId
    };
    manualListsUi = manualListsUiModule.createController(shared);
    manualFinancialUi = manualFinancialUiModule.createController({
      ...shared,
      getSelectedStock: selectedStockForManualFinancial
    });
    operatorCatalogUi = operatorCatalogUiModule.createController({
      postNative: shared.postNative,
      isLocked: operatorUiLocked,
      showToast,
      addLog,
      createId
    });
    manualListsUi.mount(document.body);
    manualFinancialUi.mount(document.body);
    operatorCatalogUi.mount(document.body);
  }

  function initialize() {
    initializeOperatorUiControllers();
    bindEvents();
    loadComparisonPairList();
    renderCatalog();
    renderStockWorkflow();
    renderFixedWorkflow();
    renderIndustryWorkflow();
    renderComparisonWorkflow();
    renderThemeWorkflow();
    renderOverseasWorkflow();
    renderExpertWorkflow();
    renderTradingHaltWorkflow();
    renderBackgroundControls();
    renderPlaylist();
    renderNamedPlaylist();
    updatePreview();
    renderDatabaseStatus();
    renderLiveData();
    updateClock();
    setInterval(updateClock, 1000);
    addLog("WebView 운영 화면 시작");

    if (nativeBridge) {
      nativeBridge.addEventListener("message", handleNativeMessage);
      postNative("ready");
      postNative("request-app-info");
      requestDatabaseStatus();
      requestPlayoutStatus();
      requestBackgroundStatus();
    } else {
      elements.bridgeState.textContent = "브라우저 미리보기";
      state.database.loading = false;
      state.playout.mode = "preview-dry-run";
      state.playout.engineState = "IDLE";
      state.playout.message = "Native Bridge 없음 · 미리보기 전용 DRY RUN (PROGRAM 출력 차단)";
      renderPlayout();
      renderDatabaseStatus();
      addLog("Native Bridge 없이 미리보기 전용 DRY RUN으로 실행");
    }
    requestMarketData(state.activeMarket);
  }

  initialize();
})();
