"use strict";

const test = require("node:test");
const assert = require("node:assert/strict");
const workflow = require("../../Web/overseas-workflow.js");

const industry = Object.freeze({
  koreanName: "Philadelphia Semiconductor",
  inputName: "US Semiconductor Index",
  symbol: "NAS@SOX",
  nationCode: "US",
  fdtc: "0"
});
const usStock = Object.freeze({
  inputName: "NVIDIA",
  symbol: "NAS@NVDA",
  nationCode: "US",
  fdtc: "1"
});
const twStock = Object.freeze({
  inputName: "Taiwan Semiconductor",
  symbol: "TWS@2330",
  nationCode: "TW",
  fdtc: "1"
});

function clone(value) {
  return JSON.parse(JSON.stringify(value));
}

function industryResponse(overrides = {}) {
  return {
    requestId: "overseas-industry-1",
    query: "",
    retrievedAt: "2026-07-11T12:34:56+09:00",
    totalRowCount: 2,
    truncated: false,
    results: [
      {
        koreanName: "Alpha Industry",
        inputName: "ALPHA_INDEX",
        symbol: "NAS@ALPHA",
        nationCode: "US",
        fdtc: "0"
      },
      {
        koreanName: "Beta Industry",
        inputName: "BETA_INDEX",
        symbol: "NAS@BETA",
        nationCode: "US",
        fdtc: "0"
      }
    ],
    ...overrides
  };
}

function stockResponse(overrides = {}) {
  return {
    requestId: "overseas-stock-1",
    query: "",
    retrievedAt: "2026-07-11T12:34:56+09:00",
    totalRowCount: 2,
    truncated: false,
    results: [
      { inputName: "Apple", symbol: "NAS@AAPL", nationCode: "US", fdtc: "1" },
      { inputName: "Taiwan Semiconductor", symbol: "TWS@2330", nationCode: "TW", fdtc: "1" }
    ],
    ...overrides
  };
}

test("UC5 bridge contracts expose one industry action and six stock actions", () => {
  assert.deepEqual(workflow.bridgeContracts.industry, {
    requestType: "search-overseas-industries",
    resultType: "overseas-industry-results",
    errorType: "overseas-industry-error",
    defaultMaximumResults: 100,
    maximumResults: 500,
    maximumQueryLength: 64
  });
  assert.deepEqual(workflow.bridgeContracts.stock, {
    requestType: "search-world-stocks",
    resultType: "world-stock-search-results",
    errorType: "world-stock-search-error",
    defaultMaximumResults: 100,
    maximumResults: 500,
    maximumQueryLength: 64
  });
  assert.deepEqual(workflow.industryActions.map(value => [value.id, value.code]), [
    ["industry-current", "5001"]
  ]);
  assert.deepEqual(workflow.stockActions.map(value => [value.id, value.code, value.periodDays]), [
    ["stock-current", "5001", null],
    ["stock-candle-5d", "8061", 5],
    ["stock-candle-20d", "8040", 20],
    ["stock-candle-60d", "8046", 60],
    ["stock-candle-120d", "8051", 120],
    ["stock-candle-240d", "8056", 240]
  ]);
  assert.deepEqual(workflow.sourceContract.industry, {
    fdtc: "0", nationCodes: ["US"], lookupField: "F_KNAM", currentCutCode: "5001"
  });
  assert.deepEqual(workflow.sourceContract.stock.candleCutCodes, [
    "8061", "8040", "8046", "8051", "8056"
  ]);
});

test("industry and stock search requests normalize blank initial lists and exact limits", () => {
  assert.deepEqual(
    workflow.createIndustrySearchRequest("industry-request", "   "),
    { requestId: "industry-request", query: "" });
  assert.deepEqual(
    workflow.createStockSearchRequest("stock-request", "  NVDA  ", 200),
    { requestId: "stock-request", query: "NVDA", maximumResults: 200 });

  for (const create of [workflow.createIndustrySearchRequest, workflow.createStockSearchRequest]) {
    assert.throws(() => create("bad id", ""), /safe/i);
    assert.throws(() => create("request", "bad\nquery"), /safe/i);
    assert.throws(() => create("request", "x".repeat(65)), /64/);
    assert.throws(() => create("request", "", 0), /1 through 500/);
    assert.throws(() => create("request", "", 501), /1 through 500/);
    assert.throws(() => create("request", "", "100"), /1 through 500/);
  }
});

