import assert from "node:assert/strict";
import { lstat, mkdtemp, mkdir, readFile, rm, stat, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import test from "node:test";

import {
  acquireApplyLock,
  appendBoundedLog,
  assertCanonicalSourceRoot,
  copySourceTree,
  pwaAllowedHostsEnvironment,
  promoteRelease,
  redactOperationalError,
  rollbackRelease,
  runtimeHealthUrls,
  safeApplyWaitError,
  windowsCommand
} from "./safe-runtime-lib.mjs";

const exec = promisify(execFile);

test("windowsCommand runs command shims through cmd.exe", () => {
  const result = windowsCommand("npm.cmd", ["test"]);
  assert.match(result.command.toLowerCase(), /cmd\.exe$/);
  assert.deepEqual(result.args, ["/d", "/s", "/c", "npm.cmd", "test"]);
});

async function withTempDirectory(run) {
  const directory = await mkdtemp(join(tmpdir(), "code-remote-manager-release-"));
  try {
    await run(directory);
  } finally {
    await rm(directory, { recursive: true, force: true });
  }
}

test("copySourceTree omits credentials, dependencies, and runtime data", () => withTempDirectory(async (root) => {
  const source = join(root, "source");
  const target = join(root, "target");
  await mkdir(join(source, "apps"), { recursive: true });
  await mkdir(join(source, "node_modules"), { recursive: true });
  await mkdir(join(source, "data"), { recursive: true });
  await writeFile(join(source, "apps", "index.js"), "safe");
  await writeFile(join(source, "config.local.json"), "secret");
  await writeFile(join(source, "node_modules", "dependency"), "large");
  await writeFile(join(source, "data", "session.json"), "private");

  await copySourceTree(source, target);

  assert.equal(await readFile(join(target, "apps", "index.js"), "utf8"), "safe");
  await assert.rejects(readFile(join(target, "config.local.json")));
  await assert.rejects(readFile(join(target, "node_modules", "dependency")));
  await assert.rejects(readFile(join(target, "data", "session.json")));
}));

test("copySourceTree omits ignored worktrees, attachments, build output, and local databases", () => withTempDirectory(async (root) => {
  const source = join(root, "source");
  const target = join(root, "target");
  await mkdir(join(source, "apps"), { recursive: true });
  await mkdir(join(source, ".worktrees", "feature"), { recursive: true });
  await mkdir(join(source, ".code-remote-manager-attachments", "session"), { recursive: true });
  await mkdir(join(source, "dist"), { recursive: true });
  await mkdir(join(source, ".turbo"), { recursive: true });
  await writeFile(join(source, "apps", "index.js"), "safe");
  await writeFile(join(source, ".env.example"), "example");
  await writeFile(join(source, ".worktrees", "feature", "config.local.json"), "secret");
  await writeFile(join(source, ".code-remote-manager-attachments", "session", "image.png"), "private");
  await writeFile(join(source, "dist", "bundle.js"), "old build");
  await writeFile(join(source, ".turbo", "cache"), "cache");
  await writeFile(join(source, "sessions.db"), "private db");
  await writeFile(join(source, "sessions.db-wal"), "private wal");

  await copySourceTree(source, target);

  assert.equal(await readFile(join(target, "apps", "index.js"), "utf8"), "safe");
  assert.equal(await readFile(join(target, ".env.example"), "utf8"), "example");
  await assert.rejects(readFile(join(target, ".worktrees", "feature", "config.local.json")));
  await assert.rejects(readFile(join(target, ".code-remote-manager-attachments", "session", "image.png")));
  await assert.rejects(readFile(join(target, "dist", "bundle.js")));
  await assert.rejects(readFile(join(target, ".turbo", "cache")));
  await assert.rejects(readFile(join(target, "sessions.db")));
  await assert.rejects(readFile(join(target, "sessions.db-wal")));
}));

test("copySourceTree omits arbitrary paths ignored by repository rules", () => withTempDirectory(async (root) => {
  const source = join(root, "source");
  const target = join(root, "target");
  await mkdir(join(source, "apps"), { recursive: true });
  await mkdir(join(source, "machine-private", "nested"), { recursive: true });
  await writeFile(join(source, ".gitignore"), "machine-private/\n*.operator-secret\n");
  await writeFile(join(source, "apps", "index.js"), "safe");
  await writeFile(join(source, "machine-private", "nested", "credential.txt"), "private");
  await writeFile(join(source, "local.operator-secret"), "private");
  await exec("git", ["init", "--quiet"], { cwd: source, windowsHide: true });

  await copySourceTree(source, target);

  assert.equal(await readFile(join(target, "apps", "index.js"), "utf8"), "safe");
  await assert.rejects(readFile(join(target, "machine-private", "nested", "credential.txt")));
  await assert.rejects(readFile(join(target, "local.operator-secret")));
}));

test("runtimeHealthUrls uses config and environment overrides", () => {
  assert.deepEqual(runtimeHealthUrls({ port: 43110 }, {}), [
    "http://127.0.0.1:43110/health",
    "http://127.0.0.1:43111/"
  ]);
  assert.deepEqual(runtimeHealthUrls({ bindHost: "127.0.0.1", port: 45000 }, { pwaUrl: "http://127.0.0.1:45001/app" }), [
    "http://127.0.0.1:45000/health",
    "http://127.0.0.1:45001/app"
  ]);
});

test("safe runtime derives Vite allowed hosts from configured HTTPS origins", () => {
  assert.deepEqual(
    pwaAllowedHostsEnvironment({
      allowedOrigins: [
        "https://phone.example.ts.net",
        "http://127.0.0.1:43111",
        "not-an-origin"
      ]
    }, { CODE_REMOTE_MANAGER_PWA_ALLOWED_HOSTS: "manual.example.ts.net" }),
    { CODE_REMOTE_MANAGER_PWA_ALLOWED_HOSTS: "manual.example.ts.net,phone.example.ts.net" }
  );
});

test("safe runtime accepts only its installed canonical source root", () => withTempDirectory(async (root) => {
  const canonical = join(root, "main");
  await mkdir(canonical);

  assert.doesNotThrow(() => assertCanonicalSourceRoot(canonical, canonical));
  assert.throws(
    () => assertCanonicalSourceRoot(canonical, join(root, "other-worktree")),
    /safe_runtime_source_mismatch/
  );
}));

test("safe runtime allows only one apply request at a time", () => withTempDirectory(async (root) => {
  const lockPath = join(root, "apply.lock");
  const release = await acquireApplyLock(lockPath, "request-one");

  await assert.rejects(
    acquireApplyLock(lockPath, "request-two"),
    /safe_runtime_apply_in_progress/
  );

  await release();
  const releaseNext = await acquireApplyLock(lockPath, "request-two");
  await releaseNext();
}));

test("redactOperationalError removes local paths and credential filenames", () => {
  const message = redactOperationalError("failed C:\\Users\\me\\.codex\\auth.json CODEX_HOME=C:\\secret\\home");
  assert.equal(message.includes("C:\\Users"), false);
  assert.equal(message.includes("auth.json"), false);
  assert.equal(message.includes("C:\\secret"), false);
});

test("appendBoundedLog keeps the latest bytes", () => withTempDirectory(async (root) => {
  const logPath = join(root, "logs", "backend.log");

  await appendBoundedLog(logPath, "12345", 10);
  await appendBoundedLog(logPath, "67890", 10);
  await appendBoundedLog(logPath, "abcde", 10);

  assert.equal(await readFile(logPath, "utf8"), "67890abcde");
  assert.equal((await stat(logPath)).size, 10);
}));

test("appendBoundedLog redacts credentials and local paths before persistence", () => withTempDirectory(async (root) => {
  const logPath = join(root, "logs", "backend.log");

  await appendBoundedLog(logPath, "Bearer secret-token at C:\\Users\\me\\.codex\\auth.json\n", 4096);

  const content = await readFile(logPath, "utf8");
  assert.equal(content.includes("secret-token"), false);
  assert.equal(content.includes("C:\\Users"), false);
  assert.equal(content.includes("auth.json"), false);
  assert.match(content, /\[redacted\]|\[local path redacted\]/);
}));

test("appendBoundedLog redacts quoted Windows paths with spaces", () => withTempDirectory(async (root) => {
  const logPath = join(root, "logs", "backend.log");

  await appendBoundedLog(logPath, "failed \"C:\\Users\\me\\My Project\\auth.json\" CODEX_HOME=\"C:\\Secret Home\\codex\"\n", 4096);

  const content = await readFile(logPath, "utf8");
  assert.equal(content.includes("C:\\Users"), false);
  assert.equal(content.includes("Project"), false);
  assert.equal(content.includes("C:\\Secret Home"), false);
  assert.equal(content.includes("Home\\codex"), false);
  assert.equal(content.includes("auth.json"), false);
}));

test("safe-runtime verifies the immutable staging copy instead of the mutable source", async () => {
  const source = await readFile(new URL("./safe-runtime.mjs", import.meta.url), "utf8");
  const installIndex = source.indexOf("async function install()");
  const superviseIndex = source.indexOf("async function supervise()");
  const installBody = source.slice(installIndex, superviseIndex);
  const superviseBody = source.slice(superviseIndex, source.indexOf("async function apply()"));

  assert.ok(installBody.indexOf("await prepare(sourceRoot)") < installBody.indexOf("await verify(staging)"));
  assert.ok(superviseBody.indexOf("await prepare(request.sourceRoot)") < superviseBody.indexOf("await verify(staging)"));
  assert.doesNotMatch(installBody, /verify\(sourceRoot\)/);
  assert.doesNotMatch(superviseBody, /verify\(request\.sourceRoot\)/);
  assert.ok(superviseBody.indexOf("assertCanonicalSourceRoot(canonicalSourceRoot, request.sourceRoot)") < superviseBody.indexOf("await prepare(request.sourceRoot)"));
});

test("safe-runtime locks apply requests until their matching result arrives", async () => {
  const source = await readFile(new URL("./safe-runtime.mjs", import.meta.url), "utf8");
  const applyBody = source.slice(source.indexOf("async function apply()"));

  assert.match(applyBody, /assertCanonicalSourceRoot\(canonicalSourceRoot, sourceRoot\)/);
  assert.match(applyBody, /await acquireApplyLock\(applyLockPath, id\)/);
  assert.match(applyBody, /finally \{[\s\S]*await releaseApplyLock\(\)/);
});

test("safe-runtime apply fails quickly when no supervisor picks up the request", () => {
  assert.equal(safeApplyWaitError({ elapsedMs: 9_999, requestPending: true }), "");
  assert.equal(safeApplyWaitError({ elapsedMs: 10_001, requestPending: true }), "safe_runtime_supervisor_unavailable");
  assert.equal(safeApplyWaitError({ elapsedMs: 10_001, requestPending: false }), "");
  assert.equal(safeApplyWaitError({ elapsedMs: 600_001, requestPending: false }), "safe_runtime_apply_timeout");
});

test("safe-runtime waits for stopped children and verifies rollback recovery", async () => {
  const source = await readFile(new URL("./safe-runtime.mjs", import.meta.url), "utf8");
  const stopBody = source.slice(source.indexOf("async function stopChild"), source.indexOf("async function healthy"));
  const rollbackStart = source.indexOf("await rollbackRelease");
  const rollbackBody = source.slice(rollbackStart, source.indexOf("await writeFile(resultPath", rollbackStart));

  assert.match(stopBody, /await waitForChildExit\(child\)/);
  assert.match(source, /async function healthy\(url, timeoutMs = 180000\)/);
  assert.match(rollbackBody, /healthy\(url\)/);
  assert.match(rollbackBody, /Previous release failed health checks after rollback/);
});

test("promoteRelease keeps the previous release for rollback", () => withTempDirectory(async (root) => {
  const current = join(root, "current");
  const staging = join(root, "staging");
  const previous = join(root, "previous");
  await mkdir(current);
  await mkdir(staging);
  await writeFile(join(current, "version"), "one");
  await writeFile(join(staging, "version"), "two");

  await promoteRelease({ current, staging, previous });

  assert.equal(await readFile(join(current, "version"), "utf8"), "two");
  assert.equal(await readFile(join(previous, "version"), "utf8"), "one");
}));

test("promoteRelease mirrors a release when Windows locks the current directory rename", () => withTempDirectory(async (root) => {
  const current = join(root, "current");
  const staging = join(root, "staging");
  const previous = join(root, "previous");
  const legacyWorkspace = join(root, "legacy-workspace");
  await mkdir(current);
  await mkdir(staging);
  await mkdir(legacyWorkspace);
  await mkdir(join(staging, "workspace"));
  await writeFile(join(current, "version"), "one");
  await writeFile(join(legacyWorkspace, "package.json"), "{}");
  await writeFile(join(staging, "version"), "two");
  await writeFile(join(staging, "workspace", "package.json"), "{}");
  await symlink(legacyWorkspace, join(current, "legacy-workspace-link"), process.platform === "win32" ? "junction" : "dir");
  await symlink(join(staging, "workspace"), join(staging, "workspace-link"), process.platform === "win32" ? "junction" : "dir");
  let renameAttempts = 0;
  const renameDirectory = async () => {
    renameAttempts += 1;
    const error = new Error("directory locked");
    error.code = "EPERM";
    throw error;
  };

  await promoteRelease({ current, staging, previous }, { renameDirectory });

  assert.equal(renameAttempts, 1);
  assert.equal(await readFile(join(current, "version"), "utf8"), "two");
  assert.equal(await readFile(join(previous, "version"), "utf8"), "one");
  assert.equal(await readFile(join(current, "workspace-link", "package.json"), "utf8"), "{}");
  assert.equal((await lstat(join(current, "workspace-link"))).isSymbolicLink(), false);
  await assert.rejects(stat(join(previous, "legacy-workspace-link")));
  await assert.rejects(stat(staging));
}));

test("promoteRelease rejects links that escape the verified release", () => withTempDirectory(async (root) => {
  const current = join(root, "current");
  const staging = join(root, "staging");
  const previous = join(root, "previous");
  const outside = join(root, "outside");
  await mkdir(current);
  await mkdir(staging);
  await mkdir(outside);
  await writeFile(join(current, "version"), "one");
  await writeFile(join(outside, "secret"), "private");
  await symlink(outside, join(staging, "outside-link"), process.platform === "win32" ? "junction" : "dir");
  const renameDirectory = async () => {
    const error = new Error("directory locked");
    error.code = "EPERM";
    throw error;
  };

  await assert.rejects(
    promoteRelease({ current, staging, previous }, { renameDirectory }),
    /release_link_outside_staging/
  );
  assert.equal(await readFile(join(current, "version"), "utf8"), "one");
}));

test("promoteRelease rejects managed paths outside safe home", () => withTempDirectory(async (root) => {
  const safeHome = join(root, "safe");
  const outside = join(root, "outside");
  const current = join(safeHome, "current");
  const previous = join(safeHome, "previous");
  await mkdir(current, { recursive: true });
  await mkdir(outside, { recursive: true });

  await assert.rejects(
    promoteRelease({ safeHome, current, staging: outside, previous }),
    /outside safe runtime home/
  );
}));

test("rollbackRelease restores the previous release", () => withTempDirectory(async (root) => {
  const current = join(root, "current");
  const previous = join(root, "previous");
  const failed = join(root, "failed");
  await mkdir(current);
  await mkdir(previous);
  await writeFile(join(current, "version"), "broken");
  await writeFile(join(previous, "version"), "working");

  await rollbackRelease({ current, previous, failed });

  assert.equal(await readFile(join(current, "version"), "utf8"), "working");
  assert.equal(await readFile(join(failed, "version"), "utf8"), "broken");
}));

test("rollbackRelease mirrors the previous release when Windows locks the current directory rename", () => withTempDirectory(async (root) => {
  const current = join(root, "current");
  const previous = join(root, "previous");
  const failed = join(root, "failed");
  await mkdir(current);
  await mkdir(previous);
  await writeFile(join(current, "version"), "broken");
  await writeFile(join(previous, "version"), "working");
  let renameAttempts = 0;
  const renameDirectory = async () => {
    renameAttempts += 1;
    const error = new Error("directory locked");
    error.code = "EPERM";
    throw error;
  };

  await rollbackRelease({ current, previous, failed }, { renameDirectory });

  assert.equal(renameAttempts, 1);
  assert.equal(await readFile(join(current, "version"), "utf8"), "working");
  assert.equal(await readFile(join(failed, "version"), "utf8"), "broken");
  await assert.rejects(stat(previous));
}));
