import { createHash } from "node:crypto";
import { promises as fs } from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

// ★ルート本体は静的 import★
import { GET as getCommands } from "@/app/api/ds-agent/commands/route";
import { POST as postResult } from "@/app/api/ds-agent/commands/[command_id]/result/route";
import { POST as postPrepared } from "@/app/api/ds-agent/commands/[command_id]/prepared/route";
import { POST as postChunk } from "@/app/api/ds-agent/commands/[command_id]/chunks/[seq]/route";
import { POST as postComplete } from "@/app/api/ds-agent/commands/[command_id]/complete/route";
import { POST as postFail } from "@/app/api/ds-agent/commands/[command_id]/fail/route";
import { LIST_RESULT_MAX_ITEMS, PATH_COMMAND_ID_MAX_LENGTH } from "@/lib/config/ds-agent";
import { createFakeRelay, flush } from "@/lib/ds-agent/__tests__/fake-relay";
import { __setRelayForTesting } from "@/lib/ds-agent/relay";
import { __setAgentTokenStoreForTesting, createAgentTokenStore, issueAgentToken } from "@/lib/ds-agent/tokens";
import type { DsAgentCommand } from "@/lib/types";

/**
 * 指示書AY 段階7-4 — 制御盤向けの6本（`app/api/ds-agent/**`）。
 * - ★Bearer なし・違う → 401（`requireAgent()` の再検証）★
 * - 表5 R7（どのルートでも last_seen_at が進む）・R35・R36・チャンクの 400 / 409 / 410・段階7-2 W1〜W11・prepared の検証
 * - ★表5 R44（オブジェクトでない本文は4ルートとも 400 result_invalid。500 にならない）★
 * - ★command_id / seq の 422（段階3-3 X2・X5・X6）★
 *
 * トークンストアは mkdtemp（★ストアとパスを一緒に差し替える。M13★）。relay は偽の時計。★agent のルートは DB を読まない★。
 */

vi.mock("@/lib/logger", async (importOriginal) => {
  const actual = await importOriginal<typeof import("@/lib/logger")>();
  return { ...actual, logEvent: vi.fn() };
});

let dir = "";
let fake: ReturnType<typeof createFakeRelay>;
let token002 = "";
let token003 = "";

beforeEach(async () => {
  dir = await fs.mkdtemp(path.join(os.tmpdir(), "dwatch-agent-routes-"));
  __setAgentTokenStoreForTesting(createAgentTokenStore(path.join(dir, "ds-agent-tokens.json")));
  token002 = (await issueAgentToken("DS-002")).token;
  token003 = (await issueAgentToken("DS-003")).token;
  fake = createFakeRelay({ chunkMaxBytes: 16 });
  __setRelayForTesting(fake.relay);
});

afterEach(async () => {
  __setRelayForTesting(null);
  __setAgentTokenStoreForTesting(null);
  await fs.rm(dir, { recursive: true, force: true });
});

const signal = () => new AbortController().signal;
const sha = (bytes: Uint8Array) => createHash("sha256").update(bytes).digest("hex");

interface CallOptions {
  token?: string | null;
  body?: BodyInit;
  headers?: Record<string, string>;
}

function agentRequest(pathname: string, method: string, opts: CallOptions = {}): Request {
  const token = opts.token === undefined ? token002 : opts.token;
  const headers: Record<string, string> = { ...(opts.headers ?? {}) };
  if (token !== null) headers.authorization = `Bearer ${token}`;
  return new Request(`http://localhost${pathname}`, { method, headers, body: opts.body });
}

const commands = (qs = "", opts: CallOptions = {}) =>
  getCommands(agentRequest(`/api/ds-agent/commands${qs}`, "GET", opts));

type CommandRoute = "result" | "prepared" | "complete" | "fail";
const COMMAND_ROUTES = { result: postResult, prepared: postPrepared, complete: postComplete, fail: postFail };

function commandPost(route: CommandRoute, commandId: string, body: string, opts: CallOptions = {}) {
  return COMMAND_ROUTES[route](
    agentRequest(`/api/ds-agent/commands/${commandId}/${route}`, "POST", { ...opts, body }),
    { params: Promise.resolve({ command_id: commandId }) }
  );
}

