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

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

  const DEFAULT_FADE_DURATION = 6;
  const DEFAULT_MAXIMUM_RESULTS = 100;
  const MAXIMUM_RESULTS = 500;
  const MAXIMUM_QUERY_LENGTH = 64;
  const SCHEMA_VERSION = 1;
  const identifierPattern = /^[A-Za-z0-9_-]{1,128}$/;
  // F_SYMB is provider identity data, not executable syntax. Current Oracle
  // contains Hangul-letter symbols with internal ASCII spaces; keep letters and
  // numbers Unicode-aware and allow only that observed space plus a closed
  // punctuation set. Canonical trim and this pattern still exclude edge space,
  // `|`, `^`, controls and arbitrary punctuation from the dataCode identity.
  const symbolPattern = /^[\p{L}\p{N}@._$:\x20-]{1,64}$/u;
  const unsafeTextPattern = /[\p{Cc}\p{Cf}\p{Cs}\p{Zl}\p{Zp}\uFFFD]/u;

  const bridgeContracts = Object.freeze({
    industry: Object.freeze({
      requestType: "search-overseas-industries",
      resultType: "overseas-industry-results",
      errorType: "overseas-industry-error",
      defaultMaximumResults: DEFAULT_MAXIMUM_RESULTS,
      maximumResults: MAXIMUM_RESULTS,
      maximumQueryLength: MAXIMUM_QUERY_LENGTH
    }),
    stock: Object.freeze({
      requestType: "search-world-stocks",
      resultType: "world-stock-search-results",
      errorType: "world-stock-search-error",
      defaultMaximumResults: DEFAULT_MAXIMUM_RESULTS,
      maximumResults: MAXIMUM_RESULTS,
      maximumQueryLength: MAXIMUM_QUERY_LENGTH
    })
  });

  const sourceContract = Object.freeze({
    originalControl: "Control/UC5.cs",
    industry: Object.freeze({
      fdtc: "0",
      nationCodes: Object.freeze(["US"]),
      lookupField: "F_KNAM",
      currentCutCode: "5001"
    }),
    stock: Object.freeze({
      fdtc: "1",
      nationCodes: Object.freeze(["US", "TW"]),
      lookupField: "F_INPUT_NAME",
      currentCutCode: "5001",
      candleCutCodes: Object.freeze(["8061", "8040", "8046", "8051", "8056"])
    })
  });

  function action(id, label, builderKey, code, kind, periodDays = null) {
    return Object.freeze({
      id,
      label,
      builderKey,
      code,
      aliases: Object.freeze([code]),
      kind,
      periodDays
    });
  }

  const industryActions = Object.freeze([
    action("industry-current", "\uBBF8\uAD6D \uD574\uC678\uC5C5\uC885 \uD604\uC7AC\uAC00 1\uC5F4\uD310", "s5001", "5001", "current")
  ]);
  const stockActions = Object.freeze([
    action("stock-current", "\uD574\uC678\uC885\uBAA9 \uD604\uC7AC\uAC00 1\uC5F4\uD310", "s5001", "5001", "current"),
    action("stock-candle-5d", "\uD574\uC678\uC885\uBAA9 \uCE94\uB4E4 5\uC77C", "s8010", "8061", "candle", 5),
    action("stock-candle-20d", "\uD574\uC678\uC885\uBAA9 \uCE94\uB4E4 20\uC77C", "s8010", "8040", "candle", 20),
    action("stock-candle-60d", "\uD574\uC678\uC885\uBAA9 \uCE94\uB4E4 60\uC77C", "s8010", "8046", "candle", 60),
    action("stock-candle-120d", "\uD574\uC678\uC885\uBAA9 \uCE94\uB4E4 120\uC77C", "s8010", "8051", "candle", 120),
    action("stock-candle-240d", "\uD574\uC678\uC885\uBAA9 \uCE94\uB4E4 240\uC77C", "s8010", "8056", "candle", 240)
  ]);
  const actionsById = new Map(
    [...industryActions, ...stockActions].map(value => [value.id, value]));

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

  function isSafeCanonicalText(value, maximumLength, allowEmpty = false) {
    return typeof value === "string" &&
      value.length <= maximumLength &&
      (allowEmpty || value.length > 0) &&
      value === value.trim() &&
      !unsafeTextPattern.test(value);
  }

  function normalizeQuery(value) {
    if (typeof value !== "string" || unsafeTextPattern.test(value)) return null;
    const query = value.trim();
    return query.length <= MAXIMUM_QUERY_LENGTH && !unsafeTextPattern.test(query) ? query : null;
  }

  function normalizeSymbol(value) {
    return isSafeCanonicalText(value, 64) && symbolPattern.test(value) ? value : null;
  }

  function normalizeIndustry(value) {
    if (!hasExactKeys(value, [
      "koreanName", "inputName", "symbol", "nationCode", "fdtc"
    ])) return null;
    if (!isSafeCanonicalText(value.koreanName, 200) || value.koreanName.includes("|") ||
        !isSafeCanonicalText(value.inputName, 200) || value.inputName.includes("|") ||
        !normalizeSymbol(value.symbol) || value.nationCode !== "US" || value.fdtc !== "0") {
      return null;
    }
    return Object.freeze({
      koreanName: value.koreanName,
      inputName: value.inputName,
      symbol: value.symbol,
      nationCode: "US",
      fdtc: "0"
    });
  }

  function normalizeStock(value) {
    if (!hasExactKeys(value, ["inputName", "symbol", "nationCode", "fdtc"])) return null;
    if (!isSafeCanonicalText(value.inputName, 200) || value.inputName.includes("|") ||
        !normalizeSymbol(value.symbol) || !["US", "TW"].includes(value.nationCode) ||
        value.fdtc !== "1") {
      return null;
    }
    return Object.freeze({
      inputName: value.inputName,
      symbol: value.symbol,
      nationCode: value.nationCode,
      fdtc: "1"
    });
  }

  function createSearchRequest(requestId, query, maximumResults) {
    if (!isSafeCanonicalText(requestId, 128) || !identifierPattern.test(requestId)) {
      throw new TypeError("A safe overseas search request id is required.");
    }
    const normalizedQuery = normalizeQuery(query ?? "");
    if (normalizedQuery === null) {
      throw new TypeError(`An overseas search query must contain at most ${MAXIMUM_QUERY_LENGTH} safe characters.`);
    }
    if (maximumResults !== undefined &&
        (!Number.isInteger(maximumResults) || maximumResults < 1 || maximumResults > MAXIMUM_RESULTS)) {
      throw new RangeError(`Overseas results must be limited from 1 through ${MAXIMUM_RESULTS}.`);
    }
    const result = { requestId, query: normalizedQuery };
    if (maximumResults !== undefined) result.maximumResults = maximumResults;
    return Object.freeze(result);
  }

  function createIndustrySearchRequest(requestId, query = "", maximumResults) {
    return createSearchRequest(requestId, query, maximumResults);
  }

  function createStockSearchRequest(requestId, query = "", maximumResults) {
    return createSearchRequest(requestId, query, maximumResults);
  }

  function compareUpper(left, right) {
    const upperLeft = left.toLocaleUpperCase("en-US");
    const upperRight = right.toLocaleUpperCase("en-US");
    if (upperLeft < upperRight) return -1;
    if (upperLeft > upperRight) return 1;
    return 0;
  }

  function compareOrdinal(left, right) {
    if (left < right) return -1;
    if (left > right) return 1;
    return 0;
  }

  function compareIndustries(left, right) {
    return compareUpper(left.koreanName, right.koreanName) ||
      compareOrdinal(left.inputName, right.inputName) ||
      compareOrdinal(left.symbol, right.symbol);
  }

  function compareStocks(left, right) {
    return compareUpper(left.inputName, right.inputName) ||
      compareOrdinal(left.symbol, right.symbol) ||
      compareOrdinal(left.nationCode, right.nationCode);
  }

  function normalizeSearchResponse(value, expectedRequest, normalizeItem, compare, duplicateKeys) {
    if (!hasExactKeys(value, [
      "requestId", "query", "retrievedAt", "totalRowCount", "truncated", "results"
    ]) || !isSafeCanonicalText(value.requestId, 128) || !identifierPattern.test(value.requestId) ||
        normalizeQuery(value.query) !== value.query || !isSafeCanonicalText(value.retrievedAt, 64) ||
        !Number.isFinite(Date.parse(value.retrievedAt)) || !Number.isInteger(value.totalRowCount) ||
        value.totalRowCount < 0 || value.totalRowCount > MAXIMUM_RESULTS ||
        typeof value.truncated !== "boolean" || !Array.isArray(value.results) ||
        value.results.length !== value.totalRowCount) {
      return null;
    }
    if (expectedRequest) {
      let expected;
      try {
        expected = createSearchRequest(
          expectedRequest.requestId,
          expectedRequest.query,
          expectedRequest.maximumResults);
      } catch {
        return null;
      }
      const expectedLimit = expected.maximumResults ?? DEFAULT_MAXIMUM_RESULTS;
      if (value.requestId !== expected.requestId || value.query !== expected.query ||
          value.results.length > expectedLimit) {
        return null;
      }
    }

    const results = [];
    const keySets = duplicateKeys.map(() => new Set());
    for (const raw of value.results) {
      const item = normalizeItem(raw);
      if (!item) return null;
      for (let index = 0; index < duplicateKeys.length; index += 1) {
        const key = duplicateKeys[index](item);
        if (keySets[index].has(key)) return null;
        keySets[index].add(key);
      }
      if (results.length && compare(results[results.length - 1], item) > 0) return null;
      results.push(item);
    }
    return Object.freeze({
      requestId: value.requestId,
      query: value.query,
      retrievedAt: value.retrievedAt,
      totalRowCount: value.totalRowCount,
      truncated: value.truncated,
      results: Object.freeze(results)
    });
  }

  function normalizeIndustrySearchResponse(value, expectedRequest) {
    return normalizeSearchResponse(
      value,
      expectedRequest,
      normalizeIndustry,
      compareIndustries,
      [item => item.koreanName, item => item.inputName, item => item.symbol]);
  }

  function normalizeStockSearchResponse(value, expectedRequest) {
    return normalizeSearchResponse(
      value,
      expectedRequest,
      normalizeStock,
      compareStocks,
      [item => item.inputName, item => `${item.inputName}\u0000${item.symbol}\u0000${item.nationCode}`]);
  }

  function normalizeSearchError(value, expectedRequest) {
    if (!hasExactKeys(value, ["requestId", "query", "message"]) ||
        !isSafeCanonicalText(value.requestId, 128) || !identifierPattern.test(value.requestId) ||
        normalizeQuery(value.query) !== value.query || !isSafeCanonicalText(value.message, 512)) {
      return null;
    }
    if (expectedRequest &&
        (value.requestId !== expectedRequest.requestId ||
         value.query !== normalizeQuery(expectedRequest.query))) return null;
    return Object.freeze({ requestId: value.requestId, query: value.query, message: value.message });
  }

  function normalizeIndustrySearchError(value, expectedRequest) {
    return normalizeSearchError(value, expectedRequest);
  }

  function normalizeStockSearchError(value, expectedRequest) {
    return normalizeSearchError(value, expectedRequest);
  }

  function requireOptions(options) {
    if (!options || typeof options !== "object" || Array.isArray(options) ||
        !isSafeCanonicalText(options.id, 128) || !identifierPattern.test(options.id)) {
      throw new TypeError("Safe overseas playlist options are required.");
    }
    const fadeDuration = options.fadeDuration === undefined
      ? DEFAULT_FADE_DURATION
      : options.fadeDuration;
    const ma5 = options.ma5 === undefined ? false : options.ma5;
    const ma20 = options.ma20 === undefined ? false : options.ma20;
    if (!Number.isInteger(fadeDuration) || fadeDuration < 0 || fadeDuration > 60) {
      throw new RangeError("Fade duration must be an integer from 0 through 60.");
    }
    if (typeof ma5 !== "boolean" || typeof ma20 !== "boolean") {
      throw new TypeError("Moving-average flags must be boolean values.");
    }
    return Object.freeze({ id: options.id, fadeDuration, ma5, ma20 });
  }

  function encodeIndustryIdentity(value) {
    return `${value.inputName}|${value.symbol}|${value.nationCode}|${value.fdtc}`;
  }

  function encodeStockIdentity(value) {
    return `${value.symbol}|${value.nationCode}|${value.fdtc}`;
  }

  function movingAverageFlags(actionValue, options) {
    const enabled = actionValue.kind === "candle";
    const movingAverages = Object.freeze({
      ma5: enabled && options.ma5,
      ma20: enabled && options.ma20
    });
    const flags = [];
    if (movingAverages.ma5) flags.push("MA5");
    if (movingAverages.ma20) flags.push("MA20");
    return Object.freeze({ movingAverages, graphicType: flags.join(",") });
  }

  function buildOverseasIndustrySelection(industryValue) {
    const industry = normalizeIndustry(industryValue);
    if (!industry) throw new TypeError("A valid US overseas-industry identity is required.");
    return Object.freeze({
      builderKey: "s5001",
      code: "5001",
      aliases: Object.freeze(["5001"]),
      selection: Object.freeze({
        groupCode: "FOREIGN_INDUSTRY",
        subject: industry.koreanName,
        graphicType: "\u0031\uC5F4\uD310\uAE30\uBCF8",
        subtype: "CURRENT",
        dataCode: encodeIndustryIdentity(industry)
      }),
      movingAverages: Object.freeze({ ma5: false, ma20: false })
    });
  }

  function buildOverseasStockSelection(stockValue, actionId, options = {}) {
    const stock = normalizeStock(stockValue);
    const selectedAction = actionsById.get(actionId);
    if (!stock) throw new TypeError("A valid US/TW overseas-stock identity is required.");
    if (!selectedAction || !stockActions.includes(selectedAction)) {
      throw new RangeError("The overseas-stock action is unknown.");
    }
    const ma5 = options.ma5 === undefined ? false : options.ma5;
    const ma20 = options.ma20 === undefined ? false : options.ma20;
    if (typeof ma5 !== "boolean" || typeof ma20 !== "boolean") {
      throw new TypeError("Moving-average flags must be boolean values.");
    }
    const flags = movingAverageFlags(selectedAction, { ma5, ma20 });
    return Object.freeze({
      builderKey: selectedAction.builderKey,
      code: selectedAction.code,
      aliases: selectedAction.aliases,
      selection: Object.freeze({
        groupCode: "FOREIGN_STOCK",
        subject: stock.inputName,
        graphicType: selectedAction.kind === "current"
          ? "\u0031\uC5F4\uD310\uAE30\uBCF8"
          : flags.graphicType,
        subtype: selectedAction.kind === "current" ? "CURRENT" : "PRICE",
        dataCode: encodeStockIdentity(stock)
      }),
      movingAverages: flags.movingAverages
    });
  }

  function createOverseasIndustryPlaylistEntry(industryValue, options) {
    const industry = normalizeIndustry(industryValue);
    if (!industry) throw new TypeError("A valid US overseas-industry identity is required.");
    const normalizedOptions = requireOptions(options);
    const mapping = buildOverseasIndustrySelection(industry);
    const actionValue = industryActions[0];
    return {
      id: normalizedOptions.id,
      builderKey: mapping.builderKey,
      code: mapping.code,
      aliases: [...mapping.aliases],
      title: industry.koreanName,
      detail: actionValue.label,
      category: "plate",
      market: "overseas",
      symbol: industry.symbol,
      nationCode: industry.nationCode,
      fdtc: industry.fdtc,
      selection: mapping.selection,
      enabled: true,
      fadeDuration: normalizedOptions.fadeDuration,
      graphicType: mapping.selection.graphicType,
      subtype: mapping.selection.subtype,
      operator: Object.freeze({
        source: "legacy-overseas-workflow",
        schemaVersion: SCHEMA_VERSION,
        kind: "industry",
        actionId: actionValue.id,
        identity: industry,
        movingAverages: mapping.movingAverages
      })
    };
  }

  function createOverseasStockPlaylistEntry(stockValue, actionId, options) {
    const stock = normalizeStock(stockValue);
    if (!stock) throw new TypeError("A valid US/TW overseas-stock identity is required.");
    const selectedAction = actionsById.get(actionId);
    if (!selectedAction || !stockActions.includes(selectedAction)) {
      throw new RangeError("The overseas-stock action is unknown.");
    }
    const normalizedOptions = requireOptions(options);
    const mapping = buildOverseasStockSelection(stock, actionId, normalizedOptions);
    return {
      id: normalizedOptions.id,
      builderKey: mapping.builderKey,
      code: mapping.code,
      aliases: [...mapping.aliases],
      title: stock.inputName,
      detail: selectedAction.label,
      category: selectedAction.kind === "candle" ? "chart" : "stock",
      market: "overseas",
      symbol: stock.symbol,
      nationCode: stock.nationCode,
      fdtc: stock.fdtc,
      selection: mapping.selection,
      enabled: true,
      fadeDuration: normalizedOptions.fadeDuration,
      graphicType: mapping.selection.graphicType,
      subtype: mapping.selection.subtype,
      operator: Object.freeze({
        source: "legacy-overseas-workflow",
        schemaVersion: SCHEMA_VERSION,
        kind: "stock",
        actionId,
        identity: stock,
        movingAverages: mapping.movingAverages
      })
    };
  }

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

  function sameAliases(value, recreated) {
    return Array.isArray(value.aliases) && value.aliases.length === 1 &&
      value.aliases[0] === recreated.code;
  }

  function restoreOverseasPlaylistEntry(value) {
    if (!value || typeof value !== "object" || Array.isArray(value) ||
        typeof value.enabled !== "boolean") return null;
    const operator = value.operator;
    if (!hasExactKeys(operator, [
      "source", "schemaVersion", "kind", "actionId", "identity", "movingAverages"
    ]) || operator.source !== "legacy-overseas-workflow" ||
        operator.schemaVersion !== SCHEMA_VERSION ||
        !hasExactKeys(operator.movingAverages, ["ma5", "ma20"]) ||
        typeof operator.movingAverages.ma5 !== "boolean" ||
        typeof operator.movingAverages.ma20 !== "boolean") return null;

    let recreated;
    try {
      recreated = operator.kind === "industry"
        ? createOverseasIndustryPlaylistEntry(operator.identity, {
          id: value.id,
          fadeDuration: value.fadeDuration,
          ma5: operator.movingAverages.ma5,
          ma20: operator.movingAverages.ma20
        })
        : operator.kind === "stock"
          ? createOverseasStockPlaylistEntry(operator.identity, operator.actionId, {
            id: value.id,
            fadeDuration: value.fadeDuration,
            ma5: operator.movingAverages.ma5,
            ma20: operator.movingAverages.ma20
          })
          : null;
    } catch {
      return null;
    }
    if (!recreated || operator.actionId !== recreated.operator.actionId) return null;
    const scalars = [
      "id", "builderKey", "code", "title", "detail", "category", "market",
      "symbol", "nationCode", "fdtc", "fadeDuration", "graphicType", "subtype"
    ];
    if (!scalars.every(key => value[key] === recreated[key]) ||
        !sameAliases(value, recreated) || !sameSelection(value.selection, recreated.selection) ||
        JSON.stringify(operator) !== JSON.stringify(recreated.operator)) return null;
    return { ...recreated, enabled: value.enabled };
  }

  function isActionAllowed(kind, actionId, identity) {
    if (kind === "industry") {
      return actionId === "industry-current" && Boolean(normalizeIndustry(identity));
    }
    return kind === "stock" && Boolean(normalizeStock(identity)) &&
      stockActions.some(value => value.id === actionId);
  }

  return {
    bridgeContracts,
    sourceContract,
    industryActions,
    stockActions,
    normalizeIndustry,
    normalizeStock,
    createIndustrySearchRequest,
    createStockSearchRequest,
    normalizeIndustrySearchResponse,
    normalizeStockSearchResponse,
    normalizeIndustrySearchError,
    normalizeStockSearchError,
    isActionAllowed,
    buildOverseasIndustrySelection,
    buildOverseasStockSelection,
    createOverseasIndustryPlaylistEntry,
    createOverseasStockPlaylistEntry,
    restoreOverseasPlaylistEntry,
    refreshOverseasPlaylistEntry: restoreOverseasPlaylistEntry
  };
});
