import fs from "node:fs/promises";
import path from "node:path";
import type { ImageContent } from "@mariozechner/pi-ai";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js";
import { MAX_IMAGE_BYTES } from "../media/constants.js";
import {
  buildCliArgs,
  loadPromptRefImages,
  prepareCliPromptImagePayload,
  resolveCliRunQueueKey,
  writeCliImages,
  writeCliSystemPromptFile,
} from "./cli-runner/helpers.js";
import * as promptImageUtils from "./pi-embedded-runner/run/images.js";
import type { SandboxFsBridge } from "./sandbox/fs-bridge.js";
import { SYSTEM_PROMPT_CACHE_BOUNDARY } from "./system-prompt-cache-boundary.js";
import * as toolImages from "./tool-images.js";

describe("loadPromptRefImages", () => {
  beforeEach(() => {
    vi.restoreAllMocks();
  });

  it("returns empty results when the prompt has no image refs", async () => {
    const loadImageFromRefSpy = vi.spyOn(promptImageUtils, "loadImageFromRef");
    const sanitizeImageBlocksSpy = vi.spyOn(toolImages, "sanitizeImageBlocks");

    await expect(
      loadPromptRefImages({
        prompt: "just text",
        workspaceDir: "/workspace",
      }),
    ).resolves.toEqual([]);

    expect(loadImageFromRefSpy).not.toHaveBeenCalled();
    expect(sanitizeImageBlocksSpy).not.toHaveBeenCalled();
  });

  it("passes the max-byte guardrail through load and sanitize", async () => {
    const loadedImage: ImageContent = {
      type: "image",
      data: "c29tZS1pbWFnZQ==",
      mimeType: "image/png",
    };
    const sanitizedImage: ImageContent = {
      type: "image",
      data: "c2FuaXRpemVkLWltYWdl",
      mimeType: "image/jpeg",
    };
    const sandbox = {
      root: "/sandbox",
      bridge: {} as SandboxFsBridge,
    };

    const loadImageFromRefSpy = vi
      .spyOn(promptImageUtils, "loadImageFromRef")
      .mockResolvedValueOnce(loadedImage);
    const sanitizeImageBlocksSpy = vi
      .spyOn(toolImages, "sanitizeImageBlocks")
      .mockResolvedValueOnce({ images: [sanitizedImage], dropped: 0 });

    const result = await loadPromptRefImages({
      prompt: "Look at /tmp/photo.png",
      workspaceDir: "/workspace",
      workspaceOnly: true,
      sandbox,
    });

    const [ref, workspaceDir, options] = loadImageFromRefSpy.mock.calls[0] ?? [];
    expect(ref).toMatchObject({ resolved: "/tmp/photo.png", type: "path" });
    expect(workspaceDir).toBe("/workspace");
    expect(options).toEqual({
      maxBytes: MAX_IMAGE_BYTES,
      workspaceOnly: true,
      sandbox,
    });
    expect(sanitizeImageBlocksSpy).toHaveBeenCalledWith([loadedImage], "prompt:images", {
      maxBytes: MAX_IMAGE_BYTES,
    });
    expect(result).toEqual([sanitizedImage]);
  });

  it("dedupes repeated refs and skips failed loads before sanitizing", async () => {
    const loadedImage: ImageContent = {
      type: "image",
      data: "b25lLWltYWdl",
      mimeType: "image/png",
    };

    const loadImageFromRefSpy = vi
      .spyOn(promptImageUtils, "loadImageFromRef")
      .mockResolvedValueOnce(loadedImage)
      .mockResolvedValueOnce(null);
    const sanitizeImageBlocksSpy = vi
      .spyOn(toolImages, "sanitizeImageBlocks")
      .mockResolvedValueOnce({ images: [loadedImage], dropped: 0 });

    const result = await loadPromptRefImages({
      prompt: "Compare /tmp/a.png with /tmp/a.png and /tmp/b.png",
      workspaceDir: "/workspace",
    });

    expect(loadImageFromRefSpy).toHaveBeenCalledTimes(2);
    expect(
      loadImageFromRefSpy.mock.calls.map(
        (call) => (call[0] as { resolved?: string } | undefined)?.resolved,
      ),
    ).toEqual(["/tmp/a.png", "/tmp/b.png"]);
    expect(sanitizeImageBlocksSpy).toHaveBeenCalledWith([loadedImage], "prompt:images", {
      maxBytes: MAX_IMAGE_BYTES,
    });
    expect(result).toEqual([loadedImage]);
  });
});

