import assert from "node:assert/strict";

import {
  getOpfsRoot,
  loadTextFile,
  saveTextFile,
} from "../../../runtime/opfs/file-service.js";
import {
  defaultMachinePaths,
  gcodeProgramPath,
  machineIniPath,
  normalizeOpfsPath,
  parameterFilePath,
  previewCachePath,
  sessionSnapshotPath,
  toolTablePath,
} from "../../../runtime/opfs/path-model.js";
import {
  createSessionSnapshot,
  loadSessionSnapshot,
  saveSessionSnapshot,
  validateSessionSnapshot,
} from "../../../runtime/opfs/snapshot-store.js";
import {
  loadGcodeProgram,
  loadMachineTextFiles,
  machineFilePaths,
  saveGcodeProgram,
  saveMachineTextFiles,
} from "../../../runtime/opfs/machine-file-store.js";
import {
  restoreMachineParametersFromOpfs,
  saveMachineParametersToOpfs,
} from "../../../runtime/opfs/linuxcnc-parameter-bridge.js";
import {
  loadMachineToolTableFromOpfs,
  saveMachineToolTableToOpfs,
} from "../../../runtime/opfs/linuxcnc-tool-table-bridge.js";
import {
  loadMachineSessionFromOpfs,
} from "../../../runtime/opfs/linuxcnc-machine-session-bridge.js";

class MockFileHandle {
  constructor(name) {
    this.name = name;
    this.textValue = "";
  }

  async createWritable() {
    return {
      write: async (text) => {
        this.textValue = String(text);
      },
      close: async () => {},
    };
  }

  async getFile() {
    return {
      text: async () => this.textValue,
    };
  }
}

class MockDirectoryHandle {
  constructor(name = "") {
    this.name = name;
    this.dirs = new Map();
    this.files = new Map();
  }

  async getDirectoryHandle(name, options = {}) {
    if (!this.dirs.has(name)) {
      if (!options.create) {
        throw new Error(`missing directory: ${name}`);
      }
      this.dirs.set(name, new MockDirectoryHandle(name));
    }
    return this.dirs.get(name);
  }

  async getFileHandle(name, options = {}) {
    if (!this.files.has(name)) {
      if (!options.create) {
        throw new Error(`missing file: ${name}`);
      }
      this.files.set(name, new MockFileHandle(name));
    }
    return this.files.get(name);
  }
}

const root = new MockDirectoryHandle();
const storage = {
  getDirectory: async () => root,
};

assert.equal(await getOpfsRoot(storage), root);

assert.equal(normalizeOpfsPath("linuxcnc/machines/xyzab.ini"), "linuxcnc/machines/xyzab.ini");
assert.equal(machineIniPath("xyzab-tdr"), "linuxcnc/machines/xyzab-tdr/machine.ini");
assert.equal(toolTablePath("xyzab-tdr"), "linuxcnc/machines/xyzab-tdr/tool.tbl");
assert.equal(parameterFilePath("xyzab-tdr"), "linuxcnc/machines/xyzab-tdr/linuxcnc.var");
assert.equal(gcodeProgramPath("fixture.ngc"), "linuxcnc/gcode/fixture.ngc");
assert.equal(gcodeProgramPath("custom-fixture.ngc"), "linuxcnc/gcode/custom-fixture.ngc");
assert.equal(previewCachePath("fixture"), "linuxcnc/preview-cache/fixture/preview.json");
assert.equal(sessionSnapshotPath("session-1"), "linuxcnc/sessions/session-1/snapshot.json");
assert.equal(
  sessionSnapshotPath("session-1", "custom-snapshot.json"),
  "linuxcnc/sessions/session-1/custom-snapshot.json",
);
assert.deepEqual(defaultMachinePaths("xyzab-tdr"), {
  ini: "linuxcnc/machines/xyzab-tdr/machine.ini",
  toolTable: "linuxcnc/machines/xyzab-tdr/tool.tbl",
  parameters: "linuxcnc/machines/xyzab-tdr/linuxcnc.var",
});
assert.deepEqual(machineFilePaths("xyzab-tdr"), defaultMachinePaths("xyzab-tdr"));

