/**
 * P0-2: CLI attaches to Local Service; claim/deliver via RPC only.
 * Real service lifecycle — no parallel direct-core mutation for task path.
 */
import assert from "node:assert/strict";
import { spawn, type SpawnOptions } from "node:child_process";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { test } from "node:test";
import { fileURLToPath, pathToFileURL } from "node:url";
import { scaffoldInWorkspace } from "../src/core/scaffold.js";
import { NodeFs } from "../src/fs/node-fs.js";
import { startLocalTentService } from "../src/service/service.js";
import { createServiceClient, type ServiceClient } from "../src/service/client.js";
import {
  readServiceEndpoint,
  serviceEndpointPath,
  writeServiceEndpoint,
} from "../src/service/data-dir.js";
import {
  attachOrBootstrapService,
  tryAttachService,
  cliServiceChildEnv,
  resolveDefaultServiceEntry,
} from "../src/cli/service-attach.js";
import { runTaskCommand } from "../src/cli/task-rpc.js";
import { ensureMountedWorkspace } from "../src/cli/workspace-context.js";
import {
  attachOrStartService,
  tryAttach as tryAttachDesktop,
} from "../src/desktop/client/service-attach.js";
import { DesktopServiceHost } from "../src/desktop/main/service-host.js";
import { serviceLeasePath } from "../src/service/service-lease.js";

const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const serviceEntry = path.join(repoRoot, "service.mjs");
const sourceServiceEntry = path.join(repoRoot, "src", "service", "cli.ts");
const sourceServiceModuleUrl = pathToFileURL(
  path.join(repoRoot, "src", "service", "service.ts")
).href;

const spawnSourceService = ((
  _command: string,
  args: readonly string[] = [],
  options: SpawnOptions = {}
) => {
  const dataDirFlag = args.indexOf("--data-dir");
  assert.ok(dataDirFlag >= 0 && args[dataDirFlag + 1]);
  return spawn(
    process.execPath,
    [
      "--import",
      "tsx",
      sourceServiceEntry,
      "start",
      "--data-dir",
      args[dataDirFlag + 1]!,
    ],
    { ...options, cwd: repoRoot }
  );
}) as typeof spawn;

function spawnDelayedSourceService(dataDir: string, options: SpawnOptions) {
  const script = `
    setTimeout(async () => {
      const { startLocalTentService } = await import(${JSON.stringify(sourceServiceModuleUrl)});
      await startLocalTentService({ dataDir: ${JSON.stringify(dataDir)}, writeEndpoint: true });
    }, 250);
    setInterval(() => {}, 1000);
  `;
  return spawn(process.execPath, ["--import", "tsx", "--eval", script], {
    ...options,
    cwd: repoRoot,
  });
}

async function makeWorkspace(name = "cli-p02"): Promise<string> {
  const workspace = await fs.mkdtemp(path.join(os.tmpdir(), "tent-cli-ws-"));
  const fsa = new NodeFs(workspace);
  await scaffoldInWorkspace(fsa, {
    name,
    nodes: [{ name: "inbox", type: "prompt", body: "# inbox\n" }],
  });
  await fsa.writeFile(
    ".tent/roles.json",
    JSON.stringify(
      {
        roles: [
          { id: "rl-executor", name: "executor", prompt: "do work" },
          { id: "rl-orchestrator", name: "orchestrator", prompt: "dispatch" },
        ],
      },
      null,
      2
    ) + "\n"
  );
  return workspace;
}

async function withService<T>(
  fn: (svc: Awaited<ReturnType<typeof startLocalTentService>>, dataDir: string) => Promise<T>
): Promise<T> {
  const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "tent-cli-data-"));
  const svc = await startLocalTentService({ dataDir, writeEndpoint: true });
  try {
    return await fn(svc, dataDir);
  } finally {
    await svc.stop();
  }
}

async function enterRoleClient(
  svc: Awaited<ReturnType<typeof startLocalTentService>>,
  workspaceId: string,
  workspace: string
): Promise<{ client: ServiceClient; env: NodeJS.ProcessEnv }> {
  const setup = createServiceClient({ baseUrl: svc.url, token: svc.token });
  const entered = (await setup.sessionEnter({
    workspaceId,
    roleId: "rl-executor",
    cwd: workspace,
  })) as { session: { sessionId: string }; sessionToken: string };
  return {
    client: createServiceClient({
      baseUrl: svc.url,
      token: svc.token,
      currentSessionId: entered.session.sessionId,
      currentSessionToken: entered.sessionToken,
    }),
    env: {
      ...process.env,
      TENT_SESSION_ID: entered.session.sessionId,
      TENT_SESSION_TOKEN: entered.sessionToken,
    },
  };
}

