(function (root, factory) {
  "use strict";

  const api = Object.freeze(factory(root));
  if (typeof module === "object" && module.exports) module.exports = api;
  if (root) root.MbnManualListsUi = api;
})(typeof globalThis === "object" ? globalThis : this, function (root) {
  "use strict";

  const NET_KEYS = Object.freeze(["leftName", "leftAmount", "rightName", "rightAmount"]);
  const STOCK_MARKETS = Object.freeze(["kospi", "kosdaq", "nxt-kospi", "nxt-kosdaq"]);
  const STOCK_SOURCES = Object.freeze(["oracle", "mariaDb"]);
  const MAXIMUM_STOCK_RESULTS = 200;
  const DEFAULT_FADE_DURATION = 6;
  const identifierPattern = /^[A-Za-z0-9_-]{1,128}$/;
  const stockCodePattern = /^[A-Za-z0-9._-]{1,63}$/;
  const unsafeTextPattern = /[\p{Cc}\p{Cf}\p{Cs}\p{Zl}\p{Zp}\uFFFD]/u;

  function exactKeys(value, keys) {
    if (!value || typeof value !== "object" || Array.isArray(value)) return false;
    const actual = Object.keys(value).sort();
    const expected = [...keys].sort();
    return actual.length === expected.length && actual.every((key, index) => key === expected[index]);
  }

  function safeText(value, maximumLength, allowEmpty = true) {
    return typeof value === "string" && value.length <= maximumLength &&
      (allowEmpty || value.length > 0) && !value.includes("^") && !unsafeTextPattern.test(value);
  }

  function copyRows(rows) {
    return rows.map(row => Object.freeze({
      leftName: row.leftName,
      leftAmount: row.leftAmount,
      rightName: row.rightName,
      rightAmount: row.rightAmount
    }));
  }

  function copyItems(items) {
    return items.map(item => Object.freeze({ code: item.code, name: item.name }));
  }

  function rowsEqual(left, right) {
    return Array.isArray(left) && Array.isArray(right) && left.length === right.length &&
      left.every((row, index) => NET_KEYS.every(key => row[key] === right[index][key]));
  }

  function itemsEqual(left, right) {
    return Array.isArray(left) && Array.isArray(right) && left.length === right.length &&
      left.every((item, index) => item.code === right[index].code && item.name === right[index].name);
  }

  function emptyRows() {
    return Array.from({ length: 5 }, () => ({
      leftName: "",
      leftAmount: "",
      rightName: "",
      rightAmount: ""
    }));
  }

  function resolveWorkflow() {
    if (root && root.MbnManualListsWorkflow) return root.MbnManualListsWorkflow;
    if (typeof require === "function") return require("./manual-lists-workflow.js");
    throw new Error("MbnManualListsWorkflow must be loaded before the manual-list UI.");
  }

  function normalizeStockResult(value) {
    if (!exactKeys(value, ["market", "source", "name", "code"]) ||
        !STOCK_MARKETS.includes(value.market) || !STOCK_SOURCES.includes(value.source) ||
        !safeText(value.name, 200, false) || value.name !== value.name.trim() || value.name.includes(",") ||
        typeof value.code !== "string" || !stockCodePattern.test(value.code)) return null;
    return Object.freeze({
      market: value.market,
      source: value.source,
      name: value.name,
      code: value.code
    });
  }

  function normalizeStockSearchResponse(value, expected) {
    if (!exactKeys(value, [
      "requestId", "query", "retrievedAt", "totalRowCount", "truncated", "results"
    ]) || !expected || value.requestId !== expected.requestId || value.query !== expected.query ||
        !identifierPattern.test(value.requestId) || !safeText(value.query, 100) ||
        typeof value.retrievedAt !== "string" || !Number.isFinite(Date.parse(value.retrievedAt)) ||
        !Number.isInteger(value.totalRowCount) || value.totalRowCount < 0 ||
        typeof value.truncated !== "boolean" || !Array.isArray(value.results) ||
        value.results.length > expected.maximumResults || value.totalRowCount < value.results.length) return null;
    const results = value.results.map(normalizeStockResult);
    if (!results.every(Boolean)) return null;
    const identities = new Set(results.map(item => `${item.market}\u0000${item.code}\u0000${item.name}`));
    if (identities.size !== results.length) return null;
    return Object.freeze({
      requestId: value.requestId,
      query: value.query,
      retrievedAt: value.retrievedAt,
      totalRowCount: value.totalRowCount,
      truncated: value.truncated,
      results: Object.freeze(results)
    });
  }

  function normalizeStockSearchError(value, expected) {
    if (!exactKeys(value, ["requestId", "query", "message"]) || !expected ||
        value.requestId !== expected.requestId || value.query !== expected.query ||
        !identifierPattern.test(value.requestId) || !safeText(value.query, 100) ||
        !safeText(value.message, 512, false)) return null;
    return Object.freeze({ ...value });
  }

  function createController(dependencies) {
    const workflow = resolveWorkflow();
    if (!exactKeys(dependencies, [
      "postNative", "appendPlaylistEntry", "isLocked", "showToast", "addLog", "createId"
    ]) || !Object.values(dependencies).every(value => typeof value === "function")) {
      throw new TypeError("Closed manual-list UI dependencies are required.");
    }

    const {
      postNative,
      appendPlaylistEntry,
      isLocked,
      showToast,
      addLog,
      createId
    } = dependencies;

    const state = {
      host: null,
      root: null,
      open: false,
      mode: null,
      manualLocked: false,
      available: null,
      unavailableCode: "",
      unavailableMessage: "",
      quarantined: false,
      quarantineMessage: "",
      audience: "INDIVIDUAL",
      netRows: emptyRows(),
      netRevision: 0,
      netVerified: null,
      viItems: [],
      viBaseVersion: null,
      viVersion: null,
      viRevision: 0,
      viVerified: null,
      searchQuery: "",
      searchResults: [],
      searchMessage: "",
      pending: {
        status: null,
        legacyImport: null,
        netRead: null,
        netSave: null,
        viRead: null,
        viSave: null,
        stockSearch: null
      }
    };

    function locked() {
      try {
        return state.manualLocked || Boolean(isLocked());
      } catch {
        return true;
      }
    }

    function canMutate() {
      return state.available === true && !state.pending.status && !state.pending.legacyImport &&
        !locked() && !state.quarantined;
    }

    function hasPendingManualDataOperation() {
      return Boolean(
        state.pending.status || state.pending.legacyImport ||
        state.pending.netRead || state.pending.netSave ||
        state.pending.viRead || state.pending.viSave);
    }

    function notify(message, kind = "info") {
      try { showToast(message, kind); } catch { /* host notification is best effort */ }
    }

    function log(message) {
      try { addLog(message); } catch { /* host logging is best effort */ }
    }

    function nextId() {
      const value = createId();
      if (typeof value !== "string" || !identifierPattern.test(value)) {
        throw new TypeError("createId returned an unsafe identifier.");
      }
      return value;
    }

    function send(request) {
      postNative(request.type, request.payload);
      return request.payload.requestId;
    }

    function requestStatus() {
      const requestId = nextId();
      state.pending.status = { requestId };
      send(workflow.createStatusRequest(requestId));
    }

    function importLegacyManualData() {
      if (!canMutate() || hasPendingManualDataOperation()) return false;
      const confirmation =
        "원본 프로젝트의 개인.dat, 외국인.dat, 기관.dat, VI발동.dat를 " +
        "앱 전용 폴더로 한 번만 가져옵니다. 기존 대상 파일이 하나라도 있으면 " +
        "아무것도 덮어쓰지 않고 중단합니다. 계속하시겠습니까?";
      let confirmed = false;
      try {
        confirmed = root && typeof root.confirm === "function" && root.confirm(confirmation) === true;
      } catch {
        confirmed = false;
      }
      if (!confirmed) return false;

      const request = workflow.createLegacyImportRequest(nextId());
      state.pending.legacyImport = { requestId: request.payload.requestId };
      state.netVerified = null;
      state.viVerified = null;
      send(request);
      render();
      return true;
    }

    function requestNetRead(audience, verification = null) {
      state.netVerified = null;
      const requestId = nextId();
      state.pending.netRead = {
        requestId,
        audience,
        revision: state.netRevision,
        verification
      };
      send(workflow.createNetSellReadRequest(requestId, audience));
    }

    function requestViRead(verification = null) {
      state.viVersion = null;
      state.viVerified = null;
      const requestId = nextId();
      state.pending.viRead = {
        requestId,
        revision: state.viRevision,
        verification
      };
      send(workflow.createViReadRequest(requestId));
    }

    function invalidateNetVerification() {
      state.netRevision += 1;
      state.netVerified = null;
    }

    function invalidateViVerification() {
      state.viRevision += 1;
      state.viVersion = null;
      state.viVerified = null;
    }

    function setAudience(audience) {
      const normalized = workflow.normalizeAudience(audience);
      if (!normalized) throw new RangeError("Unknown FSell audience.");
      state.audience = normalized;
      state.netRows = emptyRows();
      invalidateNetVerification();
      requestNetRead(normalized);
      render();
    }

    function setNetCell(rowIndex, key, value) {
      if (locked() || !Number.isInteger(rowIndex) || rowIndex < 0 || rowIndex >= 5 ||
          !NET_KEYS.includes(key) || typeof value !== "string" || value.length > 256) return false;
      state.netRows[rowIndex][key] = value;
      invalidateNetVerification();
      updateGateButtons();
      return true;
    }

    function saveNetSell() {
      if (!canMutate() || state.pending.netRead || state.pending.netSave) return false;
      let request;
      try {
        request = workflow.createNetSellSaveRequest(nextId(), state.audience, state.netRows);
      } catch {
        notify("순매도 입력 5행의 값을 확인하세요.", "error");
        return false;
      }
      const snapshot = copyRows(request.payload.rows);
      state.pending.netSave = {
        requestId: request.payload.requestId,
        audience: request.payload.audience,
        rows: snapshot,
        revision: state.netRevision
      };
      state.netVerified = null;
      send(request);
      render();
      return true;
    }

    function addNetSellCut() {
      const verified = state.netVerified;
      if (!canMutate() || !verified || verified.audience !== state.audience ||
          !rowsEqual(verified.rows, state.netRows)) return false;
      try {
        const entry = workflow.createNetSellPlaylistEntry(state.audience, {
          id: nextId(),
          fadeDuration: DEFAULT_FADE_DURATION
        }, verified.freshRead);
        appendPlaylistEntry(entry);
        notify("수동 순매도 컷을 playlist에 추가했습니다.", "success");
        log(`수동 순매도 컷 추가 · ${state.audience}`);
        return true;
      } catch {
        notify("수동 순매도 컷을 추가하지 못했습니다.", "error");
        return false;
      }
    }

    function searchStocks(query = state.searchQuery) {
      const normalizedQuery = typeof query === "string" ? query.trim() : "";
      if (!safeText(normalizedQuery, 100)) return false;
      const requestId = nextId();
      const request = {
        requestId,
        query: normalizedQuery,
        maximumResults: MAXIMUM_STOCK_RESULTS
      };
      state.searchQuery = normalizedQuery;
      state.searchResults = [];
      state.searchMessage = "검색 중…";
      state.pending.stockSearch = request;
      postNative("search-stocks", request);
      render();
      return true;
    }

    function toViItem(stock) {
      const prefix = stock.market.endsWith("kospi") ? "P" : "D";
      return workflow.normalizeViItem({ code: `${prefix}${stock.code}`, name: stock.name });
    }

    function addSearchResult(index) {
      if (locked() || !Number.isInteger(index) || index < 0 ||
          index >= state.searchResults.length || state.viItems.length >= workflow.maximumViItems) return false;
      const item = toViItem(state.searchResults[index]);
      if (!item) return false;
      state.viItems.push({ code: item.code, name: item.name });
      invalidateViVerification();
      render();
      return true;
    }

    function removeViItem(index) {
      if (locked() || !Number.isInteger(index) || index < 0 || index >= state.viItems.length) return false;
      state.viItems.splice(index, 1);
      invalidateViVerification();
      render();
      return true;
    }

    function moveViItem(index, delta) {
      if (locked() || !Number.isInteger(index) || ![-1, 1].includes(delta)) return false;
      const target = index + delta;
      if (index < 0 || index >= state.viItems.length || target < 0 || target >= state.viItems.length) return false;
      [state.viItems[index], state.viItems[target]] = [state.viItems[target], state.viItems[index]];
      invalidateViVerification();
      render();
      return true;
    }

    function clearViItems() {
      if (locked() || state.viItems.length === 0) return false;
      state.viItems = [];
      invalidateViVerification();
      render();
      return true;
    }

    function saveVi() {
      if (!canMutate() || !state.viBaseVersion || state.pending.viRead || state.pending.viSave) return false;
      let request;
      try {
        request = workflow.createViSaveRequest(nextId(), state.viItems, state.viBaseVersion);
      } catch {
        notify("VI 목록 값을 확인하세요.", "error");
        return false;
      }
      state.pending.viSave = {
        requestId: request.payload.requestId,
        expectedVersion: request.payload.expectedVersion,
        items: copyItems(request.payload.items),
        revision: state.viRevision
      };
      state.viVerified = null;
      send(request);
      render();
      return true;
    }

    function addViCut() {
      const verified = state.viVerified;
      if (!canMutate() || state.viItems.length === 0 || !verified ||
          verified.version !== state.viVersion || !itemsEqual(verified.items, state.viItems)) return false;
      try {
        const entry = workflow.createViPlaylistEntry(state.viItems, state.viVersion, {
          id: nextId(),
          fadeDuration: DEFAULT_FADE_DURATION
        }, verified.freshRead);
        appendPlaylistEntry(entry);
        notify("VI 발동 컷을 playlist에 추가했습니다.", "success");
        log(`VI 발동 컷 추가 · ${state.viItems.length}종목 · ${entry.pageCount}페이지`);
        return true;
      } catch {
        notify("최신 VI 목록을 다시 조회한 뒤 추가하세요.", "error");
        return false;
      }
    }

    function handleStatus(payload) {
      const pending = state.pending.status;
      if (!pending) return false;
      const normalized = workflow.normalizeStatusResponse(payload, pending.requestId);
      if (!normalized) return false;
      state.pending.status = null;
      state.available = normalized.available;
      state.unavailableCode = normalized.available ? "" : normalized.code;
      state.unavailableMessage = normalized.available ? "" : (normalized.message || "");
      if (normalized.writeQuarantined) {
        state.quarantined = true;
        state.quarantineMessage = normalized.message || "수동 저장 결과를 확인할 수 없습니다.";
      }
      render();
      return true;
    }

    function handleLegacyImport(payload) {
      const pending = state.pending.legacyImport;
      if (!pending) return false;
      const normalized = workflow.normalizeLegacyImportResponse(payload, pending.requestId);
      if (!normalized) return false;
      state.pending.legacyImport = null;
      state.netVerified = null;
      state.viVerified = null;
      notify(
        `원본 수동 데이터를 가져왔습니다. 순매도 ${normalized.netSellRowCount}행 · VI ${normalized.viItemCount}종목`,
        "success");
      log(`원본 수동 데이터 가져오기 완료 · marker v${normalized.markerVersion}`);
      if (state.mode === "net") requestNetRead(state.audience);
      if (state.mode === "vi") requestViRead();
      render();
      return true;
    }

    function handleNetData(payload) {
      const pending = state.pending.netRead;
      if (!pending) return false;
      const normalized = workflow.normalizeNetSellDataResponse(payload, pending);
      if (!normalized) return false;
      state.pending.netRead = null;
      if (pending.audience !== state.audience || pending.revision !== state.netRevision) return true;
      state.netRows = copyRows(normalized.rows).map(row => ({ ...row }));
      if (pending.verification &&
          pending.verification.audience === state.audience &&
          rowsEqual(pending.verification.rows, normalized.rows)) {
        state.netVerified = {
          audience: state.audience,
          rows: copyRows(normalized.rows),
          freshRead: normalized
        };
        notify("저장 파일 재조회가 입력과 일치합니다.", "success");
      } else {
        state.netVerified = null;
      }
      render();
      return true;
    }

    function handleNetSave(payload) {
      const pending = state.pending.netSave;
      if (!pending) return false;
      const normalized = workflow.normalizeNetSellSaveResponse(payload, pending);
      if (!normalized) return false;
      state.pending.netSave = null;
      requestNetRead(pending.audience, {
        audience: pending.audience,
        rows: pending.rows
      });
      notify("저장했습니다. 파일을 다시 확인하고 있습니다.", "success");
      render();
      return true;
    }

    function handleViData(payload) {
      const pending = state.pending.viRead;
      if (!pending) return false;
      const normalized = workflow.normalizeViDataResponse(payload, pending.requestId);
      if (!normalized) return false;
      state.pending.viRead = null;
      if (pending.revision !== state.viRevision) return true;

      state.viItems = copyItems(normalized.items).map(item => ({ ...item }));
      state.viBaseVersion = normalized.version;
      state.viVersion = normalized.version;
      const verification = pending.verification;
      if (verification && verification.persisted &&
          (verification.version !== normalized.version ||
           !itemsEqual(verification.items, normalized.items))) {
        state.viVerified = null;
        notify("저장 후 VI 파일 내용이 달라 컷 추가를 차단했습니다.", "error");
      } else {
        state.viVerified = {
          version: normalized.version,
          items: copyItems(normalized.items),
          freshRead: normalized
        };
        if (verification) {
          notify(
            verification.persisted
              ? "저장 파일 재조회가 VI 입력과 일치합니다."
              : "빈 목록은 원본 동작에 따라 파일을 변경하지 않았습니다.",
            "success");
        }
      }
      render();
      return true;
    }

    function handleViSave(payload) {
      const pending = state.pending.viSave;
      if (!pending) return false;
      const normalized = workflow.normalizeViSaveResponse(payload, pending.requestId);
      if (!normalized) return false;
      state.pending.viSave = null;
      requestViRead({
        items: pending.items,
        version: normalized.version,
        persisted: normalized.persisted
      });
      notify(
        normalized.persisted
          ? "VI 목록을 저장했습니다. 파일을 다시 확인하고 있습니다."
          : "빈 VI 목록은 원본 동작에 따라 저장 파일을 변경하지 않았습니다.",
        "success");
      render();
      return true;
    }

    const pendingOperations = Object.freeze({
      status: "status",
      legacyImport: "import-legacy-manual-data",
      netRead: "read-net-sell",
      netSave: "save-net-sell",
      viRead: "read-vi",
      viSave: "save-vi"
    });

    function handleManualError(payload) {
      for (const [key, operation] of Object.entries(pendingOperations)) {
        const pending = state.pending[key];
        if (!pending || payload?.requestId !== pending.requestId) continue;
        const normalized = workflow.normalizeError(payload, pending.requestId);
        if (!normalized || normalized.operation !== operation) return false;
        state.pending[key] = null;
        if (key === "status") {
          state.available = false;
          state.unavailableCode = normalized.code;
          state.unavailableMessage = normalized.message;
        }
        if (key === "viSave" && normalized.code === "STALE_DATA") {
          state.viBaseVersion = null;
          state.viVersion = null;
          state.viVerified = null;
        }
        if (normalized.outcomeUnknown) {
          state.quarantined = true;
          state.quarantineMessage = normalized.message;
          state.netVerified = null;
          state.viVerified = null;
          notify("저장 결과가 불명확하여 앱 재시작 전까지 재저장을 차단합니다.", "error");
        } else {
          notify(normalized.message, "error");
        }
        log(`수동 목록 오류 · ${normalized.operation} · ${normalized.code}`);
        render();
        return true;
      }
      return false;
    }

    function handleStockResults(payload) {
      const pending = state.pending.stockSearch;
      if (!pending) return false;
      const normalized = normalizeStockSearchResponse(payload, pending);
      if (!normalized) return false;
      state.pending.stockSearch = null;
      state.searchResults = [...normalized.results];
      state.searchMessage = normalized.results.length === 0
        ? "검색 결과가 없습니다."
        : `${normalized.results.length}건${normalized.truncated ? " · 일부 결과" : ""}`;
      render();
      return true;
    }

    function handleStockError(payload) {
      const pending = state.pending.stockSearch;
      if (!pending) return false;
      const normalized = normalizeStockSearchError(payload, pending);
      if (!normalized) return false;
      state.pending.stockSearch = null;
      state.searchResults = [];
      state.searchMessage = normalized.message;
      notify(normalized.message, "error");
      render();
      return true;
    }

    function handleMessage(type, payload) {
      switch (type) {
        case workflow.bridgeContract.statusResultType: return handleStatus(payload);
        case workflow.bridgeContract.legacyImportResultType: return handleLegacyImport(payload);
        case workflow.bridgeContract.netSellDataType: return handleNetData(payload);
        case workflow.bridgeContract.netSellSaveResultType: return handleNetSave(payload);
        case workflow.bridgeContract.viDataType: return handleViData(payload);
        case workflow.bridgeContract.viSaveResultType: return handleViSave(payload);
        case workflow.bridgeContract.errorType: return handleManualError(payload);
        case "stock-search-results": return handleStockResults(payload);
        case "stock-search-error": return handleStockError(payload);
        default: return false;
      }
    }

    function element(tag, className, text) {
      const node = state.root.ownerDocument.createElement(tag);
      if (className) node.className = className;
      if (text !== undefined) node.textContent = text;
      return node;
    }

    function button(text, action, options = {}) {
      const node = element("button", options.className || "manual-lists-button", text);
      node.type = "button";
      node.disabled = Boolean(options.disabled);
      if (options.title) node.title = options.title;
      node.addEventListener("click", action);
      return node;
    }

    function renderHeader(panel) {
      const header = element("header", "manual-lists-header");
      const title = element(
        "h2",
        "manual-lists-title",
        state.mode === "net" ? "수동 순매도 상위" : "VI 발동 목록");
      const close = button("닫기", () => {
        state.open = false;
        render();
      }, { className: "manual-lists-close" });
      const legacyImport = button("원본 데이터 가져오기", importLegacyManualData, {
        className: "manual-lists-button",
        disabled: !canMutate() || hasPendingManualDataOperation(),
        title: "원본 프로젝트의 FSell/VI 파일을 앱 전용 폴더로 한 번만 가져옵니다."
      });
      header.append(title, legacyImport, close);
      panel.append(header);
    }

    function renderBanner(panel) {
      if (state.quarantined) {
        const banner = element(
          "div",
          "manual-lists-banner manual-lists-banner-danger",
          state.quarantineMessage || "저장 결과가 불명확하여 앱을 다시 시작하기 전까지 저장할 수 없습니다.");
        banner.setAttribute("role", "alert");
        panel.append(banner);
      } else if (locked()) {
        panel.append(element(
          "div",
          "manual-lists-banner",
          "PREPARE 또는 송출 중에는 목록을 변경할 수 없습니다."));
      } else if (state.available === false) {
        panel.append(element(
          "div",
          "manual-lists-banner manual-lists-banner-danger",
          state.unavailableMessage || "수동 데이터 저장소를 사용할 수 없습니다."));
      }
    }

    function renderNet(panel) {
      const tabs = element("div", "manual-lists-tabs");
      for (const audience of workflow.audiences) {
        const tab = button(workflow.audienceLabels[audience], () => setAudience(audience), {
          className: audience === state.audience
            ? "manual-lists-tab is-active"
            : "manual-lists-tab",
          disabled: Boolean(state.pending.netSave)
        });
        tab.setAttribute("aria-pressed", String(audience === state.audience));
        tabs.append(tab);
      }
      panel.append(tabs);

      const table = element("table", "manual-lists-table manual-lists-net-table");
      const head = element("thead");
      const headRow = element("tr");
      for (const text of ["#", "좌측 종목", "좌측 금액", "우측 종목", "우측 금액"]) {
        headRow.append(element("th", "", text));
      }
      head.append(headRow);
      table.append(head);
      const body = element("tbody");
      state.netRows.forEach((row, rowIndex) => {
        const tr = element("tr");
        tr.append(element("th", "manual-lists-row-number", String(rowIndex + 1)));
        for (const key of NET_KEYS) {
          const td = element("td");
          const input = element("input", "manual-lists-input");
          input.type = "text";
          input.maxLength = 256;
          input.value = row[key];
          input.disabled = locked() || Boolean(state.pending.netSave);
          input.setAttribute("aria-label", `${rowIndex + 1}행 ${key}`);
          input.addEventListener("input", event => setNetCell(rowIndex, key, event.currentTarget.value));
          td.append(input);
          tr.append(td);
        }
        body.append(tr);
      });
      table.append(body);
      const tableWrap = element("div", "manual-lists-table-wrap");
      tableWrap.append(table);
      panel.append(tableWrap);

      const actions = element("div", "manual-lists-actions");
      actions.append(
        button("재조회", () => requestNetRead(state.audience), {
          disabled: Boolean(state.pending.netRead || state.pending.netSave)
        }),
        button("저장", saveNetSell, {
          className: "manual-lists-button is-primary",
          disabled: !canMutate() || Boolean(state.pending.netRead || state.pending.netSave)
        }),
        button("s5025 추가", addNetSellCut, {
          className: "manual-lists-button is-accent",
          disabled: !canMutate() || !state.netVerified ||
            !rowsEqual(state.netVerified.rows, state.netRows)
        })
      );
      panel.append(actions);
    }

    function renderSearch(panel) {
      const section = element("section", "manual-lists-search");
      const controls = element("div", "manual-lists-search-controls");
      const input = element("input", "manual-lists-input manual-lists-search-input");
      input.type = "search";
      input.maxLength = 100;
      input.placeholder = "종목명 검색";
      input.value = state.searchQuery;
      input.addEventListener("input", event => { state.searchQuery = event.currentTarget.value; });
      input.addEventListener("keydown", event => {
        if (event.key === "Enter") {
          event.preventDefault();
          searchStocks(event.currentTarget.value);
        }
      });
      controls.append(input, button("검색", () => searchStocks(input.value), {
        disabled: Boolean(state.pending.stockSearch)
      }));
      section.append(controls);
      section.append(element("p", "manual-lists-search-state", state.searchMessage));
      const results = element("div", "manual-lists-search-results");
      state.searchResults.forEach((stock, index) => {
        results.append(button(
          `${stock.name} · ${stock.code} · ${stock.market}`,
          () => addSearchResult(index),
          {
            className: "manual-lists-search-result",
            disabled: locked() || state.viItems.length >= workflow.maximumViItems
          }));
      });
      section.append(results);
      panel.append(section);
    }

    function renderVi(panel) {
      renderSearch(panel);
      const summary = element(
        "div",
        "manual-lists-summary",
        `${state.viItems.length}/${workflow.maximumViItems}종목 · ${state.viItems.length === 0 ? 0 : Math.ceil(state.viItems.length / workflow.viPageSize)}페이지`);
      panel.append(summary);

      const tableWrap = element("div", "manual-lists-table-wrap");
      const table = element("table", "manual-lists-table manual-lists-vi-table");
      const head = element("thead");
      const headRow = element("tr");
      for (const text of ["#", "코드", "종목명", "순서/삭제"]) headRow.append(element("th", "", text));
      head.append(headRow);
      table.append(head);
      const body = element("tbody");
      state.viItems.forEach((item, index) => {
        const row = element("tr");
        row.append(
          element("td", "manual-lists-row-number", String(index + 1)),
          element("td", "manual-lists-code", item.code),
          element("td", "manual-lists-name", item.name)
        );
        const controls = element("td", "manual-lists-row-actions");
        controls.append(
          button("↑", () => moveViItem(index, -1), {
            disabled: locked() || index === 0,
            title: "위로"
          }),
          button("↓", () => moveViItem(index, 1), {
            disabled: locked() || index === state.viItems.length - 1,
            title: "아래로"
          }),
          button("삭제", () => removeViItem(index), { disabled: locked() })
        );
        row.append(controls);
        body.append(row);
      });
      table.append(body);
      tableWrap.append(table);
      panel.append(tableWrap);

      const actions = element("div", "manual-lists-actions");
      actions.append(
        button("전체 삭제", clearViItems, {
          disabled: locked() || state.viItems.length === 0
        }),
        button("재조회", () => requestViRead(), {
          disabled: Boolean(state.pending.viRead || state.pending.viSave)
        }),
        button("저장", saveVi, {
          className: "manual-lists-button is-primary",
          disabled: !canMutate() || !state.viBaseVersion ||
            Boolean(state.pending.viRead || state.pending.viSave)
        }),
        button("s5074 추가", addViCut, {
          className: "manual-lists-button is-accent",
          disabled: !canMutate() || state.viItems.length === 0 || !state.viVerified ||
            state.viVerified.version !== state.viVersion ||
            !itemsEqual(state.viVerified.items, state.viItems)
        })
      );
      panel.append(actions);
    }

    function updateGateButtons() {
      if (!state.root) return;
      const add = state.root.querySelector(".manual-lists-button.is-accent");
      if (!add) return;
      add.disabled = state.mode === "net"
        ? !canMutate() || !state.netVerified || !rowsEqual(state.netVerified.rows, state.netRows)
        : !canMutate() || state.viItems.length === 0 || !state.viVerified ||
          state.viVerified.version !== state.viVersion || !itemsEqual(state.viVerified.items, state.viItems);
    }

    function snapshot() {
      return Object.freeze({
        open: state.open,
        mode: state.mode,
        locked: locked(),
        available: state.available,
        unavailableCode: state.unavailableCode,
        unavailableMessage: state.unavailableMessage,
        quarantined: state.quarantined,
        audience: state.audience,
        netRows: Object.freeze(copyRows(state.netRows)),
        netVerified: Boolean(state.netVerified && rowsEqual(state.netVerified.rows, state.netRows)),
        viItems: Object.freeze(copyItems(state.viItems)),
        viBaseVersion: state.viBaseVersion,
        viVersion: state.viVersion,
        viVerified: Boolean(state.viVerified && state.viVerified.version === state.viVersion &&
          itemsEqual(state.viVerified.items, state.viItems)),
        searchResults: Object.freeze(state.searchResults.map(item => ({ ...item }))),
        pending: Object.freeze(Object.fromEntries(
          Object.entries(state.pending).map(([key, value]) => [key, value?.requestId || null])))
      });
    }

    function render() {
      if (!state.root) return snapshot();
      state.root.hidden = !state.open;
      state.root.replaceChildren();
      if (!state.open) return snapshot();
      const backdrop = element("div", "manual-lists-backdrop");
      const panel = element("section", "manual-lists-dialog");
      panel.setAttribute("role", "dialog");
      panel.setAttribute("aria-modal", "true");
      renderHeader(panel);
      renderBanner(panel);
      if (state.mode === "net") renderNet(panel);
      if (state.mode === "vi") renderVi(panel);
      backdrop.append(panel);
      state.root.append(backdrop);
      return snapshot();
    }

    function mount(host) {
      const resolvedHost = host || root?.document?.body;
      if (!resolvedHost || typeof resolvedHost.append !== "function" || !resolvedHost.ownerDocument) {
        throw new TypeError("A DOM host is required to mount the manual-list UI.");
      }
      if (state.root?.parentNode) state.root.remove();
      state.host = resolvedHost;
      state.root = resolvedHost.ownerDocument.createElement("div");
      state.root.className = "manual-lists-root";
      state.root.hidden = true;
      resolvedHost.append(state.root);
      render();
      return state.root;
    }

    function openNetSell(audience = state.audience) {
      const normalized = workflow.normalizeAudience(audience);
      if (!normalized) throw new RangeError("Unknown FSell audience.");
      state.open = true;
      state.mode = "net";
      state.audience = normalized;
      state.netRows = emptyRows();
      invalidateNetVerification();
      requestStatus();
      requestNetRead(normalized);
      render();
      return snapshot();
    }

    function openVi() {
      state.open = true;
      state.mode = "vi";
      requestStatus();
      requestViRead();
      render();
      return snapshot();
    }

    function setLocked(value) {
      state.manualLocked = Boolean(value);
      render();
      return state.manualLocked;
    }

    const actions = Object.freeze({
      setAudience,
      setNetCell,
      saveNetSell,
      addNetSellCut,
      requestNetRead: () => requestNetRead(state.audience),
      searchStocks,
      addSearchResult,
      removeViItem,
      moveViItem,
      clearViItems,
      saveVi,
      addViCut,
      importLegacyManualData,
      requestViRead: () => requestViRead(),
      close: () => {
        state.open = false;
        render();
      }
    });

    return Object.freeze({
      mount,
      openNetSell,
      openVi,
      handleMessage,
      render,
      setLocked,
      actions,
      getState: snapshot
    });
  }

  return { createController };
});