const snapshotPayload = {
  files: {
    ini: machineIniPath("xyzab-tdr"),
    toolTable: toolTablePath("xyzab-tdr"),
  },
};
const snapshot = createSessionSnapshot("session-1", snapshotPayload, {
  createdAt: "2026-06-08T00:00:00.000Z",
  metadata: { source: "node-smoke" },
});
assert.deepEqual(snapshot, {
  format: "linuxcnc-wasm-session-snapshot",
  version: 1,
  sessionId: "session-1",
  createdAt: "2026-06-08T00:00:00.000Z",
  metadata: { source: "node-smoke" },
  payload: snapshotPayload,
});
assert.equal(validateSessionSnapshot(snapshot, "session-1"), snapshot);
assert.throws(
  () => validateSessionSnapshot({ ...snapshot, format: "other-format" }, "session-1"),
  /Unsupported session snapshot format/,
);
assert.throws(
  () => validateSessionSnapshot({ ...snapshot, version: 99 }, "session-1"),
  /Unsupported session snapshot version/,
);
assert.throws(
  () => validateSessionSnapshot({ ...snapshot, sessionId: "other-session" }, "session-1"),
  /Session snapshot id mismatch/,
);
assert.throws(
  () => validateSessionSnapshot({ ...snapshot, metadata: null }, "session-1"),
  /snapshot metadata must be a plain object/,
);
assert.throws(
  () => validateSessionSnapshot({ ...snapshot, payload: [] }, "session-1"),
  /snapshot payload must be a plain object/,
);

await saveTextFile(
  "linuxcnc/machines/xyzab.ini",
  "[EMC]\nMACHINE = opfs-smoke\n",
  storage,
);
assert.equal(
  await loadTextFile("linuxcnc/machines/xyzab.ini", storage),
  "[EMC]\nMACHINE = opfs-smoke\n",
);

await saveSessionSnapshot("session-1", snapshotPayload, {
  storage,
  createdAt: "2026-06-08T00:00:00.000Z",
  metadata: { source: "node-smoke" },
});
assert.deepEqual(await loadSessionSnapshot("session-1", { storage }), snapshot);
const customSnapshot = await saveSessionSnapshot("session-1", snapshotPayload, {
  storage,
  filename: "custom-snapshot.json",
  createdAt: "2026-06-08T00:00:00.000Z",
  metadata: { source: "node-smoke-custom" },
});
assert.equal(
  customSnapshot.metadata.source,
  "node-smoke-custom",
);
assert.deepEqual(
  await loadSessionSnapshot("session-1", {
    storage,
    filename: "custom-snapshot.json",
  }),
  customSnapshot,
);

await saveMachineTextFiles("xyzab-tdr", {
  ini: "[EMC]\nMACHINE = xyzab-tdr\n",
  toolTable: "T0 P0 ; no tool\nT2 P2 Z1.25 D0.25\n",
  parameters: "5161 0.0\n5162 0.0\n",
}, { storage });
assert.deepEqual(await loadMachineTextFiles("xyzab-tdr", { storage }), {
  ini: "[EMC]\nMACHINE = xyzab-tdr\n",
  toolTable: "T0 P0 ; no tool\nT2 P2 Z1.25 D0.25\n",
  parameters: "5161 0.0\n5162 0.0\n",
});
assert.equal(
  await saveGcodeProgram("fixture.ngc", "G0 X0 Y0\nM2\n", { storage }),
  "linuxcnc/gcode/fixture.ngc",
);
assert.equal(await loadGcodeProgram("fixture.ngc", { storage }), "G0 X0 Y0\nM2\n");
assert.equal(
  await saveGcodeProgram("custom-fixture.ngc", "G1 X1 F10\nM2\n", { storage }),
  "linuxcnc/gcode/custom-fixture.ngc",
);
assert.equal(await loadGcodeProgram("custom-fixture.ngc", { storage }), "G1 X1 F10\nM2\n");