async function waitForProcessExit(pid: number, timeoutMs = 5_000): Promise<void> {
  const deadline = Date.now() + timeoutMs;
  while (Date.now() < deadline) {
    try {
      process.kill(pid, 0);
    } catch {
      return;
    }
    await new Promise((resolve) => setTimeout(resolve, 25));
  }
  assert.fail(`process ${pid} did not exit within ${timeoutMs}ms`);
}

test("tryAttachService: requires endpoint token; health open is not enough", async () => {
  await withService(async (svc, dataDir) => {
    const ok = await tryAttachService(dataDir);
    assert.ok(ok);
    assert.equal(ok!.endpoint.token, svc.token);
    assert.equal(ok!.client.token, svc.token);

    // Strip token from endpoint — attach must fail even if health is up.
    const epPath = serviceEndpointPath(
      dataDir,
      svc.endpoint!.instanceId,
      svc.endpoint!.startedAt
    );
    const raw = JSON.parse(await fs.readFile(epPath, "utf8")) as Record<string, unknown>;
    delete raw.token;
    await fs.writeFile(epPath, JSON.stringify(raw, null, 2) + "\n", "utf8");
    const bad = await tryAttachService(dataDir);
    assert.equal(bad, null);

    const health = await fetch(`${svc.url}/health`);
    assert.equal(health.status, 200);
  });
});

test("tryAttachService: forged public health cannot authenticate an endpoint", async () => {
  const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "tent-cli-public-health-"));
  const endpoint = {
    instanceId: "instance-public-health",
    pid: 4321,
    host: "127.0.0.1",
    port: 7788,
    startedAt: "2026-08-10T00:00:00.000Z",
    version: "0.1.0",
    token: "invalid-token",
  };
  await writeServiceEndpoint(dataDir, endpoint);
  let publicHealthCalls = 0;
  let authenticatedRpcCalls = 0;
  const fakeFetch: typeof fetch = async (input, init) => {
    const url = String(input);
    if (url.endsWith("/health")) {
      publicHealthCalls += 1;
      return jsonResponse(200, {
        status: "ok",
        protocolVersion: 10,
        instanceId: endpoint.instanceId,
        pid: endpoint.pid,
        startedAt: endpoint.startedAt,
      });
    }
    assert.equal(url.endsWith("/rpc"), true);
    assert.equal(init?.method, "POST");
    authenticatedRpcCalls += 1;
    return new Response("Unauthorized", { status: 401 });
  };

  assert.equal(await tryAttachService(dataDir, fakeFetch), null);
  assert.equal(authenticatedRpcCalls, 1);
  assert.equal(publicHealthCalls, 0);
});

test("tryAttachService: hanging authenticated candidates are aborted without hiding a healthy generation", async () => {
  const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "tent-cli-hanging-probe-"));
  const healthy = {
    instanceId: "instance-healthy-probe",
    pid: 5101,
    host: "127.0.0.1",
    port: 25101,
    startedAt: "2026-08-10T00:00:00.000Z",
    version: "0.1.0",
    token: "healthy-token",
  };
  const hanging = {
    ...healthy,
    instanceId: "instance-hanging-probe",
    pid: 5102,
    port: 25102,
    startedAt: "2026-08-10T00:00:01.000Z",
    token: "hanging-token",
  };
  await writeServiceEndpoint(dataDir, healthy);
  await writeServiceEndpoint(dataDir, hanging);
  let aborted = 0;
  const fakeFetch: typeof fetch = async (input, init) => {
    const url = new URL(String(input));
    if (url.port === String(hanging.port)) {
      return new Promise<Response>((_resolve, reject) => {
        init?.signal?.addEventListener(
          "abort",
          () => {
            aborted += 1;
            reject(new DOMException("aborted", "AbortError"));
          },
          { once: true }
        );
      });
    }
    const request = JSON.parse(String(init?.body)) as { id: unknown };
    return jsonResponse(200, {
      jsonrpc: "2.0",
      id: request.id,
      result: {
        status: "ok",
        protocolVersion: 10,
        instanceId: healthy.instanceId,
        pid: healthy.pid,
        startedAt: healthy.startedAt,
      },
    });
  };

  const startedAt = Date.now();
  const attached = await tryAttachService(dataDir, fakeFetch);
  assert.equal(attached?.endpoint.instanceId, healthy.instanceId);
  assert.equal(aborted, 1);
  assert.ok(Date.now() - startedAt < 2_500);

  const allHangingDir = await fs.mkdtemp(path.join(os.tmpdir(), "tent-cli-all-hanging-"));
  await writeServiceEndpoint(allHangingDir, hanging);
  assert.equal(await tryAttachService(allHangingDir, fakeFetch), null);
  assert.equal(aborted, 2);
});

