"use strict";

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

const requestId = "manual_req_1";
const rowVersion = "a".repeat(64);
const rows = () => Array.from({ length: 5 }, (_, index) => ({
  leftName: `왼쪽${index + 1}`,
  leftAmount: `${index + 1},000`,
  rightName: `오른쪽${index + 1}`,
  rightAmount: `-${index + 1},000`
}));
const viItems = () => [
  { code: "005930", name: "삼성전자" },
  { code: "000660", name: "SK하이닉스" },
  { code: "005930", name: "삼성전자" }
];

test("bridge requests expose only closed operations and keys", () => {
  assert.deepEqual(workflow.createStatusRequest(requestId), {
    type: "request-manual-operator-data-status",
    payload: { requestId }
  });
  assert.deepEqual(workflow.createNetSellReadRequest(requestId, "FOREIGN"), {
    type: "request-manual-net-sell-data",
    payload: { requestId, audience: "FOREIGN" }
  });
  assert.deepEqual(workflow.createViReadRequest(requestId), {
    type: "request-vi-manual-list",
    payload: { requestId }
  });
  const legacyImport = workflow.createLegacyImportRequest(requestId);
  assert.deepEqual(legacyImport, {
    type: "import-legacy-manual-operator-data",
    payload: { requestId }
  });
  assert.equal(/path|directory|sourceRoot/iu.test(JSON.stringify(legacyImport)), false);
});

test("legacy import receipt is exact, correlated, versioned, and path-free", () => {
  const response = {
    requestId,
    markerVersion: 1,
    sourceSha256: "b".repeat(64),
    importedAt: "2026-07-12T01:02:03+00:00",
    netSellRowCount: 15,
    viItemCount: 9
  };
  assert.deepEqual(workflow.normalizeLegacyImportResponse(response, requestId), response);
  assert.equal(workflow.normalizeLegacyImportResponse({ ...response, markerVersion: 2 }), null);
  assert.equal(workflow.normalizeLegacyImportResponse({ ...response, path: "C:\\private" }), null);
  assert.equal(workflow.normalizeLegacyImportResponse(response, "other_request"), null);
});

test("net-sell save request requires exactly five strict rows", () => {
  const request = workflow.createNetSellSaveRequest(requestId, "INDIVIDUAL", rows());
  assert.equal(request.type, "save-manual-net-sell-data");
  assert.equal(request.payload.rows.length, 5);
  assert.throws(() => workflow.createNetSellSaveRequest(requestId, "INDIVIDUAL", rows().slice(0, 4)));
  assert.throws(() => workflow.createNetSellSaveRequest(requestId, "OTHER", rows()));
  assert.throws(() => workflow.createNetSellSaveRequest(requestId, "INDIVIDUAL", [
    ...rows().slice(0, 4),
    { ...rows()[4], extra: "not allowed" }
  ]));
});

test("manual value delimiter and control characters fail closed", () => {
  for (const leftName of ["bad^name", "bad\nname", "bad\u200Ename", "bad\uFFFDname"]) {
    const invalid = rows();
    invalid[0].leftName = leftName;
    assert.throws(() => workflow.createNetSellSaveRequest(requestId, "FOREIGN", invalid));
  }
});

test("VI save preserves order and duplicates but never exposes a path", () => {
  const request = workflow.createViSaveRequest(requestId, viItems(), rowVersion);
  assert.deepEqual(request, {
    type: "save-vi-manual-list",
    payload: { requestId, expectedVersion: rowVersion, items: viItems() }
  });
  assert.equal(JSON.stringify(request).includes("path"), false);
  assert.throws(() => workflow.createViSaveRequest(requestId, viItems()));
  assert.throws(() => workflow.createViSaveRequest(requestId, viItems(), "A".repeat(64)));
  assert.throws(() => workflow.createViSaveRequest(requestId, viItems(), "a".repeat(63)));
});

