import { execFile as execFileCb } from "node:child_process";
import * as fs from "node:fs";
import { promisify } from "node:util";
import type {
  AbsFilePath,
  HomeDir,
  MCPToolManager,
  NvimCwd,
  ProviderToolResultContent,
  ThreadId,
  ToolRequestId,
  UnresolvedFilePath,
} from "@magenta/core";
import {
  ContextManager,
  FileCategory,
  GetFile,
  getToolSpecs,
} from "@magenta/core";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { createDockerEnvironment } from "../environment.ts";
import { FULL_CAPABILITIES } from "../test/capabilities.ts";
import { DockerFileIO } from "./docker-file-io.ts";
import { DockerShell } from "./docker-shell.ts";

const execFile = promisify(execFileCb);

const mockThreadId = "test-thread-docker" as ThreadId;

const mockMcpToolManager: MCPToolManager = {
  getToolSpecs: () => [],
};

describe.runIf(FULL_CAPABILITIES)("Docker Environment", () => {
  let containerId: string;

  beforeAll(async () => {
    const { stdout } = await execFile("docker", [
      "run",
      "-d",
      "bash:latest",
      "tail",
      "-f",
      "/dev/null",
    ]);
    containerId = stdout.trim();
  }, 60_000);

  afterAll(async () => {
    if (containerId) {
      await execFile("docker", ["rm", "-f", containerId]).catch(() => {});
    }
  });

  describe("DockerFileIO", () => {
    it("supports full FileIO surface", async () => {
      const fileIO = new DockerFileIO({ container: containerId });

      // mkdir
      await fileIO.mkdir("/tmp/test-dir/nested");
      expect(await fileIO.fileExists("/tmp/test-dir/nested")).toBe(true);

      // fileExists returns false for missing paths
      expect(await fileIO.fileExists("/tmp/nonexistent-path")).toBe(false);

      // writeFile + readFile
      await fileIO.writeFile("/tmp/test-dir/hello.txt", "hello world");
      const content = await fileIO.readFile("/tmp/test-dir/hello.txt");
      expect(content).toBe("hello world");

      // readBinaryFile
      const binaryContent = "binary\x00test\x01data";
      await fileIO.writeFile("/tmp/test-dir/binary.bin", binaryContent);
      const buf = await fileIO.readBinaryFile("/tmp/test-dir/binary.bin");
      expect(Buffer.isBuffer(buf)).toBe(true);

      // stat returns mtimeMs for existing files
      const statResult = await fileIO.stat("/tmp/test-dir/hello.txt");
      expect(statResult).toBeDefined();
      expect(statResult!.mtimeMs).toBeGreaterThan(0);
      // Should be a recent timestamp (within last minute)
      const now = Date.now();
      expect(statResult!.mtimeMs).toBeGreaterThan(now - 60_000);
      expect(statResult!.mtimeMs).toBeLessThanOrEqual(now + 5_000);

      // stat returns undefined for nonexistent files
      const missingStatResult = await fileIO.stat("/tmp/nonexistent-file");
      expect(missingStatResult).toBeUndefined();
    });
  });

  describe("DockerShell", () => {
    it("supports full Shell surface", async () => {
      const shell = new DockerShell({
        container: containerId,
        cwd: "/tmp",
        threadId: mockThreadId,
      });

      // Basic command execution
      const echoResult = await shell.execute("echo hello", {
        toolRequestId: "test-echo",
      });
      expect(echoResult.exitCode).toBe(0);
      expect(echoResult.output.some((l) => l.text.includes("hello"))).toBe(
        true,
      );

      // Non-zero exit code
      const failResult = await shell.execute("exit 42", {
        toolRequestId: "test-fail",
      });
      expect(failResult.exitCode).toBe(42);

      // cwd is respected
      const pwdResult = await shell.execute("pwd", {
        toolRequestId: "test-pwd",
      });
      expect(pwdResult.output.some((l) => l.text.includes("/tmp"))).toBe(true);

      // logFilePath exists on host
      expect(echoResult.logFilePath).toBeDefined();
      expect(fs.existsSync(echoResult.logFilePath!)).toBe(true);
      const logContent = fs.readFileSync(echoResult.logFilePath!, "utf-8");
      expect(logContent).toContain("echo hello");
      expect(logContent).toContain("hello");
    });

    it("can terminate a running command", async () => {
      const shell = new DockerShell({
        container: containerId,
        cwd: "/tmp",
        threadId: mockThreadId,
      });

      const startTime = Date.now();
      await shell.execute("sleep 60", {
        toolRequestId: "test-terminate",
        onStart: () => {
          setTimeout(() => shell.terminate(), 100);
        },
      });
      const elapsed = Date.now() - startTime;

      // docker exec on macOS exits with code 0 even when terminated,
      // so we verify termination by checking it finished quickly
      // rather than inspecting the exit code/signal.
      expect(elapsed).toBeLessThan(10_000);
    });
  });

  describe("createDockerEnvironment", () => {
    it("resolves cwd and homeDir from container", async () => {
      const env = await createDockerEnvironment({
        container: containerId,
        threadId: mockThreadId,
      });
      // cwd and homeDir should be non-empty paths
      expect(env.cwd).toBeTruthy();
      expect(env.homeDir).toBeTruthy();
      expect(env.cwd.startsWith("/")).toBe(true);
      expect(env.homeDir.startsWith("/")).toBe(true);
    });

    it("uses provided cwd", async () => {
      const env = await createDockerEnvironment({
        container: containerId,
        cwd: "/tmp",
        threadId: mockThreadId,
      });
      expect(env.cwd).toBe("/tmp");
    });

    it("has correct capabilities", async () => {
      const env = await createDockerEnvironment({
        container: containerId,
        threadId: mockThreadId,
      });
      expect(env.availableCapabilities.has("file-io")).toBe(true);
      expect(env.availableCapabilities.has("shell")).toBe(true);
      expect(env.availableCapabilities.has("threads")).toBe(true);
      expect(env.availableCapabilities.has("lsp")).toBe(false);
    });

    it("stores environmentConfig", async () => {
      const env = await createDockerEnvironment({
        container: containerId,
        cwd: "/tmp",
        threadId: mockThreadId,
      });
      expect(env.environmentConfig).toEqual({
        type: "docker",
        container: containerId,
        cwd: "/tmp",
      });
    });

    it("has no sandbox violation handler", async () => {
      const env = await createDockerEnvironment({
        container: containerId,
        threadId: mockThreadId,
      });
      expect(env.sandboxViolationHandler).toBeUndefined();
    });
  });

  describe("Tool filtering", () => {
    it("excludes LSP tools for Docker capabilities", async () => {
      const env = await createDockerEnvironment({
        container: containerId,
        threadId: mockThreadId,
      });
      const specs = getToolSpecs(
        "root",
        mockMcpToolManager,
        env.availableCapabilities,
      );
      const toolNames = specs.map((s) => s.name);

      // Should include file, shell, and thread tools
      expect(toolNames).toContain("get_file");
      expect(toolNames).toContain("edl");
      expect(toolNames).toContain("bash_command");
      expect(toolNames).toContain("spawn_subagents");

      // Should exclude LSP tools
      expect(toolNames).not.toContain("hover");
      expect(toolNames).not.toContain("find_references");
    });

    it("subagent tool list also excludes LSP", async () => {
      const env = await createDockerEnvironment({
        container: containerId,
        threadId: mockThreadId,
      });
      const specs = getToolSpecs(
        "subagent",
        mockMcpToolManager,
        env.availableCapabilities,
      );
      const toolNames = specs.map((s) => s.name);

      expect(toolNames).toContain("get_file");
      expect(toolNames).toContain("bash_command");
      expect(toolNames).toContain("yield_to_parent");
      expect(toolNames).not.toContain("hover");
      expect(toolNames).not.toContain("find_references");
    });

    describe("GetFile through DockerFileIO", () => {
      it("can read a file inside the container", async () => {
        const fileIO = new DockerFileIO({ container: containerId });

        // Create a test file in the container
        await execFile("docker", [
          "exec",
          containerId,
          "sh",
          "-c",
          'echo "hello from container" > /tmp/test-read.txt',
        ]);

        const request: GetFile.ToolRequest = {
          id: "test-get-file" as ToolRequestId,
          toolName: "get_file",
          input: {
            filePath: "/tmp/test-read.txt" as UnresolvedFilePath,
          },
        };

        const invocation = GetFile.execute(request, {
          cwd: "/tmp" as NvimCwd,
          homeDir: "/root" as HomeDir,
          fileIO,
          contextTracker: { files: {} },
          onToolApplied: () => {},
        });

        const { result } = await invocation.promise;
        expect(result.status).toBe("ok");
        if (result.status === "ok") {
          const text = result.value
            .filter(
              (c): c is Extract<ProviderToolResultContent, { type: "text" }> =>
                c.type === "text",
            )
            .map((c) => c.text)
            .join("");
          expect(text).toContain("hello from container");
        }
      });
    });
  });

  describe("ContextManager with DockerFileIO", () => {
    const CONTAINER_FILE = "/tmp/context-test.txt" as AbsFilePath;

    const TEXT_FILE_TYPE = {
      category: FileCategory.TEXT,
      mimeType: "text/plain",
      extension: ".txt",
    };

    function createDockerContextManager(fileIO: DockerFileIO) {
      const mockLogger = {
        error: vi.fn(),
        warn: vi.fn(),
        info: vi.fn(),
        debug: vi.fn(),
      };

      const cm = new ContextManager(
        mockLogger,
        fileIO,
        "/tmp" as NvimCwd,
        "/root" as HomeDir,
      );

      return cm;
    }

    it("does not report container files as deleted", async () => {
      const fileIO = new DockerFileIO({ container: containerId });

      await fileIO.writeFile(CONTAINER_FILE, "container content");

      const cm = createDockerContextManager(fileIO);

      cm.toolApplied(
        CONTAINER_FILE,
        { type: "get-file", content: "container content" },
        TEXT_FILE_TYPE,
      );

      const updates = await cm.getContextUpdate();
      expect(Object.keys(updates).length).toBe(0);
    });

    it("detects content changes inside the container", async () => {
      const fileIO = new DockerFileIO({ container: containerId });

      await fileIO.writeFile(CONTAINER_FILE, "original content");

      const cm = createDockerContextManager(fileIO);

      cm.toolApplied(
        CONTAINER_FILE,
        { type: "get-file", content: "original content" },
        TEXT_FILE_TYPE,
      );

      // Modify the file inside the container
      await fileIO.writeFile(CONTAINER_FILE, "modified content");

      const updates = await cm.getContextUpdate();
      const update = updates[CONTAINER_FILE];
      expect(update).toBeDefined();
      expect(update.update.status).toBe("ok");
      if (update.update.status !== "ok") throw new Error("Expected ok");
      expect(update.update.value.type).toBe("diff");
    });

    it("detects actual deletion inside the container", async () => {
      const fileIO = new DockerFileIO({ container: containerId });

      await fileIO.writeFile(CONTAINER_FILE, "soon to be deleted");

      const cm = createDockerContextManager(fileIO);

      cm.toolApplied(
        CONTAINER_FILE,
        { type: "get-file", content: "soon to be deleted" },
        TEXT_FILE_TYPE,
      );

      // Actually delete the file inside the container
      await execFile("docker", ["exec", containerId, "rm", CONTAINER_FILE]);

      const updates = await cm.getContextUpdate();
      const update = updates[CONTAINER_FILE];
      expect(update).toBeDefined();
      expect(update.update.status).toBe("ok");
      if (update.update.status !== "ok") throw new Error("Expected ok");
      expect(update.update.value.type).toBe("file-deleted");
      expect(cm.files[CONTAINER_FILE]).toBeUndefined();
    });
  });
});