test("desktop tryAttach requires authenticated token and exact endpoint process identity", async () => {
  await withService(async (svc, dataDir) => {
    const endpoint = await readServiceEndpoint(dataDir);
    assert.ok(endpoint);

    const endpointPath = serviceEndpointPath(
      dataDir,
      endpoint!.instanceId,
      endpoint!.startedAt
    );
    await fs.writeFile(
      endpointPath,
      JSON.stringify({ ...endpoint!, token: "stale-token" }, null, 2) + "\n",
      "utf8"
    );
    assert.equal(await tryAttachDesktop(dataDir), null);
    assert.equal((await fetch(`${svc.url}/health`)).status, 200);

    await fs.writeFile(
      endpointPath,
      JSON.stringify({ ...endpoint!, pid: endpoint!.pid + 1 }, null, 2) + "\n",
      "utf8"
    );
    assert.equal(await tryAttachDesktop(dataDir), null);

    await fs.writeFile(endpointPath, JSON.stringify(endpoint!, null, 2) + "\n", "utf8");
    const attached = await tryAttachDesktop(dataDir);
    assert.ok(attached);
    assert.equal(attached!.client.token, svc.token);
  });
});

test("attachOrBootstrapService: reuses healthy endpoint; attachOnly fails when missing", async () => {
  await withService(async (svc, dataDir) => {
    const attached = await attachOrBootstrapService({
      dataDir,
      attachOnly: true,
      packageRoot: repoRoot,
    });
    assert.equal(attached.started, false);
    assert.equal(attached.url, svc.url);
    assert.equal(attached.client.token, svc.token);

    const orphanDir = await fs.mkdtemp(path.join(os.tmpdir(), "tent-cli-orphan-"));
    await assert.rejects(
      () =>
        attachOrBootstrapService({
          dataDir: orphanDir,
          attachOnly: true,
          packageRoot: repoRoot,
        }),
      /No healthy Local Tent Service/
    );
  });
});

test("attachOrBootstrapService: bootstrap starts current source; CLI exit does not kill it", async () => {
  const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "tent-cli-boot-"));
  let childPid: number | undefined;
  try {
    // Ensure no stale endpoint
    assert.equal(await readServiceEndpoint(dataDir), null);

    const attached = await attachOrBootstrapService({
      dataDir,
      serviceEntry,
      spawnFn: spawnSourceService,
      packageRoot: repoRoot,
      readyTimeoutMs: 20_000,
    });
    assert.equal(attached.started, true);
    assert.ok(attached.endpoint.token);
    childPid = attached.child?.pid;
    assert.ok(childPid && childPid > 0);
    assert.doesNotThrow(() => process.kill(childPid!, 0));

    const health1 = (await attached.client.health()) as { status: string };
    assert.equal(health1.status, "ok");

    // Simulate CLI process end: drop client reference; service must stay healthy.
    const re = await tryAttachService(dataDir);
    assert.ok(re);
    const health2 = await fetch(`${re!.url}/health`);
    assert.equal(health2.status, 200);
    assert.equal((await health2.json() as { status: string }).status, "ok");
  } finally {
    // Stop bootstrapped service via its endpoint
    const ep = await readServiceEndpoint(dataDir);
    if (ep) {
      try {
        // No stop RPC — kill process by pid if still running
        if (ep.pid) process.kill(ep.pid);
      } catch {
        // already dead
      }
    } else if (childPid) {
      try {
        process.kill(childPid);
      } catch {
        // ignore
      }
    }
  }
});