describe("buildCliArgs", () => {
  it("keeps passing model overrides on resumed CLI sessions", () => {
    expect(
      buildCliArgs({
        backend: {
          command: "codex",
          modelArg: "--model",
        },
        baseArgs: ["exec", "resume", "thread-123"],
        modelId: "gpt-5.4",
        useResume: true,
      }),
    ).toEqual(["exec", "resume", "thread-123", "--model", "gpt-5.4"]);
  });

  it("strips the internal cache boundary from CLI system prompt args", () => {
    expect(
      buildCliArgs({
        backend: {
          command: "claude",
          systemPromptArg: "--append-system-prompt",
        },
        baseArgs: ["-p"],
        modelId: "claude-sonnet-4-6",
        systemPrompt: `Stable prefix${SYSTEM_PROMPT_CACHE_BOUNDARY}Dynamic suffix`,
        useResume: false,
      }),
    ).toEqual(["-p", "--append-system-prompt", "Stable prefix\nDynamic suffix"]);
  });

  it("passes Codex system prompts via a model instructions file config override", () => {
    expect(
      buildCliArgs({
        backend: {
          command: "codex",
          systemPromptFileConfigArg: "-c",
          systemPromptFileConfigKey: "model_instructions_file",
        },
        baseArgs: ["exec", "--json"],
        modelId: "gpt-5.4",
        systemPrompt: "Stable prefix",
        systemPromptFilePath: "/tmp/openclaw/system-prompt.md",
        useResume: false,
      }),
    ).toEqual(["exec", "--json", "-c", 'model_instructions_file="/tmp/openclaw/system-prompt.md"']);
  });

  it("replaces prompt placeholders before falling back to a trailing positional prompt", () => {
    expect(
      buildCliArgs({
        backend: {
          command: "gemini",
          modelArg: "--model",
        },
        baseArgs: ["--output-format", "json", "--prompt", "{prompt}"],
        modelId: "gemini-3.1-pro-preview",
        promptArg: "describe the image",
        useResume: false,
      }),
    ).toEqual([
      "--output-format",
      "json",
      "--prompt",
      "describe the image",
      "--model",
      "gemini-3.1-pro-preview",
    ]);
  });
});