test("native identities require exact name, symbol, nation and FDTC fields", () => {
  assert.deepEqual(workflow.normalizeIndustry(industry), industry);
  assert.deepEqual(workflow.normalizeStock(usStock), usStock);
  assert.deepEqual(workflow.normalizeStock(twStock), twStock);

  assert.equal(workflow.normalizeIndustry({ ...industry, nationCode: "TW" }), null);
  assert.equal(workflow.normalizeIndustry({ ...industry, fdtc: "1" }), null);
  assert.equal(workflow.normalizeIndustry({ ...industry, koreanName: " Industry" }), null);
  assert.equal(workflow.normalizeIndustry({ ...industry, inputName: "A|B" }), null);
  assert.equal(workflow.normalizeStock({ ...usStock, nationCode: "JP" }), null);
  assert.equal(workflow.normalizeStock({ ...usStock, fdtc: 1 }), null);
  assert.equal(workflow.normalizeStock({ ...usStock, symbol: "../../NVDA" }), null);
  assert.equal(workflow.normalizeStock({ ...usStock, inputName: "NV\u200BIDIA" }), null);
  assert.equal(workflow.normalizeStock({ ...usStock, koreanName: "extra" }), null);
});

test("industry responses are request-bound, ordered and deeply normalized", () => {
  const request = workflow.createIndustrySearchRequest("overseas-industry-1", "", 2);
  const normalized = workflow.normalizeIndustrySearchResponse(industryResponse(), request);
  assert.ok(normalized);
  assert.deepEqual(normalized, industryResponse());
  assert.equal(Object.isFrozen(normalized), true);
  assert.equal(Object.isFrozen(normalized.results), true);
  assert.equal(Object.isFrozen(normalized.results[0]), true);

  assert.equal(workflow.normalizeIndustrySearchResponse(
    industryResponse({ requestId: "stale" }), request), null);
  assert.equal(workflow.normalizeIndustrySearchResponse(
    industryResponse({ totalRowCount: 1 }), request), null);
  assert.equal(workflow.normalizeIndustrySearchResponse(
    industryResponse({ retrievedAt: "invalid" }), request), null);
  assert.equal(workflow.normalizeIndustrySearchResponse(
    { ...industryResponse(), extra: true }, request), null);
  assert.equal(workflow.normalizeIndustrySearchResponse(industryResponse({
    results: [...industryResponse().results].reverse()
  }), request), null);

  const overDefault = Array.from({ length: 101 }, (_, index) => {
    const key = String(index).padStart(3, "0");
    return {
      koreanName: `Industry ${key}`,
      inputName: `INDEX_${key}`,
      symbol: `NAS@I${key}`,
      nationCode: "US",
      fdtc: "0"
    };
  });
  assert.equal(workflow.normalizeIndustrySearchResponse({
    ...industryResponse(),
    totalRowCount: overDefault.length,
    results: overDefault
  }, workflow.createIndustrySearchRequest("overseas-industry-1", "")), null);
});

test("industry responses reject every ambiguous downstream lookup key", () => {
  for (const key of ["koreanName", "inputName", "symbol"]) {
    const rows = clone(industryResponse().results);
    rows[1][key] = rows[0][key];
    assert.equal(workflow.normalizeIndustrySearchResponse(industryResponse({ results: rows })), null);
  }
  const wrongScope = clone(industryResponse());
  wrongScope.results[1].fdtc = "1";
  assert.equal(workflow.normalizeIndustrySearchResponse(wrongScope), null);
});

test("stock responses preserve US/TW identity and reject duplicate input-name lookups", () => {
  const request = workflow.createStockSearchRequest("overseas-stock-1", "", 2);
  assert.ok(workflow.normalizeStockSearchResponse(stockResponse(), request));

  const duplicateName = clone(stockResponse());
  duplicateName.results[1].inputName = duplicateName.results[0].inputName;
  assert.equal(workflow.normalizeStockSearchResponse(duplicateName), null);

  const sameSymbol = clone(stockResponse());
  sameSymbol.results[1].symbol = sameSymbol.results[0].symbol;
  assert.ok(workflow.normalizeStockSearchResponse(sameSymbol));

  const wrongNation = clone(stockResponse());
  wrongNation.results[1].nationCode = "JP";
  assert.equal(workflow.normalizeStockSearchResponse(wrongNation), null);
  assert.equal(workflow.normalizeStockSearchResponse(stockResponse({
    results: [...stockResponse().results].reverse()
  })), null);
});

