"use strict";

const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const workflow = require("../../Web/operator-catalog-workflow.js");
const ui = require("../../Web/operator-catalog-ui.js");

const retrievedAt = "2026-07-12T10:00:00+09:00";

function themeContext(title = "AI 반도체") {
  return {
    selection: {
      market: "krx",
      session: "notApplicable",
      themeCode: "00000001",
      themeTitle: title,
      source: "oracle"
    },
    preview: {
      requestId: "theme-preview-1",
      selection: {
        market: "krx",
        session: "notApplicable",
        themeCode: "00000001",
        title
      },
      retrievedAt,
      totalRowCount: 2,
      truncated: false,
      items: [
        { inputIndex: 0, itemCode: "P005930", itemName: "삼성전자" },
        { inputIndex: 4, itemCode: "D035720", itemName: "카카오" }
      ]
    }
  };
}

function expertContext() {
  return {
    selection: { expertCode: "0001", expertName: "홍길동", source: "oracle" },
    preview: {
      requestId: "expert-preview-1",
      selection: { expertCode: "0001", expertName: "홍길동" },
      retrievedAt,
      totalRowCount: 2,
      truncated: false,
      items: [
        { playIndex: 0, stockCode: "005930", stockName: "삼성전자", buyAmount: 70000 },
        { playIndex: 8, stockCode: "035720", stockName: "카카오", buyAmount: 45000 }
      ]
    }
  };
}

function harness() {
  const posted = [];
  const toasts = [];
  const logs = [];
  let sequence = 0;
  let isLocked = false;
  const controller = ui.createController({
    postNative(type, payload) { posted.push({ type, payload }); },
    isLocked() { return isLocked; },
    showToast(message) { toasts.push(message); },
    addLog(message) { logs.push(message); },
    createId() { sequence += 1; return String(sequence); }
  });
  return {
    controller,
    posted,
    toasts,
    logs,
    setExternalLocked(value) { isLocked = value; }
  };
}

function completeSchema(h, overrides = {}) {
  const pending = h.controller.render().activeRead;
  assert.equal(pending.type, workflow.bridgeContract.requests.schemaPreflight);
  h.controller.handleMessage(workflow.bridgeContract.events.schemaPreflight, {
    requestId: pending.request.requestId,
    validatedAt: retrievedAt,
    validatedSources: ["oracle", "mariaDb"],
    themeCanMutate: true,
    expertCanMutate: true,
    writeQuarantined: false,
    ...overrides
  });
}

function searchAndSelectStock(h, identity, query = identity.name, expectedSelection = true) {
  assert.equal(h.controller.searchStocks(query), true);
  const pending = h.controller.render().activeRead;
  assert.equal(pending.type, workflow.stockMasterContract.request);
  assert.equal(h.controller.handleMessage(workflow.stockMasterContract.result, {
    requestId: pending.request.requestId,
    query: pending.request.query,
    retrievedAt,
    totalRowCount: 1,
    truncated: false,
    results: [identity]
  }), true);
  assert.equal(h.controller.selectStockResult(0), expectedSelection);
}

function mutationResult(active, overrides = {}) {
  return {
    requestId: active.requestId,
    entity: active.entity,
    operation: active.operation,
    identityCode: active.identityCode,
    operationId: "123e4567-e89b-12d3-a456-426614174000",
    committedAt: retrievedAt,
    writeQuarantined: false,
    ...overrides
  };
}

test("controller exposes the independent integration API", () => {
  const h = harness();
  for (const method of [
    "mount", "openTheme", "openExpert", "handleMessage", "render", "setLocked"
  ]) assert.equal(typeof h.controller[method], "function");
});

test("theme edit starts only from a closed selection and complete preview", () => {
  const h = harness();
  const opened = h.controller.openTheme(themeContext());

  assert.equal(opened.entity, "theme");
  assert.equal(opened.mode, "edit");
  assert.deepEqual(opened.draft.items, [
    { inputIndex: 0, itemCode: "P005930", itemName: "삼성전자" },
    { inputIndex: 1, itemCode: "D035720", itemName: "카카오" }
  ]);
  assert.equal(h.posted[0].type, workflow.bridgeContract.requests.schemaPreflight);
  assert.throws(() => h.controller.openTheme({ selection: themeContext().selection }), /exactly/i);
});

