"use strict";

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

const retrievedAt = "2026-07-11T09:30:00+09:00";
const selection = Object.freeze({
  groupCode: "KOSPI",
  subject: "Samsung Electronics",
  graphicType: "ONE_COLUMN",
  subtype: "CURRENT",
  dataCode: "005930"
});

function trustedEntry(overrides = {}) {
  return {
    id: "entry-1",
    builderKey: "s5001",
    code: "5001",
    enabled: true,
    selection,
    operator: { source: "trusted-test", schemaVersion: 1 },
    ...overrides
  };
}

function nativeRow(overrides = {}) {
  return {
    itemIndex: 0,
    enabled: true,
    groupCode: "KOSPI",
    subject: "Samsung Electronics",
    graphicType: "ONE_COLUMN",
    subtype: "CURRENT",
    pageText: "1/1",
    dataCode: "005930",
    ...overrides
  };
}

function loadResponse(rows = [nativeRow()], overrides = {}) {
  return {
    requestId: "load-1",
    definition: { programCode: "00000001", title: "Morning" },
    retrievedAt,
    totalRowCount: rows.length,
    canMutate: true,
    writeQuarantined: false,
    rows,
    ...overrides
  };
}

test("named-playlist bridge contract is closed and bounded", () => {
  assert.deepEqual(workflow.bridgeContract, {
    requests: {
      list: "request-named-playlist-list",
      load: "request-named-playlist-load",
      create: "create-named-playlist",
      replace: "replace-named-playlist",
      delete: "delete-named-playlist"
    },
    responses: {
      list: "named-playlist-list-results",
      load: "named-playlist-load-results",
      mutation: "named-playlist-mutation-results",
      error: "named-playlist-error"
    },
    schemaVersion: 1,
    maximumDefinitions: 1000,
    maximumItems: 1000,
    maximumPageCount: 20,
    maximumStoredPageCount: 9999
  });
  assert.deepEqual(workflow.operations, ["list", "load", "create", "replace", "delete"]);
});

test("list, load, create and delete request payloads require canonical identities", () => {
  assert.deepEqual(workflow.createListRequest("list-1"), {
    requestId: "list-1", maximumResults: 200
  });
  assert.deepEqual(workflow.createListRequest("list-2", 1000), {
    requestId: "list-2", maximumResults: 1000
  });
  assert.deepEqual(workflow.createLoadRequest("load-1", "00000001"), {
    requestId: "load-1", programCode: "00000001"
  });
  assert.deepEqual(workflow.createDefinitionRequest("create-1", "00000001", "Morning"), {
    requestId: "create-1", programCode: "00000001", title: "Morning"
  });
  assert.deepEqual(workflow.createDeleteRequest("delete-1", "00000001"), {
    requestId: "delete-1", programCode: "00000001"
  });
  assert.throws(() => workflow.createListRequest("bad id"), /safe/i);
  assert.throws(() => workflow.createListRequest("list", 1001), /1 through 1000/i);
  assert.throws(() => workflow.createLoadRequest("load", "1"), /eight-digit/i);
  assert.throws(() => workflow.createDefinitionRequest("create", "00000001", " Morning"), /canonical/i);
});

test("trusted entries serialize to the exact original seven LIST_TEXT fields", () => {
  const entry = trustedEntry();
  assert.deepEqual(workflow.toLegacySevenFields(entry, "2/4"), [
    "1", "KOSPI", "Samsung Electronics", "ONE_COLUMN", "CURRENT", "2/4", "005930"
  ]);
  assert.equal(
    workflow.legacyListText(workflow.toLegacySevenFields(entry, "2/4")),
    "1^KOSPI^Samsung Electronics^ONE_COLUMN^CURRENT^2/4^005930");

  const request = workflow.createReplaceRequest(
    "replace-1",
    "00000001",
    [entry, trustedEntry({ id: "entry-2", enabled: false })],
    {
      isTrustedEntry: () => true,
      pageTextForEntry: (_, index) => index === 0 ? "2/4" : "1/0"
    });
  assert.deepEqual(request.items, [
    {
      itemIndex: 0, enabled: true, groupCode: "KOSPI", subject: "Samsung Electronics",
      graphicType: "ONE_COLUMN", subtype: "CURRENT", pageText: "2/4", dataCode: "005930"
    },
    {
      itemIndex: 1, enabled: false, groupCode: "KOSPI", subject: "Samsung Electronics",
      graphicType: "ONE_COLUMN", subtype: "CURRENT", pageText: "1/0", dataCode: "005930"
    }
  ]);
});

