import { execFileSync, spawn } from "node:child_process";
import { createServer } from "node:http";
import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import { fileURLToPath } from "node:url";
import { describe, expect, it, vi } from "vitest";

vi.mock("@agentclientprotocol/sdk", () => ({}));

import { KubernetesAcpNativeClient } from "../../apps/control-plane/src/kubernetes-acp-client.js";
import {
  assertEligiblePostPromptFailure,
  handle,
  sameImmutableInput,
} from "../../infra/agent-runner/runner.mjs";
import type { UnitOfWork } from "@awp/application";

describe("Kubernetes ACP coder workspace recovery", () => {
  it("injects only after a completed prompt and real non-empty tree evidence", () => {
    const mode = "dogfood-post-prompt-failure-17";
    const evidence = {
      diff: "diff --git a/file.txt b/file.txt\n+changed\n",
      workspaceCheckpointDigest: "a".repeat(40),
      workspaceCheckpointedAt: "2026-08-23T00:00:00.000Z",
    };
    const eligible = { selectionKind: "initial", failureInjection: mode };

    let failure: unknown;
    try {
      assertEligiblePostPromptFailure(eligible, { stopReason: "end_turn" }, evidence, mode);
    } catch (error) {
      failure = error;
    }
    expect(failure).toMatchObject({
      message: "dogfood native ACP post-prompt failure 17",
      checkpoint: evidence,
    });

    for (const [input, completion, candidateEvidence, configured] of [
      [
        { selectionKind: "retry", failureInjection: mode },
        { stopReason: "end_turn" },
        evidence,
        mode,
      ],
      [{ failureInjection: mode }, { stopReason: "end_turn" }, evidence, mode],
      [eligible, {}, evidence, mode],
      [eligible, { stopReason: "end_turn" }, { ...evidence, diff: "" }, mode],
      [eligible, { stopReason: "end_turn" }, evidence, "arbitrary-command"],
    ] as const) {
      expect(() =>
        assertEligiblePostPromptFailure(input, completion, candidateEvidence, configured),
      ).not.toThrow();
    }
  });

  it("reattaches an existing coder repository without destructive commands", async () => {
    const root = mkdtempSync(join(tmpdir(), "awp-kubectl-recovery-"));
    try {
      const log = join(root, "calls.jsonl");
      const kubectl = join(root, "kubectl-fake.mjs");
      writeFileSync(
        kubectl,
        `#!/usr/bin/env node\nimport { appendFileSync } from "node:fs";\nconst args = process.argv.slice(2);\nappendFileSync(${JSON.stringify(log)}, JSON.stringify(args) + "\\n");\nconst command = args.slice(args.indexOf("--") + 1);\nif (command[0] === "git" && command.includes("rev-parse")) process.stdout.write("${"1".repeat(40)}\\n");\n`,
      );
      chmodSync(kubectl, 0o755);
      const client = new KubernetesAcpNativeClient({} as UnitOfWork, {
        namespace: "awp-workspaces",
        callbackBaseUrl: "http://control-plane",
        callbackSecret: "callback-secret",
        modelGatewayBaseUrl: "http://model-gateway",
        modelGatewaySigningSecret: "g".repeat(32),
        kubectlCommand: kubectl,
      });
      const internal = client as unknown as {
        ensureRepository(workspaceId: string, repositoryUrl: string): Promise<string>;
      };
      await expect(
        internal.ensureRepository("workspace-retry", "https://example.invalid/repository.git"),
      ).resolves.toBe("1".repeat(40));
      const commands = readFileSync(log, "utf8")
        .trim()
        .split("\n")
        .map((line) =>
          (JSON.parse(line) as string[]).slice((JSON.parse(line) as string[]).indexOf("--") + 1),
        );
      expect(commands).toContainEqual(["test", "-d", "/workspace/repo/.git"]);
      expect(commands).toContainEqual(["git", "-C", "/workspace/repo", "rev-parse", "HEAD"]);
      expect(commands.flat().join(" ")).not.toMatch(/\b(reset|clean|clone)\b/);
    } finally {
      rmSync(root, { recursive: true, force: true });
    }
  });

  it("serializes initial injection while retry and absent selections carry none", async () => {
    const requests: Array<Record<string, unknown>> = [];
    const client = new KubernetesAcpNativeClient({} as UnitOfWork, {
      namespace: "awp-workspaces",
      callbackBaseUrl: "http://control-plane",
      callbackSecret: "callback-secret",
      modelGatewayBaseUrl: "http://model-gateway",
      modelGatewaySigningSecret: "g".repeat(32),
    });
    let reviewer = false;
    const internal = client as unknown as {
      resolveLaunch(): Promise<
        | { repositoryUrl: string; prompt: string; role: "coder" }
        | {
            repositoryUrl: string;
            prompt: string;
            role: "reviewer";
            baseRevision: string;
            diff: string;
            candidateDigest: string;
            reviewId: string;
          }
      >;
      waitForRunner(): Promise<void>;
      ensureRepository(): Promise<string>;
      materializeReviewCandidate(): Promise<void>;
      runnerRequest(workspaceId: string, request: Record<string, unknown>): Promise<unknown>;
    };
    internal.resolveLaunch = async () =>
      reviewer
        ? {
            repositoryUrl: "https://example.invalid/repo",
            prompt: "review prompt",
            role: "reviewer",
            baseRevision: "a".repeat(40),
            diff: "diff",
            candidateDigest: "c".repeat(64),
            reviewId: "review",
          }
        : {
            repositoryUrl: "https://example.invalid/repo",
            prompt: "prompt",
            role: "coder",
          };
    internal.waitForRunner = async () => undefined;
    internal.ensureRepository = async () => "a".repeat(40);
    internal.materializeReviewCandidate = async () => undefined;
    internal.runnerRequest = async (_workspaceId, request) => {
      requests.push(request);
      const input = request.input as Record<string, unknown>;
      return {
        id: "b".repeat(64),
        attemptId: input.attemptId,
        agentRunId: input.agentRunId,
        taskId: input.taskId,
        workspaceId: input.workspaceId,
        providerId: input.providerId,
        accountId: input.accountId,
        model: input.model,
        status: "running",
        protocolVersion: "1",
        agentName: "codex-acp",
        capabilities: [],
      };
    };
    const base = {
      attemptId: "attempt",
      agentRunId: "run",
      taskId: "task",
      workspaceId: "workspace",
      providerId: "provider:acp",
      accountId: "account",
      model: "model",
      idempotencyKey: "key",
      correlationId: "correlation",
    };
    await client.startSession({ ...base, selectionKind: "initial" });
    await client.startSession({ ...base, selectionKind: "retry" });
    await client.startSession(base);
    reviewer = true;
    await client.startSession({
      ...base,
      selectionKind: "initial",
      failureInjection: "dogfood-post-prompt-failure-17",
    });
    const serialized = requests.map((request) => request.input as Record<string, unknown>);
    expect(serialized[0]).toMatchObject({
      selectionKind: "initial",
      failureInjection: "dogfood-post-prompt-failure-17",
    });
    expect(serialized[1]).toMatchObject({ selectionKind: "retry" });
    expect(serialized[1]).not.toHaveProperty("failureInjection");
    expect(serialized[2]).not.toHaveProperty("selectionKind");
    expect(serialized[2]).not.toHaveProperty("failureInjection");
    expect(serialized[3]).toMatchObject({ selectionKind: "initial", role: "reviewer" });
    expect(serialized[3]).not.toHaveProperty("failureInjection");
  });

  it("rejects invalid injection modes", async () => {
    const base = {
      attemptId: "attempt",
      agentRunId: "run",
      taskId: "task",
      workspaceId: "workspace",
      providerId: "provider:acp",
      accountId: "account",
      model: "model",
      baseRevision: "a".repeat(40),
    };
    await expect(
      handle({
        action: "start",
        idempotencyKey: "invalid-mode",
        input: {
          ...base,
          selectionKind: "retry",
          failureInjection: "dogfood-post-prompt-failure-17",
        },
      }),
    ).rejects.toThrow("requires an initial coder Attempt selection");
    await expect(
      handle({
        action: "start",
        idempotencyKey: "reviewer-injection",
        input: {
          ...base,
          role: "reviewer",
          selectionKind: "initial",
          failureInjection: "dogfood-post-prompt-failure-17",
        },
      }),
    ).rejects.toThrow("requires an initial coder Attempt selection");
    await expect(
      handle({
        action: "start",
        idempotencyKey: "arbitrary-mode",
        input: { ...base, selectionKind: "initial", failureInjection: "shell-command" },
      }),
    ).rejects.toThrow("Unsupported ACP failure injection");
  });

  it("reattaches pre-upgrade immutable state for every canonical selection kind", () => {
    const base = {
      attemptId: "attempt",
      agentRunId: "run",
      taskId: "task",
      workspaceId: "workspace",
      providerId: "provider:acp",
      accountId: "account",
      model: "model",
      baseRevision: "a".repeat(40),
    };
    const legacyState = { ...base, requestedModel: "model" };

    for (const selectionKind of ["initial", "retry", "fallback"] as const) {
      expect(sameImmutableInput(legacyState, { ...base, selectionKind })).toBe(true);
    }
    expect(
      sameImmutableInput(legacyState, {
        ...base,
        selectionKind: "initial",
        failureInjection: "dogfood-post-prompt-failure-17",
      }),
    ).toBe(true);
  });

  it("rejects collisions when upgraded immutable state recorded differing values", () => {
    const base = {
      attemptId: "attempt",
      agentRunId: "run",
      taskId: "task",
      workspaceId: "workspace",
      providerId: "provider:acp",
      accountId: "account",
      model: "model",
      requestedModel: "model",
      baseRevision: "a".repeat(40),
    };
    const injection = "dogfood-post-prompt-failure-17";

    expect(sameImmutableInput({ ...base, selectionKind: "initial" }, { ...base })).toBe(false);
    expect(
      sameImmutableInput(
        { ...base, selectionKind: "initial" },
        { ...base, selectionKind: "retry" },
      ),
    ).toBe(false);
    expect(
      sameImmutableInput(
        { ...base, selectionKind: "retry" },
        { ...base, selectionKind: "fallback" },
      ),
    ).toBe(false);
    expect(
      sameImmutableInput(
        { ...base, selectionKind: "initial" },
        { ...base, selectionKind: "initial", failureInjection: injection },
      ),
    ).toBe(false);
    expect(
      sameImmutableInput(
        { ...base, failureInjection: injection },
        { ...base, selectionKind: "initial", failureInjection: "different" },
      ),
    ).toBe(false);
    expect(
      sameImmutableInput(
        { ...base, selectionKind: "initial", failureInjection: injection },
        { ...base, selectionKind: "initial" },
      ),
    ).toBe(false);
    expect(
      sameImmutableInput(
        { ...base, selectionKind: "initial", failureInjection: injection },
        { ...base, selectionKind: "initial", failureInjection: "different" },
      ),
    ).toBe(false);
  });

  it("runs initial injection after a real prompt checkpoint and leaves retry/normal uninjected", async () => {
    const root = mkdtempSync(join(tmpdir(), "awp-runner-integration-"));
    const repository = join(root, "repo");
    const runnerRoot = join(root, "runner");
    const fakeCodex = join(root, "fake-codex-acp.mjs");
    const fakeSdk = join(root, "fake-acp-sdk.mjs");
    const loader = join(root, "acp-loader.mjs");
    const eventLog = join(root, "events.jsonl");
    execFileSync("git", ["init", "-q", repository]);
    execFileSync("git", ["-C", repository, "config", "user.email", "test@example.invalid"]);
    execFileSync("git", ["-C", repository, "config", "user.name", "AWP Test"]);
    writeFileSync(join(repository, "base.txt"), "base\n");
    execFileSync("git", ["-C", repository, "add", "base.txt"]);
    execFileSync("git", ["-C", repository, "commit", "-qm", "base"]);
    const baseRevision = execFileSync("git", ["-C", repository, "rev-parse", "HEAD"], {
      encoding: "utf8",
    }).trim();
    writeFileSync(fakeCodex, "#!/usr/bin/env node\nprocess.stdin.resume();\n");
    chmodSync(fakeCodex, 0o755);
    writeFileSync(
      fakeSdk,
      `import { appendFileSync, writeFileSync } from "node:fs";
export const PROTOCOL_VERSION = 1;
export const methods = { client: { requestPermission: "permission", sessionUpdate: "update" }, agent: { initialize: "initialize", authenticate: "authenticate", session: { load: "session/load", resume: "session/resume", new: "session/new", setConfigOption: "session/set_config_option", prompt: "session/prompt" } } };
export const ndJsonStream = () => ({});
export const client = () => {
  const value = { onRequest() { return value; }, onNotification() { return value; }, connectWith(_stream, callback) {
    return callback({ request: async (method) => {
      if (method === "initialize") return { protocolVersion: 1, agentCapabilities: { loadSession: true } };
      if (method === "session/new" || method === "session/load" || method === "session/resume") return { sessionId: "fake-session", configOptions: [] };
      if (method === "session/prompt") {
        writeFileSync(process.env.AWP_AGENT_RUNNER_REPOSITORY + "/changed-" + Date.now() + ".txt", "changed by prompt\\n");
        appendFileSync(${JSON.stringify(eventLog)}, JSON.stringify({ event: "prompt-complete" }) + "\\n");
        return { stopReason: "end_turn" };
      }
      return {};
    } });
  } };
  return value;
};
`,
    );
    writeFileSync(
      loader,
      `export async function resolve(specifier, context, nextResolve) {
  if (specifier === "@agentclientprotocol/sdk") return { url: ${JSON.stringify(`file://${fakeSdk}`)}, shortCircuit: true };
  return nextResolve(specifier, context);
}\n`,
    );

    const callbacks: Array<{ path: string; body: Record<string, unknown> }> = [];
    const callbackServer = createServer((request, response) => {
      const chunks: Buffer[] = [];
      request.on("data", (chunk: Buffer) => chunks.push(chunk));
      request.on("end", () => {
        callbacks.push({
          path: request.url ?? "",
          body: JSON.parse(Buffer.concat(chunks).toString("utf8")) as Record<string, unknown>,
        });
        response.writeHead(204).end();
      });
    });
    await new Promise<void>((resolve) => callbackServer.listen(0, "127.0.0.1", resolve));
    const address = callbackServer.address();
    if (!address || typeof address === "string") throw new Error("callback server did not bind");
    const callbackBase = `http://127.0.0.1:${address.port}`;
    const runnerPath = fileURLToPath(
      new URL("../../infra/agent-runner/runner.mjs", import.meta.url),
    );
    const env = {
      ...process.env,
      AWP_AGENT_RUNNER_ROOT: runnerRoot,
      AWP_AGENT_RUNNER_REPOSITORY: repository,
      AWP_AGENT_RUNNER_CODEX_ACP_COMMAND: fakeCodex,
      AWP_DOGFOOD_NATIVE_ACP_FAIL_AFTER_PROMPT_ON_INITIAL: "1",
      NODE_OPTIONS: `--experimental-loader=${loader}`,
    };
    const runner = spawn(process.execPath, [runnerPath, "serve"], { env, stdio: "pipe" });
    try {
      for (let attempt = 0; attempt < 100; attempt += 1) {
        if (runner.exitCode !== null) throw new Error("runner exited before becoming ready");
        try {
          execFileSync(
            process.execPath,
            [
              runnerPath,
              "request",
              Buffer.from(JSON.stringify({ action: "capabilities" })).toString("base64url"),
            ],
            { env, stdio: "pipe" },
          );
          break;
        } catch {
          await delay(20);
        }
      }
      const request = (id: string, selectionKind?: "initial" | "retry") => ({
        action: "start",
        idempotencyKey: id,
        input: {
          attemptId: id,
          agentRunId: `run-${id}`,
          taskId: `task-${id}`,
          workspaceId: "workspace",
          providerId: "provider:acp",
          accountId: "account",
          model: "model",
          idempotencyKey: id,
          correlationId: `correlation-${id}`,
          ...(selectionKind ? { selectionKind } : {}),
          ...(selectionKind === "initial"
            ? { failureInjection: "dogfood-post-prompt-failure-17" }
            : {}),
          role: "coder",
          prompt: "change the repository",
          baseRevision,
          modelGatewayBaseUrl: "http://gateway.invalid",
          modelGatewayCapability: "capability",
          callbackUrl: `${callbackBase}/complete`,
          failureUrl: `${callbackBase}/fail`,
          callbackToken: "token",
        },
      });
      for (const [id, kind] of [
        ["initial", "initial"],
        ["retry", "retry"],
        ["normal", undefined],
      ] as const) {
        execFileSync(
          process.execPath,
          [
            runnerPath,
            "request",
            Buffer.from(JSON.stringify(request(id, kind))).toString("base64url"),
          ],
          { env, stdio: "pipe" },
        );
        for (
          let wait = 0;
          wait < 200 && callbacks.length < (id === "initial" ? 1 : id === "retry" ? 2 : 3);
          wait += 1
        )
          await delay(20);
      }
      expect(readFileSync(eventLog, "utf8").trim().split("\n")).toHaveLength(3);
      expect(callbacks.map((item) => item.path)).toEqual(["/fail", "/complete", "/complete"]);
      expect(callbacks[0]?.body.reason).toBe("dogfood native ACP post-prompt failure 17");
      expect(callbacks[0]?.body.workspaceCheckpointDigest).toMatch(/^[0-9a-f]{40}$/);
      expect(callbacks[1]?.body.workspaceCheckpointDigest).toMatch(/^[0-9a-f]{40}$/);
      expect(callbacks[2]?.body.workspaceCheckpointDigest).toMatch(/^[0-9a-f]{40}$/);
    } finally {
      runner.kill("SIGTERM");
      callbackServer.close();
      rmSync(root, { recursive: true, force: true });
    }
  }, 20_000);
});