test("attachOrBootstrapService: concurrent bootstraps converge on one service", async () => {
  const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "tent-cli-race-"));
  try {
    const [first, second] = await Promise.all([
      attachOrBootstrapService({
        dataDir,
        serviceEntry,
        spawnFn: spawnSourceService,
        packageRoot: repoRoot,
        readyTimeoutMs: 20_000,
      }),
      attachOrBootstrapService({
        dataDir,
        serviceEntry,
        spawnFn: spawnSourceService,
        packageRoot: repoRoot,
        readyTimeoutMs: 20_000,
      }),
    ]);

    assert.equal(first.endpoint.instanceId, second.endpoint.instanceId);
    assert.equal(first.endpoint.pid, second.endpoint.pid);
    assert.equal(first.url, second.url);
    assert.equal((await first.client.health() as { status: string }).status, "ok");
    assert.equal((await second.client.health() as { status: string }).status, "ok");
  } finally {
    const endpoint = await readServiceEndpoint(dataDir);
    if (endpoint?.pid) {
      try {
        process.kill(endpoint.pid);
      } catch {
        // already stopped
      }
    }
  }
});

test("DesktopServiceHost and seven CLI bootstraps converge on one source Service", async () => {
  const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "tent-source-bootstrap-race-"));
  const staleEndpoint = {
    instanceId: "instance-locked-stale",
    pid: 999_999,
    host: "127.0.0.1",
    port: 9,
    startedAt: "2026-01-01T00:00:00.000Z",
    version: "0.1.0",
    token: "stale-token",
  };
  const staleFile = await writeServiceEndpoint(dataDir, staleEndpoint);
  const staleHandle = await fs.open(staleFile, "r");
  const host = new DesktopServiceHost((options) =>
    attachOrStartService({
      ...options,
      dataDir,
      serviceEntry,
      spawnFn: spawnSourceService,
      readyTimeoutMs: 30_000,
      pollMs: 5,
    })
  );
  let results: Array<{
    endpoint: { instanceId: string; pid: number; startedAt: string };
    child: { pid?: number } | null;
  }> = [];
  try {
    results = await Promise.all([
      host.ensureAttached({ dataDir, serviceEntry }),
      ...Array.from({ length: 7 }, () =>
        attachOrBootstrapService({
          dataDir,
          serviceEntry,
          spawnFn: spawnSourceService,
          packageRoot: repoRoot,
          readyTimeoutMs: 30_000,
          pollMs: 5,
        })
      ),
    ]);
    const identities = new Set(
      results.map(
        (result) =>
          `${result.endpoint.instanceId}:${result.endpoint.pid}:${result.endpoint.startedAt}`
      )
    );
    assert.deepEqual([...identities], [
      `${results[0].endpoint.instanceId}:${results[0].endpoint.pid}:${results[0].endpoint.startedAt}`,
    ]);
    assert.equal((await readServiceEndpoint(dataDir))?.instanceId, results[0].endpoint.instanceId);
  } finally {
    await staleHandle.close();
    await host.disposeShellOnly();
    const exactPids = new Set<number>();
    for (const result of results) {
      if (result.child?.pid) exactPids.add(result.child.pid);
      exactPids.add(result.endpoint.pid);
    }
    for (const pid of exactPids) {
      try {
        process.kill(pid);
      } catch {
        // already stopped
      }
    }
    await Promise.all([...exactPids].map((pid) => waitForProcessExit(pid)));
  }
});

test("CLI attach timeout stops its owned child before any endpoint or lease can publish", async () => {
  const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "tent-cli-timeout-pipes-"));
  let spawned: ReturnType<typeof spawn> | undefined;
  const spawnHung = ((_command: string, _args: readonly string[], options: SpawnOptions) => {
    spawned = spawnDelayedSourceService(dataDir, options);
    return spawned;
  }) as typeof spawn;
  await assert.rejects(
    () =>
      attachOrBootstrapService({
        dataDir,
        serviceEntry,
        spawnFn: spawnHung,
        readyTimeoutMs: 50,
        pollMs: 5,
      }),
    /Timed out waiting/
  );
  assert.ok(spawned?.pid);
  await waitForProcessExit(spawned!.pid!);
  assert.equal(spawned?.stdout?.destroyed, true);
  assert.equal(spawned?.stderr?.destroyed, true);
  await new Promise((resolve) => setTimeout(resolve, 350));
  assert.equal(await readServiceEndpoint(dataDir), null);
  await assert.rejects(() => fs.access(serviceLeasePath(dataDir)), /ENOENT/);
});