function chunkPost(commandId: string, seq: string, bytes: Uint8Array, opts: CallOptions & { sha?: string | null } = {}) {
  const headers: Record<string, string> = {};
  const hash = opts.sha === undefined ? sha(bytes) : opts.sha;
  if (hash !== null) headers["x-chunk-sha256"] = hash;
  return postChunk(
    agentRequest(`/api/ds-agent/commands/${commandId}/chunks/${seq}`, "POST", {
      ...opts,
      headers,
      body: Buffer.from(bytes),
    }),
    { params: Promise.resolve({ command_id: commandId, seq }) }
  );
}

async function errorCode(res: Response): Promise<string> {
  return ((await res.json()) as { error: { code: string } }).error.code;
}

/** 本文を1回だけ読んで code と message を両方返す（指示書BK K1） */
async function errorBody(res: Response): Promise<{ code: string; message: string }> {
  const error = ((await res.json()) as { error: { code: string; message: string } }).error;
  return { code: error.code, message: error.message };
}

/**
 * ルート経由の `GET /ds-agent/commands` は `requireAgent()`（async）のあとで poll に入る。
 * ★`flush()` 1回では足りないことがある★ので、接続中になるまで待つ（偽の時計のタイマー登録前に `at()` しない）。
 */
async function waitUntilConnected(serial = "DS-002"): Promise<void> {
  await vi.waitFor(
    () => {
      if (!fake.relay.agentStatus(serial).connected) {
        throw new Error(`${serial} が接続状態にならなかった`);
      }
    },
    { timeout: 4000, interval: 5 },
  );
}

/** ブラウザの役: 一覧を依頼し、制御盤の役: list の指示を受け取る */
async function pendingList() {
  const poll = fake.relay.poll("DS-002", "1.0.0", 25, signal());
  const list = fake.relay.requestList("DS-002");
  const command = (await poll) as DsAgentCommand;
  return { list, command };
}

/** ブラウザの役: エクスポートを依頼し、制御盤の役: export の指示を受け取る */
async function pendingExport() {
  const poll = fake.relay.poll("DS-002", "1.0.0", 25, signal());
  const created = fake.relay.createExport("DS-002", ["f1"], signal());
  const command = (await poll) as DsAgentCommand;
  return { created, command };
}

const VALID_LIST = {
  types: [{ key: "app", label: "アプリ" }],
  items: [{ file_id: "f1", name: "app/app.log", type: "app", size_bytes: 10, modified_at: "2026-10-19T11:00:00+09:00" }],
  truncated: false,
};

describe("認証（requireAgent の再検証）", () => {
  const cases: Array<[string, (opts: CallOptions) => Promise<Response>]> = [
    ["commands", (o) => commands("", o)],
    ["result", (o) => commandPost("result", "x", "{}", o)],
    ["prepared", (o) => commandPost("prepared", "x", "{}", o)],
    ["complete", (o) => commandPost("complete", "x", "{}", o)],
    ["fail", (o) => commandPost("fail", "x", "{}", o)],
    ["chunks", (o) => chunkPost("x", "0", new Uint8Array([1]), o)],
  ];

  it.each(cases)("%s: Bearer なし → 401", async (_name, call) => {
    const res = await call({ token: null });
    expect(res.status).toBe(401);
    expect(await errorCode(res)).toBe("unauthorized");
  });

  it.each(cases)("%s: 違う Bearer → 401", async (_name, call) => {
    const res = await call({ token: "Z".repeat(43) });
    expect(res.status).toBe(401);
  });

  // ★commands 以外★: requireAgent の touch だけで終わる。commands は poll で待つので別ケース
  it.each(cases.filter(([name]) => name !== "commands"))(
    "R7 %s: 認証が通れば last_seen_at が進む（D19）",
    async (_name, call) => {
      await fake.at(80);
      await call({});
      expect(fake.relay.agentStatus("DS-002").last_seen_at).toBe("2026-10-19T12:01:20+09:00");
    }
  );

  it("R7 commands: 認証が通れば last_seen_at が進む（D19）", async () => {
    await fake.at(80);
    const res = commands("?wait=1");
    await waitUntilConnected();
    expect(fake.relay.agentStatus("DS-002").last_seen_at).toBe("2026-10-19T12:01:20+09:00");
    await fake.at(81);
    expect((await res).status).toBe(204);
  });
});