const bridgeFiles = new Map();
const bridgeInterp = {
  writeTextFile(path, text) {
    bridgeFiles.set(path, text);
  },
  readTextFile(path) {
    if (!bridgeFiles.has(path)) {
      throw new Error(`missing wasm file: ${path}`);
    }
    return bridgeFiles.get(path);
  },
  restoreParameters(path) {
    return `restore_parameters=0\nrestore_path=${path}\n`;
  },
  saveParameters(path, values) {
    bridgeFiles.set(path, "5161\t12.340000\n5162\t56.780000\n");
    bridgeFiles.set(`${path}.bak`, "5161 0.0\n5162 0.0\n");
    return `save_parameters=0\nsave_path=${path}\nvalues=${Object.keys(values).join(",")}\n`;
  },
  loadToolTable(path, options = {}) {
    return [
      "tooldata_load=0",
      `load_tool_path=${path}`,
      `random_toolchanger=${options.randomToolChanger === true ? 1 : 0}`,
      "",
    ].join("\n");
  },
  saveToolTable(path) {
    bridgeFiles.set(path, "T2   P2   Z+1.250000 D+0.250000 ;loaded by LinuxCNC\n");
    return `tooldata_save=0\nsave_tool_path=${path}\n`;
  },
};

const bridgeIniSdk = {
  writeTextFile(path, text) {
    bridgeFiles.set(`ini:${path}`, text);
  },
  getString(path, section, tag) {
    if (
      path === "/work/ini-file-session.ini" &&
      section === "RS274NGC" &&
      tag === "PARAMETER_FILE"
    ) {
      return "custom.var";
    }
    if (
      path === "/work/ini-override-session.ini" &&
      section === "RS274NGC" &&
      tag === "PARAMETER_FILE"
    ) {
      return "ignored.var";
    }
    if (
      path === "/work/invalid-ini-file-session.ini" &&
      section === "RS274NGC" &&
      tag === "PARAMETER_FILE"
    ) {
      return "../escape.var";
    }
    if (
      path === "/work/ini-file-session.ini" &&
      section === "EMCIO" &&
      tag === "TOOL_TABLE"
    ) {
      return "custom-tool.tbl";
    }
    if (
      path === "/work/ini-override-session.ini" &&
      section === "EMCIO" &&
      tag === "TOOL_TABLE"
    ) {
      return "ignored-tool.tbl";
    }
    if (
      path === "/work/invalid-tool-file-session.ini" &&
      section === "EMCIO" &&
      tag === "TOOL_TABLE"
    ) {
      return "nested/tool.tbl";
    }
    return null;
  },
  getBool(path, section, tag) {
    if (
      path === "/work/ini-derived-session.ini" &&
      section === "EMCIO" &&
      tag === "RANDOM_TOOLCHANGER"
    ) {
      return true;
    }
    return null;
  },
};

const restoredParameters = await restoreMachineParametersFromOpfs(
  bridgeInterp,
  "xyzab-tdr",
  { storage, wasmPath: "/work/bridge.var" },
);
assert.deepEqual(restoredParameters, {
  opfsPath: "linuxcnc/machines/xyzab-tdr/linuxcnc.var",
  wasmPath: "/work/bridge.var",
  result: "restore_parameters=0\nrestore_path=/work/bridge.var\n",
});
assert.equal(bridgeFiles.get("/work/bridge.var"), "5161 0.0\n5162 0.0\n");

const savedParameters = await saveMachineParametersToOpfs(
  bridgeInterp,
  "xyzab-tdr",
  { 5161: 12.34, 5162: 56.78 },
  { storage, wasmPath: "/work/bridge.var" },
);
assert.equal(savedParameters.opfsPath, "linuxcnc/machines/xyzab-tdr/linuxcnc.var");
assert.equal(savedParameters.backupOpfsPath, "linuxcnc/machines/xyzab-tdr/linuxcnc.var.bak");
assert.equal(savedParameters.result.includes("save_parameters=0"), true);
assert.equal(
  await loadTextFile("linuxcnc/machines/xyzab-tdr/linuxcnc.var", storage),
  "5161\t12.340000\n5162\t56.780000\n",
);
assert.equal(
  await loadTextFile("linuxcnc/machines/xyzab-tdr/linuxcnc.var.bak", storage),
  "5161 0.0\n5162 0.0\n",
);