test("cliServiceChildEnv: sets TENT_SERVICE_DATA_DIR; does not write workspace token", async () => {
  const dataDir = path.join(os.tmpdir(), "tent-cli-env-data");
  const env = cliServiceChildEnv({ FOO: "bar" }, dataDir);
  assert.equal(env.TENT_SERVICE_DATA_DIR, dataDir);
  assert.equal(env.FOO, "bar");
  assert.equal(env.ELECTRON_RUN_AS_NODE, "1");
});

test("resolveDefaultServiceEntry finds service.mjs from package root", async () => {
  const entry = await resolveDefaultServiceEntry(repoRoot);
  assert.ok(entry.endsWith("service.mjs") || entry.includes("service"));
  await fs.access(entry);
});

test("ensureMountedWorkspace mounts once and reuses", async () => {
  const ws = await makeWorkspace();
  await withService(async (svc) => {
    const client = createServiceClient({ baseUrl: svc.url, token: svc.token });
    const first = await ensureMountedWorkspace(client, { workspace: ws });
    assert.ok(first.workspaceId);
    assert.equal(path.resolve(first.workspaceRoot), path.resolve(ws));

    const second = await ensureMountedWorkspace(client, { workspace: ws });
    assert.equal(second.workspaceId, first.workspaceId);

    // Token must not appear under workspace
    const walk = async (dir: string): Promise<string[]> => {
      const out: string[] = [];
      for (const ent of await fs.readdir(dir, { withFileTypes: true })) {
        const p = path.join(dir, ent.name);
        if (ent.isDirectory()) out.push(...(await walk(p)));
        else out.push(p);
      }
      return out;
    };
    for (const file of await walk(ws)) {
      const text = await fs.readFile(file, "utf8");
      assert.ok(!text.includes(svc.token), `token leaked into ${file}`);
    }
  });
});

test("task RPC layer: claim → submit; ServiceClient observes same state; service stays healthy", async () => {
  const ws = await makeWorkspace();
  await withService(async (svc, dataDir) => {
    const observer = createServiceClient({ baseUrl: svc.url, token: svc.token });
    const mount = (await observer.mount(ws)) as { workspaceId: string };
    const workspaceId = mount.workspaceId;

    // Create a coordination box + dispatch via service (sole mutation entry)
    const created = (await observer.call("docs.createNote", {
      workspaceId,
      name: "work-item",
      type: "prompt",
    })) as { nodeId: string };
    const nodeId = created.nodeId;

    const dispatched = (await observer.taskDispatch(workspaceId, {
      nodeIds: [nodeId],
      assigneeRoleId: "rl-executor",
      prompt: "Ship CLI attach",
      requester: { kind: "user", id: "user" },
      acceptMode: "review-required",
    })) as { taskPath: string; state: string };
    assert.equal(dispatched.state, "queued");
    const taskPath = dispatched.taskPath;
    const role = await enterRoleClient(svc, workspaceId, ws);

    // Claim through CLI command layer (uses same attach + RPC path; inject client for determinism)
    const claim = await runTaskCommand("claim", [taskPath], {
      client: role.client,
      cwd: ws,
      dataDir,
    });
    assert.equal(claim.exitCode, 0, claim.stderr);
    assert.match(claim.stdout, /Claimed via service RPC/);

    const afterClaim = (await observer.taskGet(workspaceId, taskPath)) as {
      task: { state: string };
    };
    assert.equal(afterClaim.task.state, "running");

    const listed = (await observer.taskList(workspaceId)) as {
      tasks: Array<{ path: string; state: string }>;
    };
    const found = listed.tasks.find((t) => t.path === taskPath);
    assert.ok(found);
    assert.equal(found!.state, "running");

    // Zero-commit submit: commit-bearing TaskResult requires an honest Git
    // integration target; non-Git fixtures must not invent deadbeef SHAs.
    const submit = await runTaskCommand(
      "submit",
      [taskPath, "--report", "Done via CLI RPC"],
      { client: role.client, cwd: ws, dataDir }
    );
    assert.equal(submit.exitCode, 0, submit.stderr);
    assert.match(submit.stdout, /Submitted via service RPC/);

    const afterSubmit = (await observer.taskGet(workspaceId, taskPath)) as {
      task: { state: string; currentResultId?: string };
    };
    assert.equal(afterSubmit.task.state, "submitted");

    // CLI "exit" does not stop service
    const health = await fetch(`${svc.url}/health`);
    assert.equal(health.status, 200);
    assert.equal((await health.json() as { status: string }).status, "ok");
  });
});