test("VI list rejects comma ambiguity, unsafe text, and more than twenty pages", () => {
  assert.throws(() => workflow.createViSaveRequest(requestId, [{ code: "1", name: "A,B" }], rowVersion));
  assert.throws(() => workflow.createViSaveRequest(requestId, [{ code: "1^2", name: "A" }], rowVersion));
  assert.throws(() => workflow.createViSaveRequest(requestId, [{ code: " 1", name: "A" }], rowVersion));
  assert.throws(() => workflow.createViSaveRequest(requestId, [{ code: "1", name: "A " }], rowVersion));
  assert.throws(() => workflow.createViSaveRequest(
    requestId,
    Array.from({ length: 101 }, (_, index) => ({ code: String(index + 1), name: `Item${index + 1}` })),
    rowVersion));
  assert.throws(() => workflow.createViSaveRequest(
    requestId,
    Array.from({ length: 101 }, (_, index) => ({ code: String(index + 1), name: `종목${index + 1}` }))));
});

test("status response preserves process-lifetime write quarantine", () => {
  assert.deepEqual(workflow.normalizeStatusResponse({
    requestId,
    available: true,
    code: "READY",
    writeQuarantined: true,
    message: "저장 결과를 확인할 수 없습니다."
  }, requestId), {
    requestId,
    available: true,
    code: "READY",
    writeQuarantined: true,
    message: "저장 결과를 확인할 수 없습니다."
  });
  assert.equal(workflow.normalizeStatusResponse({
    requestId,
    available: true,
    code: "READY",
    writeQuarantined: true,
    message: "저장 결과를 확인할 수 없습니다.",
    extra: true
  }), null);
  assert.equal(workflow.normalizeStatusResponse({
    requestId,
    available: true,
    code: "IMPORT_INTERRUPTED",
    writeQuarantined: false,
    message: null
  }), null);
  assert.equal(workflow.normalizeStatusResponse({
    requestId,
    available: false,
    code: "UNRECOGNIZED_STATE",
    writeQuarantined: false,
    message: "unavailable"
  }), null);
});

test("net-sell data response is correlated and exact", () => {
  const response = {
    requestId,
    audience: "INSTITUTION",
    rows: rows()
  };
  assert.ok(workflow.normalizeNetSellDataResponse(response, {
    requestId,
    audience: "INSTITUTION"
  }));
  assert.equal(workflow.normalizeNetSellDataResponse(response, {
    requestId: "other",
    audience: "INSTITUTION"
  }), null);
});

test("net-sell write result distinguishes verified recovery", () => {
  const response = {
    requestId,
    audience: "FOREIGN",
    saved: true,
    recovered: true
  };
  assert.ok(workflow.normalizeNetSellSaveResponse(response, { requestId, audience: "FOREIGN" }));
  assert.equal(workflow.normalizeNetSellSaveResponse({ ...response, saved: false }), null);
});

test("VI data response verifies item and page counts", () => {
  const response = {
    requestId,
    itemCount: 3,
    pageCount: 1,
    items: viItems(),
    version: rowVersion
  };
  assert.ok(workflow.normalizeViDataResponse(response, requestId));
  assert.equal(workflow.normalizeViDataResponse({ ...response, pageCount: 2 }), null);
  assert.equal(workflow.normalizeViDataResponse({ ...response, itemCount: 2 }), null);
});

test("VI save response rejects impossible page counts", () => {
  assert.ok(workflow.normalizeViSaveResponse({
    requestId,
    saved: true,
    recovered: false,
    persisted: true,
    itemCount: 100,
    pageCount: 20,
    version: rowVersion
  }, requestId));
  assert.equal(workflow.normalizeViSaveResponse({
    requestId,
    saved: true,
    recovered: false,
    persisted: true,
    itemCount: 100,
    pageCount: 19,
    version: rowVersion
  }), null);
  assert.ok(workflow.normalizeViSaveResponse({
    requestId,
    saved: true,
    recovered: false,
    persisted: false,
    itemCount: 3,
    pageCount: 1,
    version: rowVersion
  }, requestId));
});

