import { beforeEach, describe, expect, it, vi } from "vitest";

const mockExistsSync = vi.hoisted(() => vi.fn((..._args: unknown[]) => false));
const mockSpawn = vi.hoisted(() => vi.fn());

vi.mock("node:fs", async (importOriginal) => {
  const actual = (await importOriginal()) as Record<string, unknown>;
  return {
    ...actual,
    existsSync: mockExistsSync,
  };
});

import { ContainerManager } from "./container-manager.js";
import { validateVolumeMountTarget } from "./container-manager.js";

function createMockProc(exitCode: number, stdoutText = "", stderrText = "") {
  return {
    exited: Promise.resolve(exitCode),
    stdout: new ReadableStream<Uint8Array>({
      start(controller) {
        controller.enqueue(new TextEncoder().encode(stdoutText));
        controller.close();
      },
    }),
    stderr: new ReadableStream<Uint8Array>({
      start(controller) {
        controller.enqueue(new TextEncoder().encode(stderrText));
        controller.close();
      },
    }),
    stdin: { write: vi.fn(), end: vi.fn() },
    kill: vi.fn(),
  };
}

vi.stubGlobal("Bun", { spawn: mockSpawn });

/** Extract the shell script from a Bun.spawn call for a docker exec invocation. */
function spawnCmdScript(call: unknown[]): string {
  const args = call[0] as string[];
  // ["docker", "exec", containerId, "sh", "-lc", "...script..."]
  return args.slice(3).join(" ");
}

describe("ContainerManager git auth seeding", () => {
  beforeEach(() => {
    mockExistsSync.mockReset();
    mockSpawn.mockReset();
    // Default: existsSync returns false (no host files)
    mockExistsSync.mockReturnValue(false);
    // Default: Bun.spawn returns successful proc for container exec
    mockSpawn.mockReturnValue(createMockProc(0));
  });

  it("always configures gh as git credential helper when host token lookup fails", async () => {
    // Regression guard: copied gh auth files in the container are still valid even
    // when `gh auth token` cannot read host keychain state.
    mockSpawn.mockImplementation((args: string[]) => {
      if (args[0] === "gh" && args[1] === "auth" && args[2] === "token") {
        return createMockProc(1, "", "host token unavailable");
      }
      return createMockProc(0);
    });

    const manager = new ContainerManager();
    await manager.reseedGitAuth("container123");

    // Container exec commands go through Bun.spawn
    const containerCmds = mockSpawn.mock.calls.map((call) => spawnCmdScript(call as unknown[]));
    expect(containerCmds.some((cmd) => cmd.includes("gh auth setup-git"))).toBe(true);
    // gh auth login should NOT be called since token was unavailable
    const loginCalls = mockSpawn.mock.calls.filter(
      (call) => (call[0] as string[])[3] === "gh" && (call[0] as string[])[6] === "--with-token",
    );
    expect(loginCalls).toHaveLength(0);
  });

  it("logs in with host token before running gh auth setup-git when token exists", async () => {
    // Ordering matters: authenticate first, then wire git credential helper.
    mockSpawn.mockImplementation((args: string[]) => {
      if (args[0] === "gh" && args[1] === "auth" && args[2] === "token") {
        return createMockProc(0, "ghp_test_token");
      }
      return createMockProc(0);
    });

    const manager = new ContainerManager();
    await manager.reseedGitAuth("container123");

    // gh auth login should be called via direct Bun.spawn with stdin piping
    const loginIndex = mockSpawn.mock.calls.findIndex(
      (call) => {
        const a = call[0] as string[];
        return a[0] === "docker" && a[1] === "exec" && a[2] === "-i" && a.includes("--with-token");
      },
    );
    // gh auth setup-git should be called via execInContainer
    const setupGitIndex = mockSpawn.mock.calls.findIndex(
      (call) => spawnCmdScript(call as unknown[]).includes("gh auth setup-git"),
    );

    expect(loginIndex).toBeGreaterThan(-1);
    expect(setupGitIndex).toBeGreaterThan(-1);
    expect(loginIndex).toBeLessThan(setupGitIndex);
  });
});