test("legacy LIST_TEXT accepts a bounded 100-name VI subject beyond 256 characters", () => {
  const names = Array.from(
    { length: 100 },
    (_, index) => `종목${String(index).padStart(3, "0")}`).join(",");
  assert.ok(names.length > 256);
  const fields = ["1", "", names, "5단 표그래프", "VI 발동(수동)", "1/20", ""];
  const serialized = workflow.legacyListText(fields);
  assert.ok(serialized.length <= 4000);
  const response = workflow.normalizeLoadResponse(loadResponse([nativeRow({
    groupCode: "",
    subject: names,
    graphicType: "5단 표그래프",
    subtype: "VI 발동(수동)",
    pageText: "1/20",
    dataCode: ""
  })]));
  assert.equal(response.rawRows[0].subject, names);
  assert.throws(() => workflow.legacyListText([
    "1", "", "가".repeat(3990), "5단 표그래프", "VI 발동(수동)", "1/20", ""
  ]), /too long/u);
});

test("database replacement requires trusted verification and enforces the 1000-row bound", () => {
  assert.throws(() => workflow.createReplaceRequest(
    "replace", "00000001", [trustedEntry()]), /trusted-entry verifier/i);
  assert.throws(() => workflow.createReplaceRequest(
    "replace", "00000001", [trustedEntry()], { isTrustedEntry: () => false }), /trusted restoration/i);

  const entries = Array.from({ length: 1000 }, (_, index) => trustedEntry({ id: `entry-${index}` }));
  const bounded = workflow.createReplaceRequest(
    "replace", "00000001", entries, { isTrustedEntry: () => true });
  assert.equal(bounded.items.length, 1000);
  assert.throws(() => workflow.createReplaceRequest(
    "replace", "00000001", [...entries, trustedEntry({ id: "overflow" })],
    { isTrustedEntry: () => true }), /at most 1000/i);
});

test("unsafe selection delimiters, controls and noncanonical pages never reach the bridge", () => {
  assert.throws(() => workflow.toLegacySevenFields(trustedEntry({
    selection: { ...selection, subject: "Alpha^Beta" }
  })), /cannot be stored safely/i);
  assert.throws(() => workflow.toLegacySevenFields(trustedEntry({
    selection: { ...selection, subject: "Alpha\nBeta" }
  })), /cannot be stored safely/i);
  assert.throws(() => workflow.toLegacySevenFields(trustedEntry(), "01/02"), /page text/i);
  assert.throws(() => workflow.toLegacySevenFields(trustedEntry(), "0/0"), /page text/i);
  assert.throws(() => workflow.toLegacySevenFields(trustedEntry(), "1/10000"), /page text/i);
  assert.throws(() => workflow.legacyListText([
    "yes", "KOSPI", "Alpha", "ONE_COLUMN", "CURRENT", "1/1", "000001"
  ]), /seven safe/i);
});

test("stored page parsing preserves historical values but marks the current 20-page boundary", () => {
  assert.deepEqual(workflow.parseStoredPageText("4/20"), {
    pageText: "4/20", currentPage: 4, totalPages: 20,
    isWithinPlayoutBounds: true,
    historicalPageOutOfBounds: false,
    pageRecalculationRequired: true
  });
  assert.deepEqual(workflow.parseStoredPageText("1/0"), {
    pageText: "1/0", currentPage: 1, totalPages: 0,
    isWithinPlayoutBounds: false,
    historicalPageOutOfBounds: true,
    pageRecalculationRequired: true
  });
  assert.deepEqual(workflow.parseStoredPageText("1/24"), {
    pageText: "1/24", currentPage: 1, totalPages: 24,
    isWithinPlayoutBounds: false,
    historicalPageOutOfBounds: true,
    pageRecalculationRequired: true
  });
  for (const value of ["", "01/1", "1/01", "2/1", "2/0", "1/10000", "text"]) {
    assert.equal(workflow.parseStoredPageText(value), null);
  }
});

