import { describe, it, expect, vi, beforeEach } from "vitest";
import type { CliLauncher, SdkSessionInfo } from "./cli-launcher.js";
import type { WsBridge } from "./ws-bridge.js";
import type { WorktreeTracker } from "./worktree-tracker.js";

// ─── Hoisted mocks ──────────────────────────────────────────────────────────

// Mock env-manager — prevents filesystem access and gives us control over env resolution
const mockGetEnv = vi.hoisted(() => vi.fn((): any => null));
const mockGetEffectiveImage = vi.hoisted(() => vi.fn((): string | null => null));
vi.mock("./env-manager.js", () => ({
  getEnv: mockGetEnv,
  getEffectiveImage: mockGetEffectiveImage,
}));

// Mock git-utils — prevents real git operations
const mockGetRepoInfo = vi.hoisted(() => vi.fn((): any => null));
const mockGitFetch = vi.hoisted(() => vi.fn((): any => ({ success: true, output: "" })));
const mockGitPull = vi.hoisted(() => vi.fn((): any => ({ success: true, output: "" })));
const mockCheckoutOrCreateBranch = vi.hoisted(() => vi.fn());
const mockEnsureWorktree = vi.hoisted(() =>
  vi.fn((): any => ({
    worktreePath: "/tmp/worktrees/feature-branch",
    actualBranch: "feature-branch",
  })),
);
vi.mock("./git-utils.js", () => ({
  getRepoInfo: mockGetRepoInfo,
  gitFetch: mockGitFetch,
  gitPull: mockGitPull,
  checkoutOrCreateBranch: mockCheckoutOrCreateBranch,
  ensureWorktree: mockEnsureWorktree,
}));

// Mock container-manager — prevents Docker interactions
vi.mock("./container-manager.js", () => ({
  containerManager: {
    createContainer: vi.fn(),
    removeContainer: vi.fn(),
    retrack: vi.fn(),
    copyWorkspaceToContainer: vi.fn(),
    reseedGitAuth: vi.fn(),
    execInContainerAsync: vi.fn(),
  },
}));

// Mock claude-container-auth
vi.mock("./claude-container-auth.js", () => ({
  hasContainerClaudeAuth: vi.fn(() => true),
}));

// Mock codex-container-auth
vi.mock("./codex-container-auth.js", () => ({
  hasContainerCodexAuth: vi.fn(() => true),
}));

// Mock image-pull-manager
vi.mock("./image-pull-manager.js", () => ({
  imagePullManager: {
    isReady: vi.fn(() => true),
    getState: vi.fn(() => ({ status: "idle" })),
    ensureImage: vi.fn(),
    waitForReady: vi.fn(() => Promise.resolve(true)),
    onProgress: vi.fn(() => () => {}),
  },
}));

// Mock config — must include logLevel for the logger module
vi.mock("./config.js", () => ({
  config: {
    initScriptTimeout: 30_000,
    logLevel: "silent",
    isProduction: false,
  },
}));

// Mock logger — prevents pino initialization issues in tests
vi.mock("./logger.js", () => {
  const noop = () => {};
  const noopLogger = {
    info: noop,
    warn: noop,
    error: noop,
    debug: noop,
    trace: noop,
    fatal: noop,
    child: () => noopLogger,
  };
  return {
    logger: noopLogger,
    createLogger: () => noopLogger,
    createSessionLogger: () => noopLogger,
  };
});

// ─── Import under test ──────────────────────────────────────────────────────

import {
  createSession,
  SessionCreationError,
  type SessionCreationDeps,
  type ProgressCallback,
} from "./session-creator.js";

// ─── Helpers ─────────────────────────────────────────────────────────────────

/** Creates a fake SdkSessionInfo returned by the launcher */
function makeFakeSessionInfo(overrides: Partial<SdkSessionInfo> = {}): SdkSessionInfo {
  return {
    sessionId: "sess-001",
    state: "connected",
    cwd: "/test/workspace",
    createdAt: Date.now(),
    backendType: "claude",
    ...overrides,
  };
}