test("OutcomeUnknown errors are never retryable", () => {
  assert.ok(workflow.normalizeError({
    requestId,
    operation: "save-vi",
    code: "OUTCOME_UNKNOWN",
    message: "저장 결과를 확인할 수 없습니다.",
    retryable: false,
    outcomeUnknown: true
  }, requestId));
  assert.equal(workflow.normalizeError({
    requestId,
    operation: "save-vi",
    code: "OUTCOME_UNKNOWN",
    message: "저장 결과를 확인할 수 없습니다.",
    retryable: true,
    outcomeUnknown: true
  }), null);
});

test("manual net-sell playlist mapping is original s5025 contract", () => {
  const entry = workflow.createNetSellPlaylistEntry("INDIVIDUAL", {
    id: "entry_1",
    fadeDuration: 6
  });
  assert.equal(entry.builderKey, "s5025");
  assert.equal(entry.code, "5025");
  assert.deepEqual(entry.selection, {
    groupCode: "INDIVIDUAL",
    subject: "INDEX",
    graphicType: "MANUAL_NET_SELL",
    subtype: "MANUAL_NET_SELL",
    dataCode: ""
  });
});

test("VI playlist mapping uses a bounded trusted snapshot reference", () => {
  const entry = workflow.createViPlaylistEntry(viItems(), rowVersion, {
    id: "entry_2",
    fadeDuration: 4
  });
  assert.equal(entry.builderKey, "s5074");
  assert.equal(entry.code, "5074");
  assert.equal(entry.itemCount, 3);
  assert.equal(entry.pageSize, 5);
  assert.equal(entry.pageCount, 1);
  assert.equal(entry.selection.groupCode, "PAGED_VI");
  assert.equal(entry.selection.subject, workflow.viSnapshotSubject);
  assert.equal(entry.selection.dataCode, rowVersion);
  assert.ok(entry.selection.subject.length <= 256);
});

test("one hundred realistic long names still create a bounded twenty-page reference", () => {
  const items = Array.from({ length: 100 }, (_, index) => ({
    code: `P${String(index + 1).padStart(6, "0")}`,
    name: `현실적인긴종목명테스트${index + 1}`
  }));
  const entry = workflow.createViPlaylistEntry(items, rowVersion, {
    id: "entry_20_pages",
    fadeDuration: 4
  });
  assert.equal(entry.pageCount, 20);
  assert.equal(entry.selection.subject, workflow.viSnapshotSubject);
  assert.equal(entry.selection.dataCode.length, 64);
  assert.equal(JSON.stringify(entry.selection).includes(items[0].name), false);
});

test("empty VI list can be saved but cannot create a playout entry", () => {
  assert.deepEqual(workflow.createViSaveRequest(requestId, [], rowVersion), {
    type: "save-vi-manual-list",
    payload: { requestId, expectedVersion: rowVersion, items: [] }
  });
  assert.throws(() => workflow.createViPlaylistEntry([], rowVersion, {
    id: "entry_empty",
    fadeDuration: 6
  }));
});

test("trusted restore recreates manual entries and rejects tampering", () => {
  const netSell = workflow.createNetSellPlaylistEntry("FOREIGN", {
    id: "entry_net",
    fadeDuration: 6
  });
  const vi = workflow.createViPlaylistEntry(viItems(), rowVersion, {
    id: "entry_vi",
    fadeDuration: 6
  });
  assert.deepEqual(workflow.restorePlaylistEntry(netSell), netSell);
  assert.deepEqual(workflow.restorePlaylistEntry(vi), vi);
  assert.equal(workflow.restorePlaylistEntry({
    ...vi,
    selection: { ...vi.selection, subject: "다른종목" }
  }), null);
  assert.equal(workflow.restorePlaylistEntry({ ...vi, extra: true }), null);
  assert.equal(workflow.restorePlaylistEntry({
    ...vi,
    operator: {
      ...vi.operator,
      pagePreview: { ...vi.operator.pagePreview, pageCount: 20 }
    }
  }), null);
  assert.equal(workflow.restorePlaylistEntry({
    ...vi,
    selection: { ...vi.selection, extra: true }
  }), null);
  assert.equal(workflow.restorePlaylistEntry({
    ...netSell,
    operator: { ...netSell.operator, audience: "OTHER" }
  }), null);
});