const loadedToolTable = await loadMachineToolTableFromOpfs(
  bridgeInterp,
  "xyzab-tdr",
  { storage, wasmPath: "/work/bridge-tool.tbl" },
);
assert.deepEqual(loadedToolTable, {
  opfsPath: "linuxcnc/machines/xyzab-tdr/tool.tbl",
  wasmPath: "/work/bridge-tool.tbl",
  result: "tooldata_load=0\nload_tool_path=/work/bridge-tool.tbl\nrandom_toolchanger=0\n",
});
assert.equal(bridgeFiles.get("/work/bridge-tool.tbl"), "T0 P0 ; no tool\nT2 P2 Z1.25 D0.25\n");

const loadedRandomToolTable = await loadMachineToolTableFromOpfs(
  bridgeInterp,
  "xyzab-tdr",
  { storage, wasmPath: "/work/random-bridge-tool.tbl", randomToolChanger: true },
);
assert.deepEqual(loadedRandomToolTable, {
  opfsPath: "linuxcnc/machines/xyzab-tdr/tool.tbl",
  wasmPath: "/work/random-bridge-tool.tbl",
  result:
    "tooldata_load=0\nload_tool_path=/work/random-bridge-tool.tbl\nrandom_toolchanger=1\n",
});

const savedToolTable = await saveMachineToolTableToOpfs(
  bridgeInterp,
  "xyzab-tdr",
  { storage, wasmPath: "/work/bridge-tool.tbl" },
);
assert.equal(savedToolTable.opfsPath, "linuxcnc/machines/xyzab-tdr/tool.tbl");
assert.equal(savedToolTable.wasmPath, "/work/bridge-tool.tbl");
assert.equal(savedToolTable.result.includes("tooldata_save=0"), true);
assert.equal(
  await loadTextFile("linuxcnc/machines/xyzab-tdr/tool.tbl", storage),
  "T2   P2   Z+1.250000 D+0.250000 ;loaded by LinuxCNC\n",
);

const loadedSession = await loadMachineSessionFromOpfs(
  bridgeInterp,
  "xyzab-tdr",
  {
    storage,
    iniWasmPath: "/work/session.ini",
    parameterWasmPath: "/work/session.var",
    toolTableWasmPath: "/work/session-tool.tbl",
  },
);
assert.deepEqual(loadedSession, {
  machineId: "xyzab-tdr",
  ini: {
    opfsPath: "linuxcnc/machines/xyzab-tdr/machine.ini",
    wasmPath: "/work/session.ini",
  },
  parameters: {
    opfsPath: "linuxcnc/machines/xyzab-tdr/linuxcnc.var",
    wasmPath: "/work/session.var",
    result: "restore_parameters=0\nrestore_path=/work/session.var\n",
  },
  toolTable: {
    opfsPath: "linuxcnc/machines/xyzab-tdr/tool.tbl",
    wasmPath: "/work/session-tool.tbl",
    result: "tooldata_load=0\nload_tool_path=/work/session-tool.tbl\nrandom_toolchanger=0\n",
  },
});
assert.equal(bridgeFiles.get("/work/session.ini"), "[EMC]\nMACHINE = xyzab-tdr\n");
assert.equal(bridgeFiles.get("/work/session.var"), "5161\t12.340000\n5162\t56.780000\n");
assert.equal(
  bridgeFiles.get("/work/session-tool.tbl"),
  "T2   P2   Z+1.250000 D+0.250000 ;loaded by LinuxCNC\n",
);

const loadedRandomSession = await loadMachineSessionFromOpfs(
  bridgeInterp,
  "xyzab-tdr",
  {
    storage,
    iniWasmPath: "/work/random-session.ini",
    parameterWasmPath: "/work/random-session.var",
    toolTableWasmPath: "/work/random-session-tool.tbl",
    randomToolChanger: true,
  },
);
assert.equal(
  loadedRandomSession.toolTable.result,
  "tooldata_load=0\nload_tool_path=/work/random-session-tool.tbl\nrandom_toolchanger=1\n",
);