describe("writeCliImages", () => {
  it("uses stable hashed file paths so repeated image hydration reuses the same path", async () => {
    const workspaceDir = await fs.mkdtemp(
      path.join(resolvePreferredOpenClawTmpDir(), "openclaw-cli-write-images-"),
    );
    const image: ImageContent = {
      type: "image",
      data: "c29tZS1pbWFnZQ==",
      mimeType: "image/png",
    };

    const first = await writeCliImages({
      backend: { command: "codex" },
      workspaceDir,
      images: [image],
    });
    const second = await writeCliImages({
      backend: { command: "codex" },
      workspaceDir,
      images: [image],
    });

    try {
      expect(first.paths).toHaveLength(1);
      expect(second.paths).toEqual(first.paths);
      expect(first.paths[0]).toContain(`${resolvePreferredOpenClawTmpDir()}/openclaw-cli-images/`);
      expect(first.paths[0]).toMatch(/\.png$/);
      await expect(fs.readFile(first.paths[0])).resolves.toEqual(Buffer.from(image.data, "base64"));
    } finally {
      await fs.rm(first.paths[0], { force: true });
      await fs.rm(workspaceDir, { recursive: true, force: true });
    }
  });

  it("uses the shared media extension map for image formats beyond the tiny builtin list", async () => {
    const workspaceDir = await fs.mkdtemp(
      path.join(resolvePreferredOpenClawTmpDir(), "openclaw-cli-write-heic-"),
    );
    const image: ImageContent = {
      type: "image",
      data: "aGVpYy1pbWFnZQ==",
      mimeType: "image/heic",
    };

    const written = await writeCliImages({
      backend: { command: "codex" },
      workspaceDir,
      images: [image],
    });

    try {
      expect(written.paths[0]).toMatch(/\.heic$/);
    } finally {
      await fs.rm(written.paths[0], { force: true });
      await fs.rm(workspaceDir, { recursive: true, force: true });
    }
  });

  it("hydrates prompt media refs into codex image args through the helper seams", async () => {
    const tempDir = await fs.mkdtemp(
      path.join(resolvePreferredOpenClawTmpDir(), "openclaw-cli-prompt-image-"),
    );
    const sourceImage = path.join(tempDir, "bb-image.png");
    await fs.writeFile(
      sourceImage,
      Buffer.from(
        "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/woAAn8B9FD5fHAAAAAASUVORK5CYII=",
        "base64",
      ),
    );

    try {
      const prepared = await prepareCliPromptImagePayload({
        backend: {
          command: "codex",
          imageArg: "--image",
          imageMode: "repeat",
          input: "arg",
        },
        prompt: `[media attached: ${sourceImage} (image/png)]\n\n<media:image>`,
        workspaceDir: tempDir,
      });
      const argv = buildCliArgs({
        backend: {
          command: "codex",
          imageArg: "--image",
          imageMode: "repeat",
        },
        baseArgs: ["exec", "--json"],
        modelId: "gpt-5.4",
        imagePaths: prepared.imagePaths,
        useResume: false,
      });

      const imageArgIndex = argv.indexOf("--image");
      expect(imageArgIndex).toBeGreaterThanOrEqual(0);
      expect(argv[imageArgIndex + 1]).toContain("openclaw-cli-images");
      expect(argv[imageArgIndex + 1]).not.toBe(sourceImage);

      await prepared.cleanupImages?.();
    } finally {
      await fs.rm(tempDir, { recursive: true, force: true });
    }
  });

  it("appends hydrated prompt media refs for stdin backends through the helper seams", async () => {
    const tempDir = await fs.mkdtemp(
      path.join(resolvePreferredOpenClawTmpDir(), "openclaw-cli-prompt-image-generic-"),
    );
    const sourceImage = path.join(tempDir, "claude-image.png");
    await fs.writeFile(
      sourceImage,
      Buffer.from(
        "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/woAAn8B9FD5fHAAAAAASUVORK5CYII=",
        "base64",
      ),
    );

    try {
      const prompt = `[media attached: ${sourceImage} (image/png)]\n\n<media:image>`;
      const prepared = await prepareCliPromptImagePayload({
        backend: {
          command: "claude",
          input: "stdin",
        },
        prompt,
        workspaceDir: tempDir,
      });
      const promptWithImages = prepared.prompt;

      expect(promptWithImages).toContain("openclaw-cli-images");
      expect(promptWithImages).toContain(prepared.imagePaths?.[0] ?? "");
      expect(promptWithImages.trimEnd().endsWith(prepared.imagePaths?.[0] ?? "")).toBe(true);

      await prepared.cleanupImages?.();
    } finally {
      await fs.rm(tempDir, { recursive: true, force: true });
    }
  });

  it("appends Gemini prompt refs with @-prefixed image paths", async () => {
    const tempDir = await fs.mkdtemp(
      path.join(resolvePreferredOpenClawTmpDir(), "openclaw-cli-prompt-image-gemini-"),
    );
    const explicitImage: ImageContent = {
      type: "image",
      data: "c29tZS1leHBsaWNpdC1pbWFnZQ==",
      mimeType: "image/png",
    };

    try {
      const prepared = await prepareCliPromptImagePayload({
        backend: {
          command: "gemini",
          imageArg: "@",
          imagePathScope: "workspace",
          input: "arg",
        },
        prompt: "What is in this image?",
        workspaceDir: tempDir,
        images: [explicitImage],
      });

      expect(prepared.prompt).toContain("\n\n@");
      expect(prepared.prompt).toContain(prepared.imagePaths?.[0] ?? "");
      expect(prepared.prompt.trimEnd().endsWith(`@${prepared.imagePaths?.[0] ?? ""}`)).toBe(true);
      expect(prepared.imagePaths?.[0]?.startsWith(path.join(tempDir, ".openclaw-cli-images"))).toBe(
        true,
      );

      const argv = buildCliArgs({
        backend: {
          command: "gemini",
          imageArg: "@",
          imagePathScope: "workspace",
        },
        baseArgs: ["--output-format", "json", "--prompt", "{prompt}"],
        modelId: "gemini-3.1-pro-preview",
        promptArg: prepared.prompt,
        imagePaths: prepared.imagePaths,
        useResume: false,
      });

      expect(argv).toEqual(["--output-format", "json", "--prompt", prepared.prompt]);

      await prepared.cleanupImages?.();
    } finally {
      await fs.rm(tempDir, { recursive: true, force: true });
    }
  });

  it("prefers explicit images over prompt refs through the helper seams", async () => {
    const tempDir = await fs.mkdtemp(
      path.join(resolvePreferredOpenClawTmpDir(), "openclaw-cli-explicit-images-"),
    );
    const sourceImage = path.join(tempDir, "ignored-prompt-image.png");
    await fs.writeFile(
      sourceImage,
      Buffer.from(
        "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/woAAn8B9FD5fHAAAAAASUVORK5CYII=",
        "base64",
      ),
    );
    const explicitImage: ImageContent = {
      type: "image",
      data: "c29tZS1leHBsaWNpdC1pbWFnZQ==",
      mimeType: "image/png",
    };

    try {
      const prepared = await prepareCliPromptImagePayload({
        backend: {
          command: "codex",
          imageArg: "--image",
          imageMode: "repeat",
          input: "arg",
        },
        prompt: `[media attached: ${sourceImage} (image/png)]\n\n<media:image>`,
        workspaceDir: tempDir,
        images: [explicitImage],
      });
      const argv = buildCliArgs({
        backend: {
          command: "codex",
          imageArg: "--image",
          imageMode: "repeat",
        },
        baseArgs: ["exec", "--json"],
        modelId: "gpt-5.4",
        imagePaths: prepared.imagePaths,
        useResume: false,
      });

      expect(argv.filter((arg) => arg === "--image")).toHaveLength(1);
      expect(argv[argv.indexOf("--image") + 1]).toContain("openclaw-cli-images");
      await expect(fs.readFile(prepared.imagePaths?.[0] ?? "")).resolves.toEqual(
        Buffer.from(explicitImage.data, "base64"),
      );

      await prepared.cleanupImages?.();
    } finally {
      await fs.rm(tempDir, { recursive: true, force: true });
    }
  });
});