test("theme stock identities, duplicate checks and reordering remain explicit in replace requests", () => {
  const h = harness();
  h.controller.openTheme(themeContext());
  completeSchema(h);
  assert.equal(h.controller.addThemeItem("P", "000660", "SK하이닉스"), false);
  searchAndSelectStock(h, {
    market: "kospi", source: "oracle", code: "000660", name: "SK하이닉스"
  });
  assert.equal(h.controller.addThemeItem(), true);
  assert.equal(h.controller.moveDraftItem(2, -1), true);
  assert.equal(h.controller.updateDraft({ themeTitle: "AI·로봇" }), true);

  assert.deepEqual(h.controller.render().draft.items, [
    { inputIndex: 0, itemCode: "P005930", itemName: "삼성전자" },
    { inputIndex: 1, itemCode: "P000660", itemName: "SK하이닉스" },
    { inputIndex: 2, itemCode: "D035720", itemName: "카카오" }
  ]);
  assert.equal(h.controller.submit(), true);
  const sent = h.posted.at(-1);
  assert.equal(sent.type, workflow.bridgeContract.requests.themeReplace);
  assert.equal(sent.payload.newTitle, "AI·로봇");
  assert.deepEqual(sent.payload.items, h.controller.render().draft.items);

  const active = h.controller.render().mutation;
  h.controller.handleMessage(workflow.bridgeContract.events.mutation,
    mutationResult(active, { requestId: "stale" }));
  assert.deepEqual(h.controller.render().mutation, active);
  h.controller.handleMessage(workflow.bridgeContract.events.mutation, mutationResult(active));
  assert.equal(h.controller.render().mutation, null);
  assert.equal(h.controller.render().mode, "list");
});

test("schema, catalog search and next-code reads are serialized and exactly correlated", () => {
  const h = harness();
  h.controller.openTheme();
  assert.equal(h.posted.length, 1);
  completeSchema(h);
  assert.equal(h.posted.at(-1).type, workflow.bridgeContract.requests.themeList);

  let pending = h.controller.render().activeRead;
  h.controller.handleMessage(workflow.bridgeContract.events.themeList, {
    requestId: pending.request.requestId,
    query: "",
    nxtSession: "preMarket",
    retrievedAt,
    totalRowCount: 1,
    truncated: false,
    canMutate: true,
    writeQuarantined: false,
    results: [{
      market: "krx", session: "notApplicable", themeCode: "00000001",
      themeTitle: "AI", source: "oracle"
    }]
  });
  assert.equal(h.controller.render().themeResults.length, 1);

  h.controller.search("old", "afterMarket");
  const old = h.controller.render().activeRead;
  h.controller.search("latest", "afterMarket");
  assert.equal(h.controller.render().activeRead.superseded, true);
  h.controller.handleMessage(workflow.bridgeContract.events.themeList, {
    requestId: old.request.requestId,
    query: "old",
    nxtSession: "afterMarket",
    retrievedAt,
    totalRowCount: 0,
    truncated: false,
    canMutate: true,
    writeQuarantined: false,
    results: []
  });
  pending = h.controller.render().activeRead;
  assert.equal(pending.request.query, "latest");
  assert.equal(h.controller.render().themeResults.length, 1);
  h.controller.handleMessage(workflow.bridgeContract.events.themeList, {
    requestId: pending.request.requestId,
    query: "latest",
    nxtSession: "afterMarket",
    retrievedAt,
    totalRowCount: 0,
    truncated: false,
    canMutate: true,
    writeQuarantined: false,
    results: []
  });
  assert.equal(h.controller.render().themeResults.length, 0);

  assert.equal(h.controller.beginNewTheme("nxt"), true);
  pending = h.controller.render().activeRead;
  h.controller.handleMessage(workflow.bridgeContract.events.themeNextCode, {
    requestId: pending.request.requestId,
    market: "nxt",
    themeCode: "00000007",
    canMutate: true,
    writeQuarantined: false
  });
  assert.equal(h.controller.render().mode, "new");
  searchAndSelectStock(h, {
    market: "kospi", source: "oracle", code: "005930", name: "삼성전자"
  }, "삼성전자", false);
  assert.equal(h.controller.addThemeItem(), false);
  searchAndSelectStock(h, {
    market: "nxt-kospi", source: "mariaDb", code: "005930", name: "삼성전자(NXT)"
  });
  assert.equal(h.controller.addThemeItem(), true);
  searchAndSelectStock(h, {
    market: "nxt-kosdaq", source: "mariaDb", code: "000660", name: "SK하이닉스(NXT)"
  });
  assert.equal(h.controller.addThemeItem(), true);
  assert.deepEqual(h.controller.render().draft.items, [
    { inputIndex: 0, itemCode: "X005930", itemName: "삼성전자" },
    { inputIndex: 1, itemCode: "T000660", itemName: "SK하이닉스" }
  ]);
});