test("task claim/submit via attach (not injected client) sees same ServiceClient state", async () => {
  const ws = await makeWorkspace();
  await withService(async (svc, dataDir) => {
    const setup = createServiceClient({ baseUrl: svc.url, token: svc.token });
    const mount = (await setup.mount(ws)) as { workspaceId: string };
    const created = (await setup.call("docs.createNote", {
      workspaceId: mount.workspaceId,
      name: "agent-job",
      type: "prompt",
    })) as { nodeId: string };
    const dispatched = (await setup.taskDispatch(mount.workspaceId, {
      nodeIds: [created.nodeId],
      assigneeRoleId: "rl-executor",
      prompt: "agent path",
      requester: { kind: "user", id: "user" },
    })) as { taskPath: string };
    const role = await enterRoleClient(svc, mount.workspaceId, ws);

    // Attach from endpoint file (what a real CLI process does)
    const claim = await runTaskCommand("claim", [dispatched.taskPath, "--json"], {
      cwd: ws,
      dataDir,
      attachOnly: true,
      packageRoot: repoRoot,
      env: role.env,
    });
    assert.equal(claim.exitCode, 0, claim.stderr);
    const claimJson = JSON.parse(claim.stdout) as { state: string };
    assert.equal(claimJson.state, "running");

    const submit = await runTaskCommand(
      "submit",
      [dispatched.taskPath, "--report", "agent result", "--json"],
      { cwd: ws, dataDir, attachOnly: true, packageRoot: repoRoot, env: role.env }
    );
    assert.equal(submit.exitCode, 0, submit.stderr);
    const submitJson = JSON.parse(submit.stdout) as { state: string };
    assert.equal(submitJson.state, "submitted");

    const observer = createServiceClient({ baseUrl: svc.url, token: svc.token });
    const got = (await observer.taskGet(mount.workspaceId, dispatched.taskPath)) as {
      task: { state: string };
    };
    assert.equal(got.task.state, "submitted");

    // Service still healthy after command layer finished
    const h = (await observer.health()) as { status: string };
    assert.equal(h.status, "ok");
  });
});

test("task command errors: missing report / unknown sub / attach-only miss", async () => {
  const missing = await runTaskCommand("nosuch", [], { attachOnly: true, dataDir: path.join(os.tmpdir(), "no-svc-" + Date.now()) });
  assert.equal(missing.exitCode, 1);
  assert.match(missing.stderr, /Unknown task subcommand|No healthy Local Tent Service/);

  const ws = await makeWorkspace();
  await withService(async (svc, dataDir) => {
    const client = createServiceClient({ baseUrl: svc.url, token: svc.token });
    const mount = (await client.mount(ws)) as { workspaceId: string };
    const created = (await client.call("docs.createNote", {
      workspaceId: mount.workspaceId,
      name: "err-box",
      type: "prompt",
    })) as { nodeId: string };
    const dispatched = (await client.taskDispatch(mount.workspaceId, {
      nodeIds: [created.nodeId],
      assigneeRoleId: "rl-executor",
      prompt: "x",
      requester: { kind: "user", id: "user" },
    })) as { taskPath: string };
    const role = await enterRoleClient(svc, mount.workspaceId, ws);
    await role.client.taskClaim(mount.workspaceId, dispatched.taskPath);

    const noReport = await runTaskCommand("submit", [dispatched.taskPath], {
      client,
      cwd: ws,
    });
    assert.equal(noReport.exitCode, 1);
    assert.match(noReport.stderr, /--report/);
  });
});

test("task list/get human output uses canonical assignee fields", async () => {
  const ws = await makeWorkspace();
  await withService(async (svc, dataDir) => {
    const client = createServiceClient({ baseUrl: svc.url, token: svc.token });
    const mount = (await client.mount(ws)) as { workspaceId: string };
    const created = (await client.call("docs.createNote", {
      workspaceId: mount.workspaceId,
      name: "list-me",
      type: "prompt",
    })) as { nodeId: string };
    const dispatched = (await client.taskDispatch(mount.workspaceId, {
      nodeIds: [created.nodeId],
      assigneeRoleId: "rl-executor",
      prompt: "list test",
      requester: { kind: "user", id: "user" },
    })) as { taskPath: string };

    const list = await runTaskCommand("list", [], { client, cwd: ws, dataDir });
    assert.equal(list.exitCode, 0, list.stderr);
    assert.match(list.stdout, /workspaceId:/);
    assert.match(list.stdout, new RegExp(dispatched.taskPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));

    const get = await runTaskCommand("get", [dispatched.taskPath], { client, cwd: ws, dataDir });
    assert.equal(get.exitCode, 0, get.stderr);
    assert.match(get.stdout, /state: queued/);
    assert.match(get.stdout, /assigneeRoleId: rl-executor/);
  });
});