test("provider symbols accept Unicode letters but keep serialized delimiters closed", () => {
  const unicode = workflow.normalizeStock({
    inputName: "Provider Unicode Symbol",
    symbol: "NAS@해외 종목1",
    nationCode: "US",
    fdtc: "1"
  });
  assert.ok(unicode);
  const entry = workflow.createOverseasStockPlaylistEntry(
    unicode,
    "stock-candle-5d",
    { id: "unicode-symbol", fadeDuration: 6, ma5: true, ma20: false });
  assert.equal(entry.selection.dataCode, "NAS@해외 종목1|US|1");

  for (const symbol of ["NAS|NVDA", "NAS^NVDA", " NAS@NVDA", "NAS@NVDA ", "NAS!NVDA", "NAS\nNVDA", "NAS😀NVDA"]) {
    assert.equal(workflow.normalizeStock({ ...unicode, symbol }), null, symbol);
  }
});

test("bridge errors use exact payloads and reject stale request correlation", () => {
  const industryRequest = workflow.createIndustrySearchRequest("industry-error-1", "AI");
  const industryError = {
    requestId: "industry-error-1",
    query: "AI",
    message: "Industry lookup failed."
  };
  assert.deepEqual(
    workflow.normalizeIndustrySearchError(industryError, industryRequest),
    industryError);
  assert.equal(workflow.normalizeIndustrySearchError({
    ...industryError, requestId: "stale"
  }, industryRequest), null);
  assert.equal(workflow.normalizeIndustrySearchError({
    ...industryError, extra: true
  }, industryRequest), null);

  const stockRequest = workflow.createStockSearchRequest("stock-error-1", "NVDA");
  const stockError = { requestId: "stock-error-1", query: "NVDA", message: "Failed." };
  assert.deepEqual(workflow.normalizeStockSearchError(stockError, stockRequest), stockError);
  assert.equal(workflow.normalizeStockSearchError({
    ...stockError, message: "bad\nmessage"
  }, stockRequest), null);
});

test("US overseas industries expose only the 5001 current one-column mapping", () => {
  assert.equal(workflow.isActionAllowed("industry", "industry-current", industry), true);
  assert.equal(workflow.isActionAllowed("industry", "stock-candle-5d", industry), false);
  const mapping = workflow.buildOverseasIndustrySelection(industry);
  assert.equal(mapping.builderKey, "s5001");
  assert.equal(mapping.code, "5001");
  assert.deepEqual(mapping.aliases, ["5001"]);
  assert.deepEqual(mapping.selection, {
    groupCode: "FOREIGN_INDUSTRY",
    subject: "Philadelphia Semiconductor",
    graphicType: "1\uC5F4\uD310\uAE30\uBCF8",
    subtype: "CURRENT",
    dataCode: "US Semiconductor Index|NAS@SOX|US|0"
  });
  assert.throws(() => workflow.buildOverseasIndustrySelection({
    ...industry, nationCode: "TW"
  }), /valid/i);
});

test("US/TW stocks expose 5001 current and the five exact UC5 candle aliases", () => {
  const current = workflow.buildOverseasStockSelection(usStock, "stock-current", {
    ma5: true,
    ma20: true
  });
  assert.equal(current.builderKey, "s5001");
  assert.deepEqual(current.selection, {
    groupCode: "FOREIGN_STOCK",
    subject: "NVIDIA",
    graphicType: "1\uC5F4\uD310\uAE30\uBCF8",
    subtype: "CURRENT",
    dataCode: "NAS@NVDA|US|1"
  });
  assert.deepEqual(current.movingAverages, { ma5: false, ma20: false });

  for (const action of workflow.stockActions.filter(value => value.kind === "candle")) {
    const mapping = workflow.buildOverseasStockSelection(twStock, action.id, {
      ma5: true,
      ma20: false
    });
    assert.equal(mapping.builderKey, "s8010");
    assert.equal(mapping.code, action.code);
    assert.deepEqual(mapping.aliases, [action.code]);
    assert.deepEqual(mapping.selection, {
      groupCode: "FOREIGN_STOCK",
      subject: "Taiwan Semiconductor",
      graphicType: "MA5",
      subtype: "PRICE",
      dataCode: "TWS@2330|TW|1"
    });
  }
  assert.throws(() => workflow.buildOverseasStockSelection(usStock, "stock-candle-intraday"), /unknown/i);
  assert.throws(() => workflow.buildOverseasStockSelection(usStock, "industry-current"), /unknown/i);
  assert.throws(() => workflow.buildOverseasStockSelection(usStock, "stock-current", {
    ma5: "true"
  }), /boolean/i);
});