test("list responses are exact, duplicate-safe and expose permanent write quarantine", () => {
  const response = {
    requestId: "list-1",
    retrievedAt,
    totalRowCount: 2,
    truncated: false,
    suggestedProgramCode: "00000003",
    canMutate: true,
    writeQuarantined: false,
    definitions: [
      { programCode: "00000001", title: "Morning" },
      { programCode: "00000002", title: "Noon" }
    ]
  };
  assert.deepEqual(workflow.normalizeListResponse(response), response);
  assert.equal(workflow.normalizeListResponse({ ...response, extra: true }), null);
  assert.equal(workflow.normalizeListResponse({
    ...response,
    definitions: [response.definitions[0], response.definitions[0]]
  }), null);
  assert.equal(workflow.normalizeListResponse({
    ...response, canMutate: true, writeQuarantined: true
  }), null);
  const quarantined = workflow.normalizeListResponse({
    ...response, canMutate: false, writeQuarantined: true
  });
  assert.equal(workflow.isWriteBlocked(quarantined), true);
});

test("load normalization preserves every raw field before trusted restoration", () => {
  const normalized = workflow.normalizeLoadResponse(loadResponse([
    nativeRow({ pageText: "1/24" }),
    nativeRow({ itemIndex: 1, enabled: false, subject: "SK hynix", dataCode: "000660", pageText: "" })
  ]));
  assert.ok(normalized);
  assert.equal(normalized.rawRows.length, 2);
  assert.deepEqual(normalized.rawRows[0].legacyFields, [
    "1", "KOSPI", "Samsung Electronics", "ONE_COLUMN", "CURRENT", "1/24", "005930"
  ]);
  assert.equal(normalized.rawRows[0].listText,
    "1^KOSPI^Samsung Electronics^ONE_COLUMN^CURRENT^1/24^005930");
  assert.equal(normalized.rawRows[0].requiresTrustedRestore, true);
  assert.equal(normalized.rawRows[0].pageRecalculationRequired, true);
  assert.equal(normalized.rawRows[0].historicalPageOutOfBounds, true);
  assert.equal(normalized.rawRows[1].requiresTrustedRestore, true);
  assert.equal(normalized.rawRows[1].pageRecalculationRequired, false);
  assert.match(workflow.pageStatusLabel(normalized.rawRows[0]), /20-page maximum/i);
});

test("load responses reject extra fields, reordered indexes and malformed historical pages", () => {
  assert.equal(workflow.normalizeLoadResponse({ ...loadResponse(), extra: true }), null);
  assert.equal(workflow.normalizeLoadResponse(loadResponse([nativeRow({ itemIndex: 1 })])), null);
  assert.equal(workflow.normalizeLoadResponse(loadResponse([nativeRow({ pageText: "01/1" })])), null);
  assert.equal(workflow.normalizeLoadResponse(loadResponse([nativeRow({ subject: " Alpha" })])), null);
  assert.equal(workflow.normalizeLoadResponse(loadResponse([], { totalRowCount: 1 })), null);
});

test("raw database rows cannot become playable until a trusted resolver reconstructs them", () => {
  const load = workflow.normalizeLoadResponse(loadResponse());
  const unresolved = workflow.restoreRawRows(load, () => null);
  assert.equal(unresolved.readyForPrepare, false);
  assert.deepEqual(unresolved.blockers.map(value => value.reason), [
    "trusted-restore-required", "page-recalculation-required"
  ]);
  assert.throws(() => workflow.materializeTrustedPlaylist(unresolved), /trusted restoration/i);
  assert.equal(unresolved.rows[0].rawRow, load.rawRows[0]);
});