describe("GET /ds-agent/commands", () => {
  it.each(["wait=0", "wait=26", "wait=abc"])("%s → 422（丸めない。表5 R8）", async (qs) => {
    const res = await commands(`?${qs}`);
    expect(res.status).toBe(422);
    expect(await errorCode(res)).toBe("validation_failed");
  });

  it("指示があれば 200（kind: list）・X-Agent-Version を記録", async () => {
    const res = commands("?wait=5", { headers: { "x-agent-version": "1.2.3" } });
    await waitUntilConnected();
    const list = fake.relay.requestList("DS-002");
    const done = await res;
    expect(done.status).toBe(200);
    const command = (await done.json()) as DsAgentCommand;
    expect(command).toMatchObject({ kind: "list", params: {} });
    expect(fake.relay.agentStatus("DS-002").agent_version).toBe("1.2.3");
    fake.relay.submitListResult("DS-002", command.command_id, VALID_LIST);
    await expect(list).resolves.toMatchObject({ kind: "ok" });
  });

  it("指示が無ければ wait 秒で 204・形の合わない X-Agent-Version は無視（拒否しない）", async () => {
    const res = commands("?wait=5", { headers: { "x-agent-version": "bad version!" } });
    await waitUntilConnected();
    await fake.at(5);
    const done = await res;
    expect(done.status).toBe(204);
    expect(fake.relay.agentStatus("DS-002").agent_version).toBeNull();
  });
});