test("task-input ack CLI omits actor for persisted user reviewer path", async () => {
  const ws = await makeWorkspace("cli-task-input-user-ack");
  await withService(async (svc, dataDir) => {
    const client = createServiceClient({ baseUrl: svc.url, token: svc.token });
    const mount = (await client.mount(ws)) as { workspaceId: string };
    const created = (await client.call("docs.createNote", {
      workspaceId: mount.workspaceId,
      name: "uncertain-cli",
      type: "prompt",
    })) as { nodeId: string };
    const dispatched = (await client.taskDispatch(mount.workspaceId, {
      nodeIds: [created.nodeId],
      assigneeRoleId: "rl-executor",
      prompt: "cli user ack",
      requester: { kind: "user", id: "user" },
    })) as { taskPath: string };
    const role = await enterRoleClient(svc, mount.workspaceId, ws);
    await role.client.taskClaim(mount.workspaceId, dispatched.taskPath);
    const now = new Date().toISOString();
    await svc.ctx.taskInputs.add({
      id: "ti-cli-user-ack",
      workspaceId: mount.workspaceId,
      taskPath: dispatched.taskPath,
      role: "executor",
      kind: "user-input",
      text: "ambiguous",
      status: "pending",
      createdAt: now,
      updatedAt: now,
    });
    await svc.ctx.taskInputs.markUncertain(
      "ti-cli-user-ack",
      "confirmation failed"
    );

    const ack = await runTaskCommand(
      "task-input",
      [
        "ack",
        "ti-cli-user-ack",
        "--task",
        dispatched.taskPath,
        "--json",
      ],
      { client, cwd: ws, dataDir }
    );
    assert.equal(ack.exitCode, 0, ack.stderr);
    const parsed = JSON.parse(ack.stdout) as {
      input: { status: string; resolvedBy?: string; uncertainAt?: string };
    };
    assert.equal(parsed.input.status, "consumed");
    assert.equal(parsed.input.resolvedBy, "user");
    assert.ok(parsed.input.uncertainAt);
  });
});

test("writeServiceEndpoint never targets workspace; token only in dataDir", async () => {
  const ws = await makeWorkspace();
  const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "tent-cli-ep-"));
  await writeServiceEndpoint(dataDir, {
    instanceId: "instance-cli-endpoint",
    pid: 1,
    host: "127.0.0.1",
    port: 9,
    startedAt: new Date().toISOString(),
    version: "test",
    token: "secret-token-xyz",
  });
  const ep = await readServiceEndpoint(dataDir);
  assert.equal(ep?.token, "secret-token-xyz");
  // Workspace must not contain any machine-local endpoint record.
  await assert.rejects(() => fs.access(path.join(ws, "service.json")));
  await assert.rejects(() => fs.access(path.join(ws, ".tent", "service.json")));
});

// ---- ServiceClient.rpcRaw response boundary (injected fetchImpl) ----

function jsonResponse(status: number, body: unknown): Response {
  return new Response(JSON.stringify(body), {
    status,
    headers: { "content-type": "application/json" },
  });
}

function textResponse(status: number, body: string): Response {
  return new Response(body, {
    status,
    headers: { "content-type": "text/plain" },
  });
}

function clientWithFetch(fetchImpl: typeof fetch) {
  return createServiceClient({
    baseUrl: "http://127.0.0.1:9",
    token: "test-token",
    fetchImpl,
  });
}

/** Capture request id from outbound JSON-RPC body (idSeq starts at 1). */
function requestIdFromInit(init?: RequestInit): number {
  const body = typeof init?.body === "string" ? init.body : "";
  const parsed = JSON.parse(body) as { id: number };
  return parsed.id;
}