test("expert recommendations preserve exact code, name, buy amount and order", () => {
  const h = harness();
  h.controller.openExpert(expertContext());
  completeSchema(h);
  assert.equal(h.controller.addExpertRecommendation("005930", "중복", 1), false);
  searchAndSelectStock(h, {
    market: "kospi", source: "oracle", code: "000660", name: "SK하이닉스"
  });
  assert.equal(h.controller.addExpertRecommendation("123456"), true);
  assert.equal(h.controller.moveDraftItem(2, -1), true);
  assert.equal(h.controller.updateDraft({ expertName: "홍길동 위원" }), true);
  assert.equal(h.controller.submit(), true);

  const sent = h.posted.at(-1);
  assert.equal(sent.type, workflow.bridgeContract.requests.expertReplace);
  assert.equal(sent.payload.newName, "홍길동 위원");
  assert.deepEqual(sent.payload.recommendations, [
    { playIndex: 0, stockCode: "005930", stockName: "삼성전자", buyAmount: 70000 },
    { playIndex: 1, stockCode: "000660", stockName: "SK하이닉스", buyAmount: 123456 },
    { playIndex: 2, stockCode: "035720", stockName: "카카오", buyAmount: 45000 }
  ]);
});

test("stock search stale and truncated responses fail closed without selecting free text", () => {
  const h = harness();
  h.controller.openTheme(themeContext());
  completeSchema(h);
  assert.equal(h.controller.searchStocks("삼성"), true);
  const pending = h.controller.render().activeRead;
  assert.equal(h.controller.handleMessage(workflow.stockMasterContract.result, {
    requestId: "stale-request",
    query: pending.request.query,
    retrievedAt,
    totalRowCount: 0,
    truncated: false,
    results: []
  }), false);
  assert.equal(h.controller.render().activeRead.request.requestId, pending.request.requestId);
  assert.equal(h.controller.handleMessage(workflow.stockMasterContract.result, {
    requestId: pending.request.requestId,
    query: pending.request.query,
    retrievedAt,
    totalRowCount: 1,
    truncated: true,
    results: [{
      market: "kospi", source: "oracle", code: "005930", name: "삼성전자"
    }]
  }), true);
  assert.equal(h.controller.render().stockSearch.selected, null);
  assert.equal(h.controller.render().stockSearch.results.length, 0);
  assert.equal(h.controller.addThemeItem("P", "005930", "삼성전자"), false);
});

test("OutcomeUnknown latches a process quarantine and never retries", () => {
  const h = harness();
  h.controller.openExpert(expertContext());
  completeSchema(h);
  h.controller.updateDraft({ expertName: "홍길동 위원" });
  assert.equal(h.controller.submit(), true);
  const active = h.controller.render().mutation;
  const writeCount = h.posted.length;
  h.controller.handleMessage(workflow.bridgeContract.events.error, {
    requestId: active.requestId,
    entity: active.entity,
    operation: active.operation,
    identityCode: active.identityCode,
    code: "OUTCOME_UNKNOWN",
    message: "Restart and reconcile before another write.",
    retryable: false,
    outcomeUnknown: true,
    writeQuarantined: true
  });

  assert.equal(h.controller.render().quarantined, true);
  assert.equal(h.controller.render().mutation, null);
  assert.equal(h.controller.submit(), false);
  assert.equal(h.posted.length, writeCount);
});