describe("POST …/result（段階7-2 W1〜W11）", () => {
  it("W1 正しい結果 → 204、ブラウザに一覧が返る", async () => {
    const { list, command } = await pendingList();
    const res = await commandPost("result", command.command_id, JSON.stringify(VALID_LIST));
    expect(res.status).toBe(204);
    await expect(list).resolves.toEqual({ kind: "ok", result: VALID_LIST });
  });

  it("W9 { error } → 204、ブラウザには agent_error", async () => {
    const { list, command } = await pendingList();
    const res = await commandPost("result", command.command_id, JSON.stringify({ error: { code: "io_error", message: "m" } }));
    expect(res.status).toBe(204);
    await expect(list).resolves.toEqual({ kind: "agent_error", code: "io_error" });
  });

  it("W11 name に / を含む → 受理", async () => {
    const { command } = await pendingList();
    const body = { ...VALID_LIST, items: [{ ...VALID_LIST.items[0], name: "api_client/api_client.log.2026-10-18" }] };
    expect((await commandPost("result", command.command_id, JSON.stringify(body))).status).toBe(204);
  });

  it.each<[string, unknown]>([
    ["W2 key の重複", { ...VALID_LIST, types: [...VALID_LIST.types, { key: "app", label: "x" }] }],
    ["W3 types に無い type", { ...VALID_LIST, items: [{ ...VALID_LIST.items[0], type: "xyz" }] }],
    ["W4 オフセットなし", { ...VALID_LIST, items: [{ ...VALID_LIST.items[0], modified_at: "2026-10-19T11:00:00" }] }],
    ["W4b 存在しない日付", { ...VALID_LIST, items: [{ ...VALID_LIST.items[0], modified_at: "2026-02-30T00:00:00+09:00" }] }],
    ["W5 size_bytes が負", { ...VALID_LIST, items: [{ ...VALID_LIST.items[0], size_bytes: -1 }] }],
    ["W6 file_id の重複", { ...VALID_LIST, items: [VALID_LIST.items[0], { ...VALID_LIST.items[0], name: "app/b.log" }] }],
    [
      "W7 LIST_RESULT_MAX_ITEMS + 1 件",
      {
        ...VALID_LIST,
        items: Array.from({ length: LIST_RESULT_MAX_ITEMS + 1 }, (_, i) => ({ ...VALID_LIST.items[0], file_id: `f${i}` })),
      },
    ],
    ["R47 file_id が8193文字", { ...VALID_LIST, items: [{ ...VALID_LIST.items[0], file_id: "x".repeat(8193) }] }],
  ])("%s → 400 result_invalid・ブラウザの一覧は 502 で終わる（指示書BJ R43′）", async (_label, body) => {
    const { list, command } = await pendingList();
    const res = await commandPost("result", command.command_id, JSON.stringify(body));
    expect(res.status).toBe(400);
    expect(await errorCode(res)).toBe("result_invalid");
    await expect(list).resolves.toEqual({ kind: "agent_error", code: "result_invalid" });
    const after = await commandPost("result", command.command_id, JSON.stringify(VALID_LIST));
    expect(after.status).toBe(404);
    expect(await errorCode(after)).toBe("not_found");
  });

  it.each<[string, string]>([
    ["R45 key が空", ""],
    ["R46 key にカンマ", "a,b"],
    ["R46b key が空白だけ", " "],
    ["R46b key の先頭が空白", " app"],
  ])("%s → 400 result_invalid・typesの形式が不正です（指示書BK K1）", async (_label, bad) => {
    const body = {
      ...VALID_LIST,
      types: [{ key: bad, label: "x" }],
      items: [{ ...VALID_LIST.items[0], type: bad }],
    };
    const { list, command } = await pendingList();
    const res = await commandPost("result", command.command_id, JSON.stringify(body));
    expect(res.status).toBe(400);
    expect(await errorBody(res)).toEqual({ code: "result_invalid", message: "typesの形式が不正です" });
    await expect(list).resolves.toEqual({ kind: "agent_error", code: "result_invalid" });
    const after = await commandPost("result", command.command_id, JSON.stringify(VALID_LIST));
    expect(after.status).toBe(404);
    expect(await errorCode(after)).toBe("not_found");
  });

  it("W8 本体が 4MB を超える → 400 result_invalid・ブラウザの一覧は 502 で終わる", async () => {
    const { list, command } = await pendingList();
    const res = await commandPost("result", command.command_id, JSON.stringify(VALID_LIST) + " ".repeat(4 * 1024 * 1024));
    expect(res.status).toBe(400);
    await expect(list).resolves.toEqual({ kind: "agent_error", code: "result_invalid" });
  });

  it("R43‴ export の指示 id に形の崩れた /result → 400・エクスポートは続く", async () => {
    const { created, command } = await pendingExport();
    const res = await commandPost("result", command.command_id, JSON.stringify({ types: [], items: [] }));
    expect(res.status).toBe(400);
    expect(await errorCode(res)).toBe("result_invalid");
    expect(
      (
        await commandPost(
          "prepared",
          command.command_id,
          JSON.stringify({ total_bytes: 4, sha256: "a".repeat(64), file_count: 1 })
        )
      ).status
    ).toBe(204);
    await expect(created).resolves.toMatchObject({ kind: "prepared" });
  });

  it("R48 prepared の total_bytes: 0 → 400、1 はよい", async () => {
    const { created, command } = await pendingExport();
    expect(
      (
        await commandPost(
          "prepared",
          command.command_id,
          JSON.stringify({ total_bytes: 0, sha256: "a".repeat(64), file_count: 1 })
        )
      ).status
    ).toBe(400);
    expect(
      (
        await commandPost(
          "prepared",
          command.command_id,
          JSON.stringify({ total_bytes: 1, sha256: "a".repeat(64), file_count: 1 })
        )
      ).status
    ).toBe(204);
    await expect(created).resolves.toMatchObject({ kind: "prepared", totalBytes: 1 });
  });

  it("R50 /result に X-Agent-Version → connected の agent_version が入る", async () => {
    await commandPost("result", "x", "{}", { headers: { "x-agent-version": "1.2.3" } });
    const connected = fake.events.filter((e) => e.event === "ds_agent.connected");
    expect(connected).toHaveLength(1);
    expect(connected[0]).toMatchObject({ agent_version: "1.2.3" });
    expect(fake.relay.agentStatus("DS-002").agent_version).toBe("1.2.3");
  });

  it("R51 /result に形の合わない X-Agent-Version → agent_version は null（拒否しない）", async () => {
    await commandPost("result", "x", "{}", { headers: { "x-agent-version": "bad version!" } });
    expect(fake.relay.agentStatus("DS-002").agent_version).toBeNull();
    const connected = fake.events.filter((e) => e.event === "ds_agent.connected");
    expect(connected[0]).toMatchObject({ agent_version: null });
  });

  it("時間切れの後の結果 → 410 command_expired（表5 R3）", async () => {
    const { command } = await pendingList();
    await fake.at(fake.limits.listTimeoutSec);
    const res = await commandPost("result", command.command_id, JSON.stringify(VALID_LIST));
    expect(res.status).toBe(410);
    expect(await errorCode(res)).toBe("command_expired");
  });

  it("R35 DS-003 のトークンで DS-002 の指示に結果 → 404（403 にしない）", async () => {
    const { command } = await pendingList();
    const res = await commandPost("result", command.command_id, JSON.stringify(VALID_LIST), { token: token003 });
    expect(res.status).toBe(404);
  });
});

describe("表5 R44 — オブジェクトでない本文は4ルートとも 400 result_invalid（500 にしない）", () => {
  const routes: CommandRoute[] = ["result", "prepared", "complete", "fail"];
  const bodies = ["null", "[]", "0", '"x"'];
  it.each(routes.flatMap((route) => bodies.map((body) => [route, body] as const)))(
    "%s に %s → 400 result_invalid",
    async (route, body) => {
      const { command } = await pendingExport();
      const res = await commandPost(route, command.command_id, body);
      expect(res.status).toBe(400);
      expect(await errorCode(res)).toBe("result_invalid");
    }
  );
});