test("playlist entries preserve typed FDTC identity and runtime candle bindings", () => {
  const industryEntry = workflow.createOverseasIndustryPlaylistEntry(industry, {
    id: "industry-entry",
    fadeDuration: 7
  });
  assert.equal(industryEntry.category, "plate");
  assert.equal(industryEntry.symbol, "NAS@SOX");
  assert.equal(industryEntry.nationCode, "US");
  assert.equal(industryEntry.fdtc, "0");
  assert.equal(industryEntry.operator.kind, "industry");
  assert.equal(industryEntry.operator.schemaVersion, 1);

  const candleEntry = workflow.createOverseasStockPlaylistEntry(
    twStock,
    "stock-candle-240d",
    { id: "tw-candle", fadeDuration: 8, ma5: true, ma20: true });
  assert.equal(candleEntry.builderKey, "s8010");
  assert.equal(candleEntry.code, "8056");
  assert.equal(candleEntry.category, "chart");
  assert.equal(candleEntry.selection.dataCode, "TWS@2330|TW|1");
  assert.equal(candleEntry.selection.graphicType, "MA5,MA20");
  assert.equal(candleEntry.operator.kind, "stock");
  assert.deepEqual(candleEntry.operator.identity, twStock);
});

test("trusted restore reconstructs entries and rejects identity or mapping tampering", () => {
  const original = workflow.createOverseasStockPlaylistEntry(
    usStock,
    "stock-candle-120d",
    { id: "stored-overseas", fadeDuration: 9, ma5: true, ma20: false });
  original.enabled = false;
  const restored = workflow.restoreOverseasPlaylistEntry(clone(original));
  assert.ok(restored);
  assert.equal(restored.enabled, false);

  const tampers = [
    value => { value.builderKey = "s5001"; },
    value => { value.code = "8040"; },
    value => { value.selection.groupCode = "FOREIGN_INDEX"; },
    value => { value.selection.subject = "Other"; },
    value => { value.selection.dataCode = "NAS@NVDA|TW|1"; },
    value => { value.selection.subtype = "VOLUME"; },
    value => { value.symbol = "NAS@OTHER"; },
    value => { value.nationCode = "TW"; },
    value => { value.fdtc = "0"; },
    value => { value.operator.actionId = "stock-current"; },
    value => { value.operator.schemaVersion = 2; },
    value => { value.operator.identity.symbol = "NAS@OTHER"; },
    value => { value.operator.identity.nationCode = "TW"; },
    value => { value.operator.identity.fdtc = "0"; },
    value => { value.operator.movingAverages.ma5 = false; },
    value => { value.enabled = "false"; }
  ];
  for (const tamper of tampers) {
    const candidate = clone(original);
    tamper(candidate);
    assert.equal(workflow.restoreOverseasPlaylistEntry(candidate), null);
  }

  const industryEntry = workflow.createOverseasIndustryPlaylistEntry(industry, {
    id: "stored-industry"
  });
  assert.ok(workflow.restoreOverseasPlaylistEntry(clone(industryEntry)));
  const tamperedIndustry = clone(industryEntry);
  tamperedIndustry.operator.identity.koreanName = "Other Industry";
  assert.equal(workflow.restoreOverseasPlaylistEntry(tamperedIndustry), null);
  assert.equal(workflow.restoreOverseasPlaylistEntry(null), null);
});

test("playlist option validation rejects unsafe ids, fades and identities", () => {
  assert.throws(() => workflow.createOverseasIndustryPlaylistEntry(industry, {
    id: "bad id"
  }), /safe/i);
  assert.throws(() => workflow.createOverseasStockPlaylistEntry(usStock, "stock-current", {
    id: "fade", fadeDuration: 61
  }), /0 through 60/i);
  assert.throws(() => workflow.createOverseasStockPlaylistEntry(usStock, "stock-current", {
    id: "flags", ma20: 1
  }), /boolean/i);
  assert.throws(() => workflow.createOverseasStockPlaylistEntry(
    { ...usStock, nationCode: "JP" },
    "stock-current",
    { id: "invalid" }), /valid/i);
});