test("a malformed same-request mutation result loses correlation and quarantines", () => {
  const h = harness();
  h.controller.openTheme(themeContext());
  completeSchema(h);
  assert.equal(h.controller.submit(), true);
  const active = h.controller.render().mutation;
  h.controller.handleMessage(workflow.bridgeContract.events.mutation, {
    ...mutationResult(active),
    operationId: "not-a-uuid"
  });
  assert.equal(h.controller.render().quarantined, true);
  assert.equal(h.controller.render().mutation, null);
});

class FakeElement {
  constructor(tagName, ownerDocument) {
    this.tagName = tagName.toUpperCase();
    this.ownerDocument = ownerDocument;
    this.children = [];
    this.attributes = new Map();
    this.listeners = new Map();
    this.textContent = "";
    this.value = "";
    this.hidden = false;
    this.disabled = false;
    this.open = false;
    this.parentElement = null;
    this.isContentEditable = false;
  }
  append(...values) { values.forEach(value => this.appendChild(value)); }
  appendChild(value) {
    value.parentElement = this;
    this.children.push(value);
    return value;
  }
  replaceChildren(...values) {
    this.children.forEach(value => { value.parentElement = null; });
    this.children = [];
    this.append(...values);
  }
  setAttribute(name, value) { this.attributes.set(name, String(value)); }
  getAttribute(name) { return this.attributes.has(name) ? this.attributes.get(name) : null; }
  removeAttribute(name) { this.attributes.delete(name); }
  addEventListener(name, callback) { this.listeners.set(name, callback); }
  contains(value) {
    if (value === this) return true;
    return this.children.some(child => child.contains(value));
  }
  dispatchEvent(event) {
    if (!event.target) event.target = this;
    event.currentTarget = this;
    const callback = this.listeners.get(event.type);
    if (callback) callback(event);
    if (!event.propagationStopped && this.parentElement) this.parentElement.dispatchEvent(event);
    return !event.defaultPrevented;
  }
}

class FakeDocument {
  constructor() {
    this.head = new FakeElement("head", this);
    this.body = new FakeElement("body", this);
  }
  createElement(tagName) { return new FakeElement(tagName, this); }
  getElementById(id) {
    function find(node) {
      if (node.id === id) return node;
      for (const child of node.children) {
        const match = find(child);
        if (match) return match;
      }
      return null;
    }
    return find(this.head) || find(this.body);
  }
}

function findRole(node, role) {
  if (node.attributes?.get("data-role") === role) return node;
  for (const child of node.children || []) {
    const match = findRole(child, role);
    if (match) return match;
  }
  return null;
}

function fakeEvent(type, values = {}) {
  return {
    type,
    defaultPrevented: false,
    propagationStopped: false,
    preventDefault() { this.defaultPrevented = true; },
    stopPropagation() { this.propagationStopped = true; },
    ...values
  };
}

function draftRow(document, index) {
  return findRole(document.body, "draft-list").children[index];
}