const loadedIniDerivedRandomSession = await loadMachineSessionFromOpfs(
  bridgeInterp,
  "xyzab-tdr",
  {
    storage,
    iniSdk: bridgeIniSdk,
    iniWasmPath: "/work/ini-derived-session.ini",
    parameterWasmPath: "/work/ini-derived-session.var",
    toolTableWasmPath: "/work/ini-derived-session-tool.tbl",
  },
);
assert.equal(
  bridgeFiles.get("ini:/work/ini-derived-session.ini"),
  "[EMC]\nMACHINE = xyzab-tdr\n",
);
assert.equal(
  loadedIniDerivedRandomSession.toolTable.result,
  "tooldata_load=0\nload_tool_path=/work/ini-derived-session-tool.tbl\nrandom_toolchanger=1\n",
);
assert.equal(
  loadedIniDerivedRandomSession.parameters.opfsPath,
  "linuxcnc/machines/xyzab-tdr/linuxcnc.var",
);
assert.equal(
  loadedIniDerivedRandomSession.toolTable.opfsPath,
  "linuxcnc/machines/xyzab-tdr/tool.tbl",
);

await saveMachineTextFiles("ini-file-session", {
  ini: [
    "[EMC]",
    "MACHINE = ini-file-session",
    "",
    "[RS274NGC]",
    "PARAMETER_FILE = custom.var",
    "",
    "[EMCIO]",
    "TOOL_TABLE = custom-tool.tbl",
    "",
  ].join("\n"),
}, { storage });
await saveTextFile(
  "linuxcnc/machines/ini-file-session/custom.var",
  "5161 77.0\n5162 88.0\n",
  storage,
);
await saveTextFile(
  "linuxcnc/machines/ini-file-session/custom-tool.tbl",
  "T8 P8 Z4.5 D0.5\n",
  storage,
);
const loadedIniFileSession = await loadMachineSessionFromOpfs(
  bridgeInterp,
  "ini-file-session",
  {
    storage,
    iniSdk: bridgeIniSdk,
    iniWasmPath: "/work/ini-file-session.ini",
    parameterWasmPath: "/work/ini-file-session.var",
    toolTableWasmPath: "/work/ini-file-session-tool.tbl",
  },
);
assert.equal(
  loadedIniFileSession.parameters.opfsPath,
  "linuxcnc/machines/ini-file-session/custom.var",
);
assert.equal(
  loadedIniFileSession.toolTable.opfsPath,
  "linuxcnc/machines/ini-file-session/custom-tool.tbl",
);
assert.equal(bridgeFiles.get("/work/ini-file-session.var"), "5161 77.0\n5162 88.0\n");
assert.equal(bridgeFiles.get("/work/ini-file-session-tool.tbl"), "T8 P8 Z4.5 D0.5\n");
assert.equal(
  loadedIniFileSession.toolTable.result,
  "tooldata_load=0\nload_tool_path=/work/ini-file-session-tool.tbl\nrandom_toolchanger=0\n",
);

await saveMachineTextFiles("ini-override-session", {
  ini: [
    "[EMC]",
    "MACHINE = ini-override-session",
    "",
    "[RS274NGC]",
    "PARAMETER_FILE = ignored.var",
    "",
    "[EMCIO]",
    "TOOL_TABLE = ignored-tool.tbl",
    "",
  ].join("\n"),
}, { storage });
await saveTextFile(
  "linuxcnc/machines/ini-override-session/explicit.var",
  "5161 11.0\n5162 22.0\n",
  storage,
);
await saveTextFile(
  "linuxcnc/machines/ini-override-session/explicit-tool.tbl",
  "T3 P3 Z1.75 D0.375\n",
  storage,
);
const loadedIniOverrideSession = await loadMachineSessionFromOpfs(
  bridgeInterp,
  "ini-override-session",
  {
    storage,
    iniSdk: bridgeIniSdk,
    iniWasmPath: "/work/ini-override-session.ini",
    parameterFilename: "explicit.var",
    parameterWasmPath: "/work/ini-override-session.var",
    toolTableFilename: "explicit-tool.tbl",
    toolTableWasmPath: "/work/ini-override-session-tool.tbl",
  },
);
assert.equal(
  loadedIniOverrideSession.parameters.opfsPath,
  "linuxcnc/machines/ini-override-session/explicit.var",
);
assert.equal(
  loadedIniOverrideSession.toolTable.opfsPath,
  "linuxcnc/machines/ini-override-session/explicit-tool.tbl",
);
assert.equal(bridgeFiles.get("/work/ini-override-session.var"), "5161 11.0\n5162 22.0\n");
assert.equal(bridgeFiles.get("/work/ini-override-session-tool.tbl"), "T3 P3 Z1.75 D0.375\n");