test("ServiceClient.rpcRaw: valid result and structured error", async () => {
  let n = 0;
  const client = clientWithFetch(async (_url, init) => {
    n += 1;
    const id = requestIdFromInit(init);
    if (n === 1) {
      return jsonResponse(200, { jsonrpc: "2.0", id, result: { ok: true, n: 1 } });
    }
    return jsonResponse(200, {
      jsonrpc: "2.0",
      id,
      error: { code: -32602, message: "Invalid params", data: { field: "x" } },
    });
  });

  const ok = await client.rpcRaw("workspace.list", {});
  assert.equal(ok.error, undefined);
  assert.deepEqual(ok.result, { ok: true, n: 1 });

  const err = await client.rpcRaw("workspace.list", {});
  assert.equal(err.result, undefined);
  assert.deepEqual(err.error, {
    code: -32602,
    message: "Invalid params",
    data: { field: "x" },
  });
});

test("ServiceClient.rpcRaw: 401 stays structured unauthorized (no body parse)", async () => {
  let readBody = false;
  const client = clientWithFetch(async () => {
    return {
      status: 401,
      ok: false,
      async text() {
        readBody = true;
        return "should-not-be-read";
      },
      async json() {
        readBody = true;
        return { leak: true };
      },
    } as unknown as Response;
  });

  const out = await client.rpcRaw("workspace.list", {});
  assert.deepEqual(out, {
    error: { code: -32001, message: "Unauthorized: invalid or missing service token" },
  });
  assert.equal(readBody, false);
});

test("ServiceClient.rpcRaw: invalid JSON / missing result|error / string error / id mismatch reject", async () => {
  const cases: Array<{
    name: string;
    status: number;
    body: string | unknown;
    match: RegExp;
  }> = [
    { name: "invalid JSON", status: 200, body: "not-json{", match: /invalid JSON/i },
    {
      name: "missing result and error",
      status: 200,
      body: { jsonrpc: "2.0", id: 1 },
      match: /exactly one of result or error/i,
    },
    {
      name: "string error",
      status: 200,
      body: { jsonrpc: "2.0", id: 1, error: "boom" },
      match: /invalid error object/i,
    },
    {
      name: "id mismatch",
      status: 200,
      body: { jsonrpc: "2.0", id: 999, result: {} },
      match: /id mismatch/i,
    },
  ];

  for (const c of cases) {
    // Fresh client so idSeq stays predictable (always 1).
    const client = clientWithFetch(async () =>
      typeof c.body === "string"
        ? textResponse(c.status, c.body)
        : jsonResponse(c.status, c.body)
    );
    await assert.rejects(
      () => client.rpcRaw("workspace.list", {}),
      (err: unknown) => {
        assert.ok(err instanceof Error, c.name);
        assert.match(err.message, c.match, c.name);
        // Must not silently resolve to undefined-shaped success.
        return true;
      },
      c.name
    );
  }
});

test("ServiceClient.rpcRaw: non-2xx with valid JSON-RPC error returns error; otherwise HTTP status only", async () => {
  // 413 with well-formed error (id may be null for parse/payload errors).
  {
    const client = clientWithFetch(async () =>
      jsonResponse(413, {
        jsonrpc: "2.0",
        id: null,
        error: { code: -32600, message: "Payload too large", data: { limit: 1 } },
      })
    );
    const out = await client.rpcRaw("workspace.list", {});
    assert.deepEqual(out, {
      error: { code: -32600, message: "Payload too large", data: { limit: 1 } },
    });
  }

  // Non-2xx garbage HTML must not leak body into the error message.
  {
    const secret = "INTERNAL_STACK_TRACE_SECRET_xyz";
    const client = clientWithFetch(async () => textResponse(502, `<html>${secret}</html>`));
    await assert.rejects(
      () => client.rpcRaw("workspace.list", {}),
      (err: unknown) => {
        assert.ok(err instanceof Error);
        assert.match(err.message, /HTTP 502/);
        assert.ok(!err.message.includes(secret));
        assert.ok(!err.message.includes("<html>"));
        return true;
      }
    );
  }

  // Non-2xx JSON that is not a valid JSON-RPC error envelope.
  {
    const client = clientWithFetch(async () =>
      jsonResponse(500, { error: "plain string", detail: "do-not-leak" })
    );
    await assert.rejects(
      () => client.rpcRaw("workspace.list", {}),
      (err: unknown) => {
        assert.ok(err instanceof Error);
        assert.match(err.message, /HTTP 500/);
        assert.ok(!err.message.includes("do-not-leak"));
        return true;
      }
    );
  }
});