test("mount creates a dialog with text-only rendering and its own stylesheet", () => {
  const h = harness();
  const document = new FakeDocument();
  h.controller.mount(document.body);
  h.controller.openTheme(themeContext("<b>AI</b>"));
  assert.equal(document.body.children[0].tagName, "DIALOG");
  assert.equal(document.head.children[0].href, "operator-catalog-ui.css");
  assert.equal(findRole(document.body, "identity-name").value, "<b>AI</b>");
  assert.ok(findRole(document.body, "stock-query"));
  assert.equal(findRole(document.body, "stock-code"), null);
  assert.equal(findRole(document.body, "stock-name"), null);

  const source = fs.readFileSync(
    path.resolve(__dirname, "../../Web/operator-catalog-ui.js"),
    "utf8");
  assert.doesNotMatch(source, /innerHTML|outerHTML|insertAdjacentHTML|document\.write|\beval\s*\(/);
});

test("Delete removes only the focused or selected expert draft row through the row removal mutation", () => {
  const h = harness();
  const document = new FakeDocument();
  h.controller.mount(document.body);
  h.controller.openExpert(expertContext());

  let row = draftRow(document, 1);
  row.dispatchEvent(fakeEvent("focus"));
  assert.equal(h.controller.render().selectedDraftIndex, 1);
  row = draftRow(document, 1);
  const deleteKey = fakeEvent("keydown", { key: "Delete" });
  row.dispatchEvent(deleteKey);

  assert.equal(deleteKey.defaultPrevented, true);
  assert.equal(deleteKey.propagationStopped, true);
  assert.deepEqual(h.controller.render().draft.recommendations, [
    { playIndex: 0, stockCode: "005930", stockName: "삼성전자", buyAmount: 70000 }
  ]);

  row = draftRow(document, 0);
  const rowDeleteButton = row.children[3];
  rowDeleteButton.dispatchEvent(fakeEvent("click"));
  assert.deepEqual(h.controller.render().draft.recommendations, []);
});

test("Delete removes focused and selected ThemeA rows, reindexes, and stops at the empty boundary", () => {
  const h = harness();
  const document = new FakeDocument();
  const context = themeContext();
  context.preview.totalRowCount = 3;
  context.preview.items.push({ inputIndex: 9, itemCode: "P000660", itemName: "SK하이닉스" });
  h.controller.mount(document.body);
  h.controller.openTheme(context);
  const nativeRequestCount = h.posted.length;

  let row = draftRow(document, 1);
  row.dispatchEvent(fakeEvent("focus"));
  const focusedDelete = fakeEvent("keydown", { key: "Delete" });
  row.dispatchEvent(focusedDelete);
  assert.equal(focusedDelete.defaultPrevented, true);
  assert.equal(focusedDelete.propagationStopped, true);
  assert.equal(h.controller.render().selectedDraftIndex, 1);
  assert.deepEqual(h.controller.render().draft.items, [
    { inputIndex: 0, itemCode: "P005930", itemName: "삼성전자" },
    { inputIndex: 1, itemCode: "P000660", itemName: "SK하이닉스" }
  ]);

  const selectedDelete = fakeEvent("keydown", { key: "Delete" });
  findRole(document.body, "draft-list").dispatchEvent(selectedDelete);
  assert.equal(selectedDelete.defaultPrevented, true);
  assert.equal(selectedDelete.propagationStopped, true);
  assert.equal(h.controller.render().selectedDraftIndex, 0);
  assert.deepEqual(h.controller.render().draft.items, [
    { inputIndex: 0, itemCode: "P005930", itemName: "삼성전자" }
  ]);

  row = draftRow(document, 0);
  row.dispatchEvent(fakeEvent("keydown", { key: "Delete" }));
  assert.equal(h.controller.render().selectedDraftIndex, -1);
  assert.deepEqual(h.controller.render().draft.items, []);

  const emptyBoundary = fakeEvent("keydown", { key: "Delete" });
  findRole(document.body, "draft-list").dispatchEvent(emptyBoundary);
  assert.equal(emptyBoundary.defaultPrevented, false);
  assert.equal(emptyBoundary.propagationStopped, false);
  assert.deepEqual(h.controller.render().draft.items, []);
  assert.equal(h.posted.length, nativeRequestCount);
});

test("Delete ignores editor inputs, contenteditable regions, closed modal, and outside events", () => {
  for (const entity of ["theme", "expert"]) {
    const h = harness();
    const document = new FakeDocument();
    const outside = document.createElement("button");
    document.body.appendChild(outside);
    h.controller.mount(document.body);
    if (entity === "theme") h.controller.openTheme(themeContext());
    else h.controller.openExpert(expertContext());

    let row = draftRow(document, 0);
    row.dispatchEvent(fakeEvent("focus"));
    const textarea = document.createElement("textarea");
    const contentEditable = document.createElement("div");
    contentEditable.setAttribute("contenteditable", "true");
    document.body.children.find(element => element.tagName === "DIALOG")
      .append(textarea, contentEditable);
    for (const editable of [findRole(document.body, "identity-name"), textarea, contentEditable]) {
      const editableDelete = fakeEvent("keydown", { key: "Delete" });
      editable.dispatchEvent(editableDelete);
      assert.equal(editableDelete.defaultPrevented, false, entity);
      assert.equal(editableDelete.propagationStopped, false, entity);
    }
    const draftLength = () => entity === "theme"
      ? h.controller.render().draft.items.length
      : h.controller.render().draft.recommendations.length;
    assert.equal(draftLength(), 2, entity);

    const outsideDelete = fakeEvent("keydown", { key: "Delete" });
    outside.dispatchEvent(outsideDelete);
    assert.equal(outsideDelete.defaultPrevented, false, entity);
    assert.equal(outsideDelete.propagationStopped, false, entity);
    assert.equal(draftLength(), 2, entity);

    row = draftRow(document, 0);
    h.controller.close();
    const closedDelete = fakeEvent("keydown", { key: "Delete" });
    row.dispatchEvent(closedDelete);
    assert.equal(closedDelete.defaultPrevented, false, entity);
    assert.equal(closedDelete.propagationStopped, false, entity);
    assert.equal(draftLength(), 2, entity);
  }
});

test("ThemeA draft Delete is consumed but blocked while locked or a DB write is active", () => {
  const h = harness();
  const document = new FakeDocument();
  h.controller.mount(document.body);
  h.controller.openTheme(themeContext());
  completeSchema(h);

  let row = draftRow(document, 0);
  row.dispatchEvent(fakeEvent("focus"));
  h.setExternalLocked(true);
  h.controller.render();
  row = draftRow(document, 0);
  const lockedDelete = fakeEvent("keydown", { key: "Delete" });
  row.dispatchEvent(lockedDelete);
  assert.equal(lockedDelete.defaultPrevented, true);
  assert.equal(lockedDelete.propagationStopped, true);
  assert.equal(h.controller.render().draft.items.length, 2);

  h.setExternalLocked(false);
  h.controller.render();
  assert.equal(h.controller.submit(), true);
  assert.ok(h.controller.render().mutation);
  row = draftRow(document, 0);
  const writingDelete = fakeEvent("keydown", { key: "Delete" });
  row.dispatchEvent(writingDelete);
  assert.equal(writingDelete.defaultPrevented, true);
  assert.equal(writingDelete.propagationStopped, true);
  assert.equal(h.controller.render().draft.items.length, 2);
});

test("expert draft Delete is consumed but blocked while locked or a DB write is active", () => {
  const h = harness();
  const document = new FakeDocument();
  h.controller.mount(document.body);
  h.controller.openExpert(expertContext());
  completeSchema(h);

  let row = draftRow(document, 0);
  row.dispatchEvent(fakeEvent("focus"));
  h.setExternalLocked(true);
  h.controller.render();
  row = draftRow(document, 0);
  const lockedDelete = fakeEvent("keydown", { key: "Delete" });
  row.dispatchEvent(lockedDelete);
  assert.equal(lockedDelete.defaultPrevented, true);
  assert.equal(lockedDelete.propagationStopped, true);
  assert.equal(h.controller.render().draft.recommendations.length, 2);

  h.setExternalLocked(false);
  h.controller.render();
  assert.equal(h.controller.submit(), true);
  assert.ok(h.controller.render().mutation);
  row = draftRow(document, 0);
  const writingDelete = fakeEvent("keydown", { key: "Delete" });
  row.dispatchEvent(writingDelete);
  assert.equal(writingDelete.defaultPrevented, true);
  assert.equal(writingDelete.propagationStopped, true);
  assert.equal(h.controller.render().draft.recommendations.length, 2);
});