/** Creates mock dependencies for createSession */
function createMockDeps(): SessionCreationDeps {
  return {
    launcher: {
      launch: vi.fn(async () => makeFakeSessionInfo()),
    } as unknown as CliLauncher,
    wsBridge: {
      markContainerized: vi.fn(),
    } as unknown as WsBridge,
    worktreeTracker: {
      addMapping: vi.fn(),
    } as unknown as WorktreeTracker,
  };
}

// =============================================================================
// Tests
// =============================================================================

describe("SessionCreationError", () => {
  it("has the correct name, message, statusCode, and optional step", () => {
    const err = new SessionCreationError("bad input", 400, "resolving_env");

    expect(err).toBeInstanceOf(Error);
    expect(err).toBeInstanceOf(SessionCreationError);
    expect(err.name).toBe("SessionCreationError");
    expect(err.message).toBe("bad input");
    expect(err.statusCode).toBe(400);
    expect(err.step).toBe("resolving_env");
  });

  it("step is undefined when not provided", () => {
    const err = new SessionCreationError("msg", 500);
    expect(err.step).toBeUndefined();
  });
});

describe("createSession", () => {
  let deps: SessionCreationDeps;
  let progressCalls: Array<{
    step: string;
    label: string;
    status: string;
    detail?: string;
  }>;
  let onProgress: ProgressCallback;

  beforeEach(() => {
    vi.clearAllMocks();
    deps = createMockDeps();
    progressCalls = [];
    onProgress = (step, label, status, detail) => {
      progressCalls.push({ step, label, status, detail });
    };
  });

  // ─── Validation ─────────────────────────────────────────────────────────

  describe("input validation", () => {
    it("throws SessionCreationError for invalid backend", async () => {
      await expect(
        createSession({ backend: "gemini" }, deps),
      ).rejects.toThrow(SessionCreationError);

      try {
        await createSession({ backend: "gemini" }, deps);
      } catch (e) {
        const err = e as SessionCreationError;
        expect(err.statusCode).toBe(400);
        expect(err.message).toContain("Invalid backend");
      }
    });

    it("accepts 'claude' as a valid backend", async () => {
      const result = await createSession({ backend: "claude" }, deps);
      expect(result.sessionId).toBe("sess-001");
    });

    it("accepts 'codex' as a valid backend", async () => {
      const result = await createSession({ backend: "codex" }, deps);
      expect(result.sessionId).toBe("sess-001");
    });

    it("defaults to 'claude' backend when not specified", async () => {
      const result = await createSession({}, deps);
      expect(result.sessionId).toBe("sess-001");
    });

    it("throws for invalid branch name (shell injection prevention)", async () => {
      await expect(
        createSession({ branch: "foo;rm -rf /", cwd: "/test" }, deps),
      ).rejects.toThrow(SessionCreationError);

      try {
        await createSession({ branch: "foo;rm -rf /", cwd: "/test" }, deps);
      } catch (e) {
        expect((e as SessionCreationError).statusCode).toBe(400);
        expect((e as SessionCreationError).step).toBe("checkout_branch");
      }
    });

    it("allows valid branch names with slashes, dots, dashes", async () => {
      // Set up git repo info so branch processing is attempted
      mockGetRepoInfo.mockResolvedValueOnce({
        repoRoot: "/test",
        currentBranch: "main",
        defaultBranch: "main",
      });

      const result = await createSession(
        { branch: "feature/my-branch.v2", cwd: "/test" },
        deps,
      );
      expect(result.sessionId).toBe("sess-001");
    });
  });

  // ─── Successful creation ────────────────────────────────────────────────

  describe("successful session creation", () => {
    it("calls launcher.launch and returns session info", async () => {
      const result = await createSession(
        { cwd: "/test/workspace" },
        deps,
        onProgress,
      );

      expect(result.sessionId).toBe("sess-001");
      expect(deps.launcher.launch).toHaveBeenCalledTimes(1);
    });

    it("reports progress steps via the callback", async () => {
      await createSession({ cwd: "/test" }, deps, onProgress);

      // Should have at least resolving_env and launching_cli progress
      const steps = progressCalls.map((p) => p.step);
      expect(steps).toContain("resolving_env");
      expect(steps).toContain("launching_cli");
    });

    it("passes model and permissionMode to the launcher", async () => {
      await createSession(
        {
          cwd: "/work",
          model: "claude-sonnet-4-6",
          permissionMode: "bypassPermissions",
        },
        deps,
      );

      const launchArgs = (deps.launcher.launch as any).mock.calls[0][0];
      expect(launchArgs.model).toBe("claude-sonnet-4-6");
      expect(launchArgs.permissionMode).toBe("bypassPermissions");
    });

    it("passes allowedTools to the launcher", async () => {
      await createSession(
        { allowedTools: ["Bash", "Read"] },
        deps,
      );

      const launchArgs = (deps.launcher.launch as any).mock.calls[0][0];
      expect(launchArgs.allowedTools).toEqual(["Bash", "Read"]);
    });
  });

  // ─── Environment resolution ─────────────────────────────────────────────

  describe("environment resolution", () => {
    it("merges zencode env variables with body env", async () => {
      mockGetEnv.mockReturnValueOnce({
        name: "dev",
        variables: { API_KEY: "secret", HOST: "localhost" },
      });

      await createSession(
        {
          envSlug: "dev",
          env: { HOST: "override-host", EXTRA: "extra-val" },
        },
        deps,
      );

      const launchArgs = (deps.launcher.launch as any).mock.calls[0][0];
      // Body env overrides zencode env for the same key
      expect(launchArgs.env).toEqual({
        API_KEY: "secret",
        HOST: "override-host",
        EXTRA: "extra-val",
      });
    });

    it("continues without env when envSlug is not found", async () => {
      mockGetEnv.mockReturnValueOnce(null);

      // Should not throw
      const result = await createSession(
        { envSlug: "nonexistent" },
        deps,
      );
      expect(result.sessionId).toBe("sess-001");
    });
  });

  // ─── Git operations ────────────────────────────────────────────────────

  describe("git operations", () => {
    it("performs fetch, checkout, and pull when branch differs", async () => {
      mockGetRepoInfo.mockResolvedValueOnce({
        repoRoot: "/repo",
        currentBranch: "main",
        defaultBranch: "main",
      });

      await createSession(
        { branch: "feature-x", cwd: "/repo" },
        deps,
        onProgress,
      );

      expect(mockGitFetch).toHaveBeenCalledWith("/repo");
      expect(mockCheckoutOrCreateBranch).toHaveBeenCalledWith(
        "/repo",
        "feature-x",
        expect.objectContaining({ defaultBranch: "main" }),
      );
      expect(mockGitPull).toHaveBeenCalledWith("/repo");
    });

    it("skips checkout when current branch matches requested branch", async () => {
      mockGetRepoInfo.mockResolvedValueOnce({
        repoRoot: "/repo",
        currentBranch: "feature-x",
        defaultBranch: "main",
      });

      await createSession(
        { branch: "feature-x", cwd: "/repo" },
        deps,
        onProgress,
      );

      expect(mockCheckoutOrCreateBranch).not.toHaveBeenCalled();
    });

    it("throws SessionCreationError when git fetch fails", async () => {
      mockGetRepoInfo.mockResolvedValueOnce({
        repoRoot: "/repo",
        currentBranch: "main",
        defaultBranch: "main",
      });
      mockGitFetch.mockResolvedValueOnce({
        success: false,
        output: "fatal: could not reach origin",
      });

      await expect(
        createSession({ branch: "feat", cwd: "/repo" }, deps),
      ).rejects.toThrow(SessionCreationError);
    });

    it("creates a worktree when useWorktree is set", async () => {
      mockGetRepoInfo.mockResolvedValueOnce({
        repoRoot: "/repo",
        currentBranch: "main",
        defaultBranch: "main",
      });

      await createSession(
        {
          branch: "wt-branch",
          cwd: "/repo",
          useWorktree: true,
        },
        deps,
        onProgress,
      );

      expect(mockEnsureWorktree).toHaveBeenCalledWith(
        "/repo",
        "wt-branch",
        expect.objectContaining({ forceNew: true }),
      );

      // worktreeTracker.addMapping should be called with worktree info
      expect(deps.worktreeTracker.addMapping).toHaveBeenCalledWith(
        expect.objectContaining({
          branch: "wt-branch",
          repoRoot: "/repo",
          worktreePath: "/tmp/worktrees/feature-branch",
        }),
      );
    });
  });

  // ─── CLI launch failure ────────────────────────────────────────────────

  describe("CLI launch failure", () => {
    it("throws SessionCreationError when launcher.launch fails", async () => {
      (deps.launcher.launch as any).mockRejectedValueOnce(
        new Error("spawn ENOENT"),
      );

      try {
        await createSession({ cwd: "/test" }, deps);
        expect.fail("Should have thrown");
      } catch (e) {
        const err = e as SessionCreationError;
        expect(err).toBeInstanceOf(SessionCreationError);
        expect(err.statusCode).toBe(503);
        expect(err.step).toBe("launching_cli");
        expect(err.message).toContain("Claude Code CLI is not installed");
      }
    });
  });

  // ─── Resume / fork ─────────────────────────────────────────────────────

  describe("resume and fork session", () => {
    it("passes resumeSessionAt to the launcher", async () => {
      await createSession(
        { resumeSessionAt: "old-session-id" },
        deps,
      );

      const launchArgs = (deps.launcher.launch as any).mock.calls[0][0];
      expect(launchArgs.resumeSessionAt).toBe("old-session-id");
    });

    it("trims whitespace from resumeSessionAt", async () => {
      await createSession(
        { resumeSessionAt: "  sid-123  " },
        deps,
      );

      const launchArgs = (deps.launcher.launch as any).mock.calls[0][0];
      expect(launchArgs.resumeSessionAt).toBe("sid-123");
    });

    it("ignores empty resumeSessionAt", async () => {
      await createSession(
        { resumeSessionAt: "   " },
        deps,
      );

      const launchArgs = (deps.launcher.launch as any).mock.calls[0][0];
      expect(launchArgs.resumeSessionAt).toBeUndefined();
    });

    it("passes forkSession flag to the launcher", async () => {
      await createSession(
        { forkSession: true, resumeSessionAt: "sid" },
        deps,
      );

      const launchArgs = (deps.launcher.launch as any).mock.calls[0][0];
      expect(launchArgs.forkSession).toBe(true);
    });
  });

  // ─── Codex-specific ────────────────────────────────────────────────────

  describe("codex backend", () => {
    it("configures codex-specific launch options", async () => {
      await createSession(
        {
          backend: "codex",
          codexSandbox: "danger-full-access",
          cwd: "/work",
        },
        deps,
      );

      const launchArgs = (deps.launcher.launch as any).mock.calls[0][0];
      expect(launchArgs.backendType).toBe("codex");
      expect(launchArgs.codexInternetAccess).toBe(true);
      expect(launchArgs.codexSandbox).toBe("danger-full-access");
    });

    it("defaults codex sandbox to workspace-write for non-bypass mode", async () => {
      await createSession(
        { backend: "codex", cwd: "/work" },
        deps,
      );

      const launchArgs = (deps.launcher.launch as any).mock.calls[0][0];
      expect(launchArgs.codexSandbox).toBe("workspace-write");
    });

    it("defaults codex sandbox to danger-full-access for bypassPermissions", async () => {
      await createSession(
        {
          backend: "codex",
          permissionMode: "bypassPermissions",
          cwd: "/work",
        },
        deps,
      );

      const launchArgs = (deps.launcher.launch as any).mock.calls[0][0];
      expect(launchArgs.codexSandbox).toBe("danger-full-access");
    });
  });

  // ─── Container auth validation ──────────────────────────────────────────

  describe("container auth validation", () => {
    it("throws when Claude backend lacks container auth", async () => {
      // Use body.container.image path (zencodeEnv is null → else branch)
      const { hasContainerClaudeAuth } = await import("./claude-container-auth.js");
      (hasContainerClaudeAuth as any).mockReturnValueOnce(false);

      try {
        await createSession(
          { backend: "claude", container: { image: "my-image:latest" }, cwd: "/test" },
          deps,
        );
        expect.fail("Should have thrown");
      } catch (e) {
        const err = e as SessionCreationError;
        expect(err).toBeInstanceOf(SessionCreationError);
        expect(err.statusCode).toBe(400);
        expect(err.message).toContain("auth");
      }
    });

    it("throws when Codex backend lacks container auth", async () => {
      const { hasContainerCodexAuth } = await import("./codex-container-auth.js");
      (hasContainerCodexAuth as any).mockReturnValueOnce(false);

      try {
        await createSession(
          { backend: "codex", container: { image: "codex-image:latest" }, cwd: "/test" },
          deps,
        );
        expect.fail("Should have thrown");
      } catch (e) {
        const err = e as SessionCreationError;
        expect(err).toBeInstanceOf(SessionCreationError);
        expect(err.statusCode).toBe(400);
        expect(err.message).toContain("auth");
      }
    });

    it("succeeds when Claude backend has valid container auth", async () => {
      const { containerManager } = await import("./container-manager.js");
      (containerManager.createContainer as any).mockResolvedValueOnce({
        containerId: "ctr-1",
        name: "test-container",
        containerCwd: "/workspace",
      });
      (containerManager.copyWorkspaceToContainer as any).mockResolvedValueOnce(undefined);
      (containerManager.reseedGitAuth as any).mockResolvedValueOnce(undefined);

      const result = await createSession(
        { backend: "claude", container: { image: "my-image:latest" }, cwd: "/test" },
        deps,
      );
      expect(result.sessionId).toBe("sess-001");
    });
  });

  // ─── Image pull failure ─────────────────────────────────────────────────

  describe("image pull failure", () => {
    it("throws SessionCreationError when image pull times out", async () => {
      const { imagePullManager } = await import("./image-pull-manager.js");
      (imagePullManager.isReady as any).mockReturnValueOnce(false);
      (imagePullManager.getState as any).mockReturnValueOnce({ status: "idle" });
      (imagePullManager.waitForReady as any).mockResolvedValueOnce(false);
      (imagePullManager.getState as any).mockReturnValueOnce({
        status: "error",
        error: "Network unreachable",
      });

      try {
        await createSession(
          { backend: "claude", container: { image: "broken-image:latest" }, cwd: "/test" },
          deps,
        );
        expect.fail("Should have thrown");
      } catch (e) {
        const err = e as SessionCreationError;
        expect(err).toBeInstanceOf(SessionCreationError);
        expect(err.statusCode).toBe(503);
        expect(err.step).toBe("pulling_image");
        expect(err.message).toContain("Network unreachable");
      }
    });
  });

  // ─── Container creation / cleanup ───────────────────────────────────────

  describe("container creation and cleanup on failure", () => {
    it("throws SessionCreationError when container creation fails", async () => {
      const { containerManager } = await import("./container-manager.js");
      (containerManager.createContainer as any).mockRejectedValueOnce(
        new Error("Docker daemon not running"),
      );

      try {
        await createSession(
          { backend: "claude", container: { image: "my-image:latest" }, cwd: "/test" },
          deps,
        );
        expect.fail("Should have thrown");
      } catch (e) {
        const err = e as SessionCreationError;
        expect(err).toBeInstanceOf(SessionCreationError);
        expect(err.statusCode).toBe(503);
        expect(err.step).toBe("creating_container");
        expect(err.message).toContain("Docker daemon not running");
      }
    });

    it("cleans up container when workspace copy fails", async () => {
      const { containerManager } = await import("./container-manager.js");
      (containerManager.createContainer as any).mockResolvedValueOnce({
        containerId: "ctr-cleanup",
        name: "test-container",
        containerCwd: "/workspace",
      });
      (containerManager.copyWorkspaceToContainer as any).mockRejectedValueOnce(
        new Error("copy failed"),
      );

      try {
        await createSession(
          { backend: "claude", container: { image: "my-image:latest" }, cwd: "/test" },
          deps,
        );
        expect.fail("Should have thrown");
      } catch (e) {
        const err = e as SessionCreationError;
        expect(err).toBeInstanceOf(SessionCreationError);
        expect(err.step).toBe("copying_workspace");
      }

      // Container should be removed on failure
      expect(containerManager.removeContainer).toHaveBeenCalled();
    });
  });

  // ─── Init script error handling ─────────────────────────────────────────

  describe("init script error handling", () => {
    it("throws SessionCreationError when init script exits non-zero", async () => {
      // Init script requires zencodeEnv to be truthy, so we must mock both getEnv and getEffectiveImage
      mockGetEnv.mockReturnValueOnce({
        name: "dev",
        variables: { API_KEY: "key" },
        initScript: "exit 1",
      });
      mockGetEffectiveImage.mockReturnValueOnce("my-image:latest");

      const { containerManager } = await import("./container-manager.js");
      (containerManager.createContainer as any).mockResolvedValueOnce({
        containerId: "ctr-init",
        name: "test-container",
        containerCwd: "/workspace",
      });
      (containerManager.copyWorkspaceToContainer as any).mockResolvedValueOnce(undefined);
      (containerManager.reseedGitAuth as any).mockResolvedValueOnce(undefined);
      (containerManager.execInContainerAsync as any).mockResolvedValueOnce({
        exitCode: 1,
        output: "init script error output",
      });

      try {
        await createSession(
          { backend: "claude", envSlug: "dev", cwd: "/test" },
          deps,
        );
        expect.fail("Should have thrown");
      } catch (e) {
        const err = e as SessionCreationError;
        expect(err).toBeInstanceOf(SessionCreationError);
        expect(err.statusCode).toBe(503);
        expect(err.step).toBe("running_init_script");
        expect(err.message).toContain("Init script failed");
      }

      // Container should be removed after init script failure
      expect(containerManager.removeContainer).toHaveBeenCalled();
    });

    it("throws SessionCreationError when init script execution throws", async () => {
      mockGetEnv.mockReturnValueOnce({
        name: "dev",
        variables: { API_KEY: "key" },
        initScript: "some-command",
      });
      mockGetEffectiveImage.mockReturnValueOnce("my-image:latest");

      const { containerManager } = await import("./container-manager.js");
      (containerManager.createContainer as any).mockResolvedValueOnce({
        containerId: "ctr-init-ex",
        name: "test-container",
        containerCwd: "/workspace",
      });
      (containerManager.copyWorkspaceToContainer as any).mockResolvedValueOnce(undefined);
      (containerManager.reseedGitAuth as any).mockResolvedValueOnce(undefined);
      (containerManager.execInContainerAsync as any).mockRejectedValueOnce(
        new Error("exec failed"),
      );

      try {
        await createSession(
          { backend: "claude", envSlug: "dev", cwd: "/test" },
          deps,
        );
        expect.fail("Should have thrown");
      } catch (e) {
        const err = e as SessionCreationError;
        expect(err).toBeInstanceOf(SessionCreationError);
        expect(err.statusCode).toBe(503);
        expect(err.step).toBe("running_init_script");
      }

      expect(containerManager.removeContainer).toHaveBeenCalled();
    });
  });

  // ─── Progress callback optional ──────────────────────────────────────────

  describe("progress callback is optional", () => {
    it("works without any progress callback", async () => {
      const result = await createSession({ cwd: "/test" }, deps);
      expect(result.sessionId).toBe("sess-001");
    });

    it("works when onProgress is undefined", async () => {
      const result = await createSession({ cwd: "/test" }, deps, undefined);
      expect(result.sessionId).toBe("sess-001");
    });
  });
});