describe("POST …/prepared", () => {
  it("正しい本体 → 204・ブラウザに prepared", async () => {
    const { created, command } = await pendingExport();
    const res = await commandPost(
      "prepared",
      command.command_id,
      JSON.stringify({ total_bytes: 4, sha256: "a".repeat(64), file_count: 1 })
    );
    expect(res.status).toBe(204);
    await expect(created).resolves.toMatchObject({ kind: "prepared", totalBytes: 4 });
  });

  it.each<[string, unknown]>([
    ["sha256 が大文字", { total_bytes: 4, sha256: "A".repeat(64), file_count: 1 }],
    ["file_count が0", { total_bytes: 4, sha256: "a".repeat(64), file_count: 0 }],
    ["total_bytes が無い", { sha256: "a".repeat(64), file_count: 1 }],
  ])("%s → 400 result_invalid", async (_label, body) => {
    const { command } = await pendingExport();
    expect((await commandPost("prepared", command.command_id, JSON.stringify(body))).status).toBe(400);
  });

  it("R36 list の指示に prepared → 404", async () => {
    const { command } = await pendingList();
    const res = await commandPost(
      "prepared",
      command.command_id,
      JSON.stringify({ total_bytes: 4, sha256: "a".repeat(64), file_count: 1 })
    );
    expect(res.status).toBe(404);
  });
});

describe("パスパラメータ（段階3-3 X2・X5・X6）", () => {
  it("X2 command_id が長すぎる → 422", async () => {
    const res = await commandPost("fail", "c".repeat(PATH_COMMAND_ID_MAX_LENGTH + 1), "{}");
    expect(res.status).toBe(422);
  });

  it.each(["-1", "01", "1.0", "abc", "1e3", "1234567890"])("X5・X6 seq=%s → 422", async (seq) => {
    const res = await chunkPost("x", seq, new Uint8Array([1]));
    expect(res.status).toBe(422);
  });
});