test("only entries bound to a correlated fresh read are playable", () => {
  const unverifiedNet = workflow.createNetSellPlaylistEntry("FOREIGN", {
    id: "entry_net_unverified",
    fadeDuration: 6
  });
  assert.equal(workflow.isPlaylistEntryPlayable(unverifiedNet), false);

  const netRead = workflow.normalizeNetSellDataResponse({
    requestId,
    audience: "FOREIGN",
    rows: rows()
  }, { requestId, audience: "FOREIGN" });
  const verifiedNet = workflow.createNetSellPlaylistEntry("FOREIGN", {
    id: "entry_net_verified",
    fadeDuration: 6
  }, netRead);
  assert.equal(workflow.isPlaylistEntryPlayable(verifiedNet), true);
  assert.equal(workflow.isPlaylistEntryPlayable(
    workflow.restorePlaylistEntry(JSON.parse(JSON.stringify(verifiedNet)))), false);
  assert.equal(workflow.isPlaylistEntryPlayable(workflow.createNetSellPlaylistEntry(
    "INDIVIDUAL",
    { id: "entry_net_wrong_audience", fadeDuration: 6 },
    netRead)), false);

  const viRead = workflow.normalizeViDataResponse({
    requestId,
    itemCount: viItems().length,
    pageCount: 1,
    items: viItems(),
    version: rowVersion
  }, requestId);
  const verifiedVi = workflow.createViPlaylistEntry(viItems(), rowVersion, {
    id: "entry_vi_verified",
    fadeDuration: 6
  }, viRead);
  assert.equal(workflow.isPlaylistEntryPlayable(verifiedVi), true);
  assert.equal(workflow.isPlaylistEntryPlayable(workflow.createViPlaylistEntry(
    viItems().slice(0, 2),
    rowVersion,
    { id: "entry_vi_stale", fadeDuration: 6 },
    viRead)), false);
});

test("enabled replacement transfers only the correlated manual-list read capability", () => {
  const netRead = workflow.normalizeNetSellDataResponse({
    requestId,
    audience: "FOREIGN",
    rows: rows()
  }, { requestId, audience: "FOREIGN" });
  const entry = workflow.createNetSellPlaylistEntry("FOREIGN", {
    id: "entry_net_toggle",
    fadeDuration: 6
  }, netRead);
  const replacement = workflow.withEnabled(entry, false);

  assert.notEqual(replacement, entry);
  assert.equal(entry.enabled, true);
  assert.equal(replacement.enabled, false);
  assert.equal(workflow.isPlaylistEntryPlayable(replacement), true);
  assert.equal(workflow.withEnabled({ ...entry }, false), null);
  assert.equal(workflow.withEnabled(workflow.createNetSellPlaylistEntry("FOREIGN", {
    id: "entry_net_toggle_untrusted",
    fadeDuration: 6
  }), false), null);
});

test("request identifiers and option bounds are strict", () => {
  assert.throws(() => workflow.createStatusRequest("bad id"));
  assert.throws(() => workflow.createNetSellPlaylistEntry("FOREIGN", {
    id: "entry",
    fadeDuration: 61
  }));
  assert.throws(() => workflow.createViPlaylistEntry(viItems(), rowVersion, {
    id: "entry",
    fadeDuration: -1
  }));
  assert.throws(() => workflow.createViPlaylistEntry(viItems(), "A".repeat(64), {
    id: "entry",
    fadeDuration: 4
  }));
});