describe("ContainerManager git identity seeding from host .gitconfig", () => {
  beforeEach(() => {
    mockExistsSync.mockReset();
    mockSpawn.mockReset();
    mockExistsSync.mockReturnValue(false);
    mockSpawn.mockImplementation((args: string[]) => {
      // gh auth token fails by default (no host token)
      if (args[0] === "gh" && args[1] === "auth" && args[2] === "token") {
        return createMockProc(1, "", "no token");
      }
      return createMockProc(0);
    });
  });

  it("copies user.name and user.email from /zencode-host-gitconfig into container global config", async () => {
    const manager = new ContainerManager();
    await manager.reseedGitAuth("container123");

    const containerCmds = mockSpawn.mock.calls
      .filter((call) => (call[0] as string[])[0] === "docker")
      .map((call) => spawnCmdScript(call as unknown[]));
    // The seeding command should reference the staged host gitconfig path
    const identityCmd = containerCmds.find((cmd) => cmd.includes("zencode-host-gitconfig"));
    expect(identityCmd).toBeDefined();
    // It should use git config -f to read from the mounted file
    expect(identityCmd).toContain("git config -f /zencode-host-gitconfig user.name");
    expect(identityCmd).toContain("git config -f /zencode-host-gitconfig user.email");
    // It should write user.name and user.email via git config --global
    expect(identityCmd).toContain("git config --global user.name");
    expect(identityCmd).toContain("git config --global user.email");
  });

  it("disables gpgsign in writable global config (not the read-only mount)", async () => {
    const manager = new ContainerManager();
    await manager.reseedGitAuth("container123");

    const containerCmds = mockSpawn.mock.calls
      .filter((call) => (call[0] as string[])[0] === "docker")
      .map((call) => spawnCmdScript(call as unknown[]));
    expect(containerCmds.some((cmd) => cmd.includes("git config --global commit.gpgsign false"))).toBe(true);
  });

  it("marks /workspace as a safe directory to avoid dubious ownership errors", async () => {
    const manager = new ContainerManager();
    await manager.reseedGitAuth("container123");

    const containerCmds = mockSpawn.mock.calls
      .filter((call) => (call[0] as string[])[0] === "docker")
      .map((call) => spawnCmdScript(call as unknown[]));
    expect(containerCmds.some((cmd) => cmd.includes("safe.directory /workspace"))).toBe(true);
  });
});

describe("ContainerManager Codex file seeding", () => {
  beforeEach(() => {
    mockExistsSync.mockReset();
    mockSpawn.mockReset();
    mockExistsSync.mockReturnValue(false);
    mockSpawn.mockReturnValue(createMockProc(0));
  });

  it("seeds Codex auth files when /zencode-host-codex is available", async () => {
    // seedCodexFiles is called internally during createContainer and startContainer.
    // Test via direct invocation of the private method.
    const manager = new ContainerManager();
    await (manager as unknown as Record<string, (id: string) => Promise<void>>)["seedCodexFiles"]("container456");

    const containerCmds = mockSpawn.mock.calls.map((call) => spawnCmdScript(call as unknown[]));
    // Should attempt to copy Codex files from bind mount
    expect(containerCmds.some((cmd) =>
      cmd.includes("/zencode-host-codex") && cmd.includes("/root/.codex"),
    )).toBe(true);
  });

  it("copies auth.json, config.toml, and directory seeds for Codex", async () => {
    const manager = new ContainerManager();
    await (manager as unknown as Record<string, (id: string) => Promise<void>>)["seedCodexFiles"]("container789");

    const containerCmds = mockSpawn.mock.calls.map((call) => spawnCmdScript(call as unknown[]));
    const seedCmd = containerCmds.find((cmd) => cmd.includes("zencode-host-codex"));
    expect(seedCmd).toBeDefined();
    // Verify it copies the expected files
    expect(seedCmd).toContain("auth.json");
    expect(seedCmd).toContain("config.toml");
    expect(seedCmd).toContain("models_cache.json");
    // Verify it copies directories
    expect(seedCmd).toContain("skills");
    expect(seedCmd).toContain("prompts");
    expect(seedCmd).toContain("rules");
  });

  it("does not fail when seedCodexFiles encounters an error", async () => {
    // seedCodexFiles is best-effort and should not throw
    mockSpawn.mockReturnValue(createMockProc(1, "", "container not running"));

    const manager = new ContainerManager();
    await expect(
      (manager as unknown as Record<string, (id: string) => Promise<void>>)["seedCodexFiles"]("container999"),
    ).resolves.toBeUndefined();
  });
});