test("trusted resolver output must reproduce enabled state and all five selection fields", () => {
  const load = workflow.normalizeLoadResponse(loadResponse());
  assert.equal(workflow.restoreRawRows(load, () => trustedEntry()).rows[0].trusted, true);
  assert.equal(workflow.restoreRawRows(load, () => trustedEntry({ enabled: false })).rows[0].trusted, false);
  assert.equal(workflow.restoreRawRows(load, () => trustedEntry({
    selection: { ...selection, subject: "Tampered" }
  })).rows[0].trusted, false);
  assert.equal(workflow.restoreRawRows(load, () => trustedEntry({ code: "bad" })).rows[0].trusted, false);
  assert.equal(workflow.restoreRawRows(load, () => trustedEntry({ builderKey: "5001" })).rows[0].trusted, false);
});

test("fresh page plans are mandatory before PREPARE and cannot exceed 20 pages", () => {
  const load = workflow.normalizeLoadResponse(loadResponse([nativeRow({ pageText: "1/24" })]));
  const restored = workflow.restoreRawRows(load, () => trustedEntry());
  assert.equal(restored.readyForPrepare, false);
  assert.throws(() => workflow.markPageRecalculated(restored, 0, {
    itemCount: 101, pageSize: 5, pageCount: 21
  }), /exceeds 20 pages/i);

  const recalculated = workflow.markPageRecalculated(restored, 0, {
    itemCount: 100, pageSize: 5, pageCount: 20
  });
  assert.equal(recalculated.readyForPrepare, true);
  assert.equal(workflow.canPrepareRestoredRows(recalculated), true);
  assert.deepEqual(recalculated.rows[0].recalculatedPagePlan, {
    itemCount: 100, pageSize: 5, pageCount: 20
  });
  assert.equal(recalculated.rows[0].rawRow.pageText, "1/24");
  assert.deepEqual(workflow.materializeTrustedPlaylist(recalculated), [trustedEntry()]);
});

test("paged trusted rows create a strict preflight request without using stored page text", () => {
  function prepare(pageText) {
    const load = workflow.normalizeLoadResponse(loadResponse([nativeRow({ pageText })]));
    const restored = workflow.restoreRawRows(load, () => trustedEntry({
      builderKey: "s5074",
      code: "5074",
      fadeDuration: 6
    }));
    return workflow.preparePagePreflight(restored, entry => entry.builderKey === "s5074");
  }

  const request = workflow.createPagePlanRequest("page-plan-1", prepare("1/24"));
  const requestFromDifferentHistoricalPage = workflow.createPagePlanRequest(
    "page-plan-1",
    prepare("4/20"));

  assert.deepEqual(request, {
    requestId: "page-plan-1",
    entries: [{
      id: "entry-1",
      code: "5074",
      enabled: true,
      fadeDuration: 6,
      selection
    }]
  });
  assert.deepEqual(requestFromDifferentHistoricalPage, request);
  assert.equal(Object.hasOwn(request.entries[0], "pageText"), false);
});

test("correlated capped page plans enable PREPARE and preserve explicit truncation", () => {
  const load = workflow.normalizeLoadResponse(loadResponse([nativeRow({ pageText: "1/24" })]));
  let restored = workflow.restoreRawRows(load, () => trustedEntry({
    builderKey: "s5074",
    code: "5074",
    fadeDuration: 6
  }));
  restored = workflow.preparePagePreflight(restored, () => true);
  const request = workflow.createPagePlanRequest("page-plan-2", restored);
  const rawResponse = {
    requestId: "page-plan-2",
    calculatedAt: retrievedAt,
    plans: [{
      entryId: "entry-1",
      itemCount: 101,
      pageSize: 5,
      pageCount: 20,
      accessibleItemCount: 100,
      isTruncated: true,
      builderKey: "s5074"
    }]
  };
  const response = workflow.normalizePagePlanResponse(rawResponse, request);
  assert.ok(response);
  restored = workflow.applyPagePlanResponse(restored, response);

  assert.equal(restored.readyForPrepare, true);
  assert.equal(restored.rows[0].pageRecalculated, true);
  assert.deepEqual(restored.rows[0].recalculatedPagePlan, rawResponse.plans[0]);
  assert.equal(workflow.normalizePagePlanResponse({
    ...rawResponse,
    requestId: "stale-request"
  }, request), null);
  assert.equal(workflow.normalizePagePlanResponse({
    ...rawResponse,
    plans: [{ ...rawResponse.plans[0], accessibleItemCount: 101 }]
  }, request), null);
});