await saveMachineTextFiles("invalid-ini-file-session", {
  ini: [
    "[EMC]",
    "MACHINE = invalid-ini-file-session",
    "",
    "[RS274NGC]",
    "PARAMETER_FILE = ../escape.var",
    "",
  ].join("\n"),
}, { storage });
await assert.rejects(
  () => loadMachineSessionFromOpfs(
    bridgeInterp,
    "invalid-ini-file-session",
    {
      storage,
      iniSdk: bridgeIniSdk,
      iniWasmPath: "/work/invalid-ini-file-session.ini",
    },
  ),
  /Invalid parameter filename/,
);

await saveMachineTextFiles("invalid-tool-file-session", {
  ini: [
    "[EMC]",
    "MACHINE = invalid-tool-file-session",
    "",
    "[EMCIO]",
    "TOOL_TABLE = nested/tool.tbl",
    "",
  ].join("\n"),
}, { storage });
await assert.rejects(
  () => loadMachineSessionFromOpfs(
    bridgeInterp,
    "invalid-tool-file-session",
    {
      storage,
      iniSdk: bridgeIniSdk,
      iniWasmPath: "/work/invalid-tool-file-session.ini",
    },
  ),
  /Invalid tool table filename/,
);

await assert.rejects(
  () => loadTextFile("linuxcnc/machines/missing.ini", storage),
  /missing file/,
);
await assert.rejects(
  () => saveTextFile("../escape.ini", "", storage),
  /Invalid OPFS path/,
);
assert.throws(
  () => machineIniPath("../escape"),
  /Invalid machine id/,
);
assert.throws(
  () => sessionSnapshotPath("session-1", "nested/snapshot.json"),
  /Invalid session snapshot filename/,
);
assert.throws(
  () => gcodeProgramPath("../escape.ngc"),
  /Invalid G-code filename/,
);
await assert.rejects(
  () => saveGcodeProgram("nested/fixture.ngc", "", { storage }),
  /Invalid G-code filename/,
);
await assert.rejects(
  () => loadGcodeProgram("../escape.ngc", { storage }),
  /Invalid G-code filename/,
);
await assert.rejects(
  () => saveSessionSnapshot("session-1", {}, {
    storage,
    filename: "nested/snapshot.json",
  }),
  /Invalid session snapshot filename/,
);
await assert.rejects(
  () => loadSessionSnapshot("session-1", {
    storage,
    filename: "../snapshot.json",
  }),
  /Invalid session snapshot filename/,
);
await assert.rejects(
  () => saveMachineTextFiles("xyzab-tdr", null, { storage }),
  /machine files must be a plain object/,
);
await saveTextFile(sessionSnapshotPath("bad-json"), "{", storage);
await assert.rejects(
  () => loadSessionSnapshot("bad-json", { storage }),
  /Invalid session snapshot JSON/,
);
await saveTextFile(
  sessionSnapshotPath("bad-format"),
  JSON.stringify({ ...snapshot, sessionId: "bad-format", format: "other-format" }),
  storage,
);
await assert.rejects(
  () => loadSessionSnapshot("bad-format", { storage }),
  /Unsupported session snapshot format/,
);
await saveTextFile(
  sessionSnapshotPath("bad-version"),
  JSON.stringify({ ...snapshot, sessionId: "bad-version", version: 99 }),
  storage,
);
await assert.rejects(
  () => loadSessionSnapshot("bad-version", { storage }),
  /Unsupported session snapshot version/,
);
await saveTextFile(
  sessionSnapshotPath("bad-session-id"),
  JSON.stringify({ ...snapshot, sessionId: "other-session" }),
  storage,
);
await assert.rejects(
  () => loadSessionSnapshot("bad-session-id", { storage }),
  /Session snapshot id mismatch/,
);
await assert.rejects(
  () => loadSessionSnapshot("missing-session", { storage }),
  /missing directory|missing file/,
);
await assert.rejects(
  () => getOpfsRoot({}),
  /OPFS is not available/,
);

console.log("opfs_file_service_node_smoke=ok");