describe("POST …/chunks/{seq}", () => {
  async function preparedExport(payload: Uint8Array) {
    const { created, command } = await pendingExport();
    fake.relay.submitPrepared("DS-002", command.command_id, { total_bytes: payload.length, sha256: sha(payload), file_count: 1 });
    const prepared = await created;
    if (prepared.kind !== "prepared") throw new Error("prepared にならない");
    return { exportCommandId: command.command_id, exportId: prepared.exportId };
  }

  async function sending(payload: Uint8Array) {
    const p = await preparedExport(payload);
    const poll = fake.relay.poll("DS-002", "1.0.0", 25, signal());
    const claim = fake.relay.claimExport("DS-002", p.exportId, signal());
    await poll;
    return { ...p, claim };
  }

  const payload = new Uint8Array(20).fill(5);

  it("X-Chunk-Sha256 が無い・形が違う → 400 chunk_invalid", async () => {
    const { exportCommandId } = await sending(payload);
    expect((await chunkPost(exportCommandId, "0", payload.subarray(0, 16), { sha: null })).status).toBe(400);
    expect((await chunkPost(exportCommandId, "0", payload.subarray(0, 16), { sha: "XYZ" })).status).toBe(400);
  });

  it("R20 send の前のチャンク → 409 chunk_out_of_order", async () => {
    const { exportCommandId } = await preparedExport(payload);
    const res = await chunkPost(exportCommandId, "0", payload.subarray(0, 16));
    expect(res.status).toBe(409);
    expect(await errorCode(res)).toBe("chunk_out_of_order");
  });

  it("R49 保留中に次のチャンク → 409・先の POST は 410 export_cancelled", async () => {
    const { exportCommandId, claim } = await sending(payload);
    const first = chunkPost(exportCommandId, "0", payload.subarray(0, 16));
    await flush();
    const claimed = await claim;
    if (claimed.kind !== "stream") throw new Error("stream にならない");
    // ★読まないまま次のチャンクを送る★
    const second = await chunkPost(exportCommandId, "1", payload.subarray(16));
    expect(second.status).toBe(409);
    expect(await errorCode(second)).toBe("chunk_out_of_order");
    const firstDone = await first;
    expect(firstDone.status).toBe(410);
    expect(await errorCode(firstDone)).toBe("export_cancelled");
    expect(fake.events.some((e) => e.event === "ds_logs.export_failed" && e.reason === "chunk_out_of_order")).toBe(
      true
    );
    await fake.at(fake.limits.exportIdleTimeoutSec + 1);
    expect(fake.events.some((e) => e.event === "ds_logs.export_failed" && e.reason === "flow_timeout")).toBe(false);
  });

  it("R23 ハッシュ不一致 → 400 chunk_invalid", async () => {
    const { exportCommandId, claim } = await sending(payload);
    // ★わざと違うハッシュ（全部 0）★
    const res = await chunkPost(exportCommandId, "0", payload.subarray(0, 16), { sha: "0".repeat(64) });
    expect(res.status).toBe(400);
    expect(await errorCode(res)).toBe("chunk_invalid");
    await expect(claim).resolves.toEqual({ kind: "agent_error", code: "chunk_invalid" });
  });

  it("R24 chunkMaxBytes + 1 → 400", async () => {
    const big = new Uint8Array(17).fill(5);
    const { exportCommandId } = await sending(big);
    expect((await chunkPost(exportCommandId, "0", big)).status).toBe(400);
  });

  it("取り消し・失敗の後のチャンク → 410 export_cancelled", async () => {
    const { exportCommandId, claim } = await sending(payload);
    await fake.at(fake.limits.exportFirstChunkTimeoutSec + 0.001);
    await expect(claim).resolves.toEqual({ kind: "timeout" });
    const res = await chunkPost(exportCommandId, "0", payload.subarray(0, 16));
    expect(res.status).toBe(410);
    expect(await errorCode(res)).toBe("export_cancelled");
  });

  it("R35 DS-003 のトークンで DS-002 の指示にチャンク → 404", async () => {
    const { exportCommandId } = await sending(payload);
    expect((await chunkPost(exportCommandId, "0", payload.subarray(0, 16), { token: token003 })).status).toBe(404);
  });

  it("R42 send の指示の command_id にチャンク → 404", async () => {
    const p = await preparedExport(payload);
    const poll = fake.relay.poll("DS-002", "1.0.0", 25, signal());
    void fake.relay.claimExport("DS-002", p.exportId, signal());
    const send = (await poll) as DsAgentCommand;
    expect((await chunkPost(send.command_id, "0", payload.subarray(0, 16))).status).toBe(404);
  });
});

describe("POST …/complete と …/fail", () => {
  it("complete: 流れていないのに complete → 400 result_invalid（照合が通らない）", async () => {
    const { command } = await pendingExport();
    fake.relay.submitPrepared("DS-002", command.command_id, { total_bytes: 4, sha256: "a".repeat(64), file_count: 1 });
    const res = await commandPost("complete", command.command_id, JSON.stringify({ total_bytes: 4, sha256: "a".repeat(64) }));
    expect(res.status).toBe(400);
    expect(await errorCode(res)).toBe("result_invalid");
  });

  it("complete: 終わった後 → 410 export_cancelled", async () => {
    const { command } = await pendingExport();
    fake.relay.failCommand("DS-002", command.command_id, "io_error", "m");
    const res = await commandPost("complete", command.command_id, JSON.stringify({ total_bytes: 4, sha256: "a".repeat(64) }));
    expect(res.status).toBe(410);
  });

  it("fail: 準備中 → 204・ブラウザに agent_error（制御盤の code）", async () => {
    const { created, command } = await pendingExport();
    const res = await commandPost("fail", command.command_id, JSON.stringify({ code: "too_large", message: "大きすぎます" }));
    expect(res.status).toBe(204);
    await expect(created).resolves.toEqual({ kind: "agent_error", code: "too_large" });
  });

  it("fail: 形の誤り（code が65文字）→ 400 result_invalid", async () => {
    const { command } = await pendingExport();
    const res = await commandPost("fail", command.command_id, JSON.stringify({ code: "c".repeat(65), message: "m" }));
    expect(res.status).toBe(400);
  });

  it("fail: 失効の後 → 410", async () => {
    const { command } = await pendingExport();
    fake.relay.failCommand("DS-002", command.command_id, "io_error", "m");
    const res = await commandPost("fail", command.command_id, JSON.stringify({ code: "io_error", message: "m" }));
    expect(res.status).toBe(410);
  });

  it("無い指示 → 404", async () => {
    const res = await commandPost("fail", "no-such", JSON.stringify({ code: "io_error", message: "m" }));
    expect(res.status).toBe(404);
  });
});