test("zero-row page preflight is complete but explicitly unavailable for PREPARE", () => {
  const load = workflow.normalizeLoadResponse(loadResponse([nativeRow({ pageText: "" })]));
  let restored = workflow.restoreRawRows(load, () => trustedEntry({
    builderKey: "s5077",
    code: "5077",
    fadeDuration: 6
  }));
  restored = workflow.preparePagePreflight(restored, () => true);
  const request = workflow.createPagePlanRequest("page-plan-zero", restored);
  const response = workflow.normalizePagePlanResponse({
    requestId: "page-plan-zero",
    calculatedAt: retrievedAt,
    plans: [{
      entryId: "entry-1",
      itemCount: 0,
      pageSize: 6,
      pageCount: 0,
      accessibleItemCount: 0,
      isTruncated: false,
      builderKey: "s5077"
    }]
  }, request);
  restored = workflow.applyPagePlanResponse(restored, response);

  assert.equal(restored.readyForPrepare, false);
  assert.equal(restored.rows[0].pagePreflightCompleted, true);
  assert.equal(restored.rows[0].pageRecalculated, false);
  assert.deepEqual(restored.blockers.map(value => value.reason), ["page-result-empty"]);
});

test("nonpaged rows become ready without a synthetic page-plan result", () => {
  const load = workflow.normalizeLoadResponse(loadResponse([nativeRow({ pageText: "1/24" })]));
  const restored = workflow.preparePagePreflight(
    workflow.restoreRawRows(load, () => trustedEntry()),
    () => false);
  assert.equal(restored.readyForPrepare, true);
  assert.equal(workflow.createPagePlanRequest("no-page-plan", restored), null);
  assert.equal(restored.rows[0].recalculatedPagePlan, null);
});

test("rows without stored page text still require trust but no artificial page plan", () => {
  const load = workflow.normalizeLoadResponse(loadResponse([nativeRow({ pageText: "" })]));
  const restored = workflow.restoreRawRows(load, () => trustedEntry());
  assert.equal(restored.readyForPrepare, true);
  assert.deepEqual(restored.blockers, []);
  assert.equal(restored.rows[0].pageRecalculated, true);
});

test("mutation responses are strict and retain quarantine state", () => {
  const response = {
    requestId: "replace-1",
    operation: "replace",
    programCode: "00000001",
    completedAt: retrievedAt,
    writeQuarantined: false
  };
  assert.deepEqual(workflow.normalizeMutationResponse(response), response);
  assert.equal(workflow.normalizeMutationResponse({ ...response, operation: "load" }), null);
  assert.equal(workflow.normalizeMutationResponse({ ...response, extra: true }), null);
});

test("OutcomeUnknown errors permanently block writes and can never be retryable", () => {
  const error = {
    requestId: "replace-1",
    operation: "replace",
    programCode: "00000001",
    code: "OUTCOME_UNKNOWN",
    message: "Restart before another write.",
    retryable: false,
    outcomeUnknown: true,
    writeQuarantined: true
  };
  const normalized = workflow.normalizeError(error);
  assert.deepEqual(normalized, error);
  assert.equal(workflow.isWriteBlocked(normalized), true);
  assert.equal(workflow.normalizeError({ ...error, retryable: true }), null);
  assert.equal(workflow.normalizeError({ ...error, writeQuarantined: false }), null);
  assert.equal(workflow.normalizeError({ ...error, extra: true }), null);
});