describe("writeCliSystemPromptFile", () => {
  it("writes stripped system prompts to a private temp file", async () => {
    const written = await writeCliSystemPromptFile({
      backend: {
        command: "codex",
        systemPromptFileConfigKey: "model_instructions_file",
      },
      systemPrompt: `Stable prefix${SYSTEM_PROMPT_CACHE_BOUNDARY}Dynamic suffix`,
    });

    try {
      expect(written.filePath).toContain("openclaw-cli-system-prompt-");
      await expect(fs.readFile(written.filePath ?? "", "utf-8")).resolves.toBe(
        "Stable prefix\nDynamic suffix",
      );
    } finally {
      await written.cleanup();
    }
    await expect(fs.access(written.filePath ?? "")).rejects.toMatchObject({ code: "ENOENT" });
  });
});

describe("resolveCliRunQueueKey", () => {
  it("scopes Claude CLI serialization to the workspace for fresh runs", () => {
    expect(
      resolveCliRunQueueKey({
        backendId: "claude-cli",
        serialize: true,
        runId: "run-1",
        workspaceDir: "/tmp/project-a",
      }),
    ).toBe("claude-cli:workspace:/tmp/project-a");
  });

  it("scopes Claude CLI serialization to the resumed CLI session id", () => {
    expect(
      resolveCliRunQueueKey({
        backendId: "claude-cli",
        serialize: true,
        runId: "run-2",
        workspaceDir: "/tmp/project-a",
        cliSessionId: "claude-session-123",
      }),
    ).toBe("claude-cli:session:claude-session-123");
  });

  it("keeps non-Claude backends on the provider lane when serialized", () => {
    expect(
      resolveCliRunQueueKey({
        backendId: "codex-cli",
        serialize: true,
        runId: "run-3",
        workspaceDir: "/tmp/project-a",
        cliSessionId: "thread-123",
      }),
    ).toBe("codex-cli");
  });

  it("disables serialization when serialize=false", () => {
    expect(
      resolveCliRunQueueKey({
        backendId: "claude-cli",
        serialize: false,
        runId: "run-4",
        workspaceDir: "/tmp/project-a",
      }),
    ).toBe("claude-cli:run-4");
  });
});