describe("ContainerManager workspace copy", () => {
  beforeEach(() => {
    mockSpawn.mockReset();
  });

  it("pipes tar output directly into docker exec stdin without a shell", async () => {
    // Validates the fast path used for large workspaces (especially on macOS):
    // tar creates an archive on the host, piped directly into docker exec.
    const mockTarProc = createMockProc(0);
    const mockDockerProc = createMockProc(0);
    mockSpawn
      .mockReturnValueOnce(mockTarProc)
      .mockReturnValueOnce(mockDockerProc);

    const manager = new ContainerManager();
    await expect(manager.copyWorkspaceToContainer("container123", "/tmp/my-workspace")).resolves.toBeUndefined();

    expect(mockSpawn).toHaveBeenCalledTimes(2);

    // First call: tar on the host
    const [tarArgs, tarOpts] = mockSpawn.mock.calls[0] as [string[], Record<string, unknown>];
    expect(tarArgs).toEqual(["tar", "-C", "/tmp/my-workspace", "-cf", "-", "."]);
    expect(tarOpts.stdout).toBe("pipe");
    expect(tarOpts.stderr).toBe("pipe");

    // Second call: docker exec receiving tar stream via stdin
    const [dockerArgs, dockerOpts] = mockSpawn.mock.calls[1] as [string[], Record<string, unknown>];
    expect(dockerArgs).toEqual(["docker", "exec", "-i", "container123", "tar", "-xf", "-", "-C", "/workspace"]);
    expect(dockerOpts.stdin).toBe(mockTarProc.stdout);
    expect(dockerOpts.stdout).toBe("pipe");
    expect(dockerOpts.stderr).toBe("pipe");
  });

  it("throws a descriptive error when copy command fails", async () => {
    // Ensures stderr from the docker exec pipeline is surfaced to users.
    mockSpawn
      .mockReturnValueOnce(createMockProc(0))          // tar succeeds
      .mockReturnValueOnce(createMockProc(2, "", "tar: write error")); // docker exec fails

    const manager = new ContainerManager();
    await expect(manager.copyWorkspaceToContainer("container123", "/tmp/my-workspace"))
      .rejects.toThrow("workspace copy failed (exit 2): tar: write error");
  });
});

// ---------------------------------------------------------------------------
// Volume mount validation
// ---------------------------------------------------------------------------

describe("validateVolumeMountTarget", () => {
  it("blocks mounting to root filesystem", () => {
    expect(() => validateVolumeMountTarget("/host/path:/")).toThrow("sensitive path");
  });

  it("blocks mounting to /etc", () => {
    expect(() => validateVolumeMountTarget("/some/path:/etc")).toThrow("sensitive path");
  });

  it("blocks mounting to /etc subdirectories", () => {
    expect(() => validateVolumeMountTarget("/host:/etc/passwd")).toThrow("sensitive path");
  });

  it("blocks mounting to /root/.ssh", () => {
    expect(() => validateVolumeMountTarget("/keys:/root/.ssh")).toThrow("sensitive path");
  });

  it("blocks mounting to /root", () => {
    expect(() => validateVolumeMountTarget("/data:/root")).toThrow("sensitive path");
  });

  it("blocks mounting Docker socket", () => {
    expect(() => validateVolumeMountTarget("/var/run/docker.sock:/var/run/docker.sock")).toThrow("sensitive path");
  });

  it("blocks mounting to /proc", () => {
    expect(() => validateVolumeMountTarget("/x:/proc")).toThrow("sensitive path");
  });

  it("blocks mounting to /dev", () => {
    expect(() => validateVolumeMountTarget("/x:/dev")).toThrow("sensitive path");
  });

  it("blocks Windows system root (case-insensitive)", () => {
    expect(() => validateVolumeMountTarget("D:\\data:C:\\")).toThrow("sensitive path");
  });

  it("blocks Windows system directories (case-insensitive)", () => {
    expect(() => validateVolumeMountTarget("D:\\data:C:\\Windows\\System32")).toThrow("sensitive path");
  });

  it("allows mounting to /workspace", () => {
    expect(() => validateVolumeMountTarget("/project:/workspace")).not.toThrow();
  });

  it("allows mounting to /app", () => {
    expect(() => validateVolumeMountTarget("/project:/app")).not.toThrow();
  });

  it("allows mounting to /data", () => {
    expect(() => validateVolumeMountTarget("/host/data:/data")).not.toThrow();
  });

  it("allows mounting to /tmp", () => {
    expect(() => validateVolumeMountTarget("/host/tmp:/tmp")).not.toThrow();
  });

  it("allows read-only option flags", () => {
    expect(() => validateVolumeMountTarget("/project:/workspace:ro")).not.toThrow();
  });

  it("handles named volumes targeting safe paths", () => {
    expect(() => validateVolumeMountTarget("myvolume:/workspace")).not.toThrow();
  });

  it("blocks named volumes targeting sensitive paths", () => {
    expect(() => validateVolumeMountTarget("myvolume:/etc")).toThrow("sensitive path");
  });
});
