import { mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { runAgent } from "../../src/agent/runner.js";
import { importSkillDirectory } from "../../src/agent/skillStore.js";

const queryMock = vi.hoisted(() => vi.fn());

vi.mock("@anthropic-ai/claude-agent-sdk", () => ({
  query: queryMock,
}));

const stubPrisma = {
  projectAgentLock: { update: async () => ({}) },
} as unknown as import("@prisma/client").PrismaClient;

function assistantMessage(text: string) {
  return {
    type: "assistant",
    message: {
      content: [{ type: "text", text }],
      usage: { input_tokens: 3, output_tokens: 5 },
    },
  };
}

function resultMessage(sessionId: string, costUsd?: number) {
  return {
    type: "result",
    subtype: "success",
    session_id: sessionId,
    ...(costUsd !== undefined ? { total_cost_usd: costUsd } : {}),
  };
}

function initMessage(skills: string[]) {
  return {
    type: "system",
    subtype: "init",
    skills,
    tools: [],
    plugins: [],
  };
}

function messages(...items: unknown[]) {
  return (async function* () {
    for (const item of items) yield item;
  })();
}
function abortableMessages(ac: AbortController, ...items: unknown[]) {
  return (async function* () {
    for (const item of items) yield item;
    // Simulate the SDK: after yielding, the query blocks until the abort
    // signal fires, then throws (the SDK raises AbortError).
    if (ac.signal.aborted) throw new Error("aborted");
    await new Promise<void>((resolve) => {
      ac.signal.addEventListener("abort", () => resolve(), { once: true });
    });
    throw new Error("aborted");
  })();
}

describe("runAgent", () => {
  let root: string;
  let workspaceRoot: string;
  let workspace: string;
  let previousSecrets: Record<string, string | undefined>;

  beforeEach(async () => {
    queryMock.mockReset();
    root = await mkdtemp(join(process.platform === "win32" ? tmpdir() : "/tmp", "r-"));
    workspaceRoot = join(root, "w");
    workspace = join(workspaceRoot, "o", "p");
    await mkdir(workspace, { recursive: true });
    workspaceRoot = await realpath(workspaceRoot);
    workspace = await realpath(workspace);
    previousSecrets = Object.fromEntries(
      ["DATABASE_URL", "FEISHU_APP_SECRET", "HUB_SESSION_SECRET", "HUB_SKILL_STORE_ROOT"].map((name) => [name, process.env[name]]),
    );
  });

  afterEach(async () => {
    for (const [name, value] of Object.entries(previousSecrets)) {
      if (value === undefined) delete process.env[name];
      else process.env[name] = value;
    }
    await rm(root, { recursive: true, force: true });
  });

  it("passes the previous Claude SDK session id through resume", async () => {
    queryMock.mockReturnValue(messages(assistantMessage("ok"), resultMessage("sdk-session-new")));

    const result = await runAgent({
      prompt: "再发一次",
      model: "anthropic/claude-sonnet-5",
      project: { projectId: "p", boundChatId: "c", workspaceRoot, workspaceDir: workspace },
      systemPrompt: undefined,
      resumeSessionId: "sdk-session-old",
      runId: "run-1",
      sessionId: "hub-session-1",
      prisma: stubPrisma,
    });

    expect(queryMock).toHaveBeenCalledWith({
      prompt: "再发一次",
      options: expect.objectContaining({
        cwd: workspace,
        model: "anthropic/claude-sonnet-5",
        resume: "sdk-session-old",
        // ADR-0018: agent execution surface bounded by workspace.
        permissionMode: "bypassPermissions",
        allowDangerouslySkipPermissions: true,
        settingSources: [],
        settings: { disableBundledSkills: true },
        skills: [],
        strictMcpConfig: true,
        sandbox: expect.objectContaining({
          enabled: true,
          failIfUnavailable: true,
          allowUnsandboxedCommands: false,
          filesystem: expect.objectContaining({
            allowWrite: expect.arrayContaining([workspace]),
            denyRead: ["/"],
            allowRead: expect.arrayContaining([workspace]),
          }),
        }),
      }),
    });
  });

  it("does not send resume for a fresh Hub session", async () => {
    queryMock.mockReturnValue(messages(assistantMessage("fresh"), resultMessage("sdk-session-1")));

    await runAgent({
      prompt: "你好",
      model: undefined,
      project: { projectId: "p", boundChatId: "c", workspaceRoot, workspaceDir: workspace },
      systemPrompt: undefined,
      runId: "run-1",
      sessionId: "hub-session-1",
      prisma: stubPrisma,
    });

    const call = queryMock.mock.calls[0]?.[0] as { options?: Record<string, unknown> } | undefined;
    expect(call?.options).not.toHaveProperty("resume");
  });

  it("returns the skills actually reported by SDK initialization", async () => {
    queryMock.mockReturnValue(messages(
      initMessage(["cph-runtime:outline"]),
      assistantMessage("fresh"),
      resultMessage("sdk-session-1"),
    ));

    const result = await runAgent({
      prompt: "写一个大纲",
      model: undefined,
      project: { projectId: "p", boundChatId: "c", workspaceRoot, workspaceDir: workspace },
      systemPrompt: undefined,
      runId: "run-1",
      sessionId: "hub-session-1",
      prisma: stubPrisma,
    });

    expect(result.initializedSkillIds).toEqual(["cph-runtime:outline"]);
  });

  it("maps role tool ids to the Claude SDK tool whitelist", async () => {
    queryMock.mockReturnValue(messages(assistantMessage("ok"), resultMessage("sdk-session-1")));

    await runAgent({
      prompt: "审校一下",
      model: undefined,
      project: { projectId: "p", boundChatId: "c", workspaceRoot, workspaceDir: workspace },
      systemPrompt: undefined,
      tools: ["read_file", "cph_check", "send_file"],
      runId: "run-1",
      sessionId: "hub-session-1",
      prisma: stubPrisma,
    });

    expect(queryMock.mock.calls[0]?.[0]).toMatchObject({
      options: {
        tools: ["Read", "Bash"],
        allowedTools: ["Read", "Bash", "mcp__cph_hub__send_file"],
      },
    });
  });

  it("disables SDK tools for an empty role tool and skill selection", async () => {
    queryMock.mockReturnValue(messages(assistantMessage("ok"), resultMessage("sdk-session-1")));

    await runAgent({
      prompt: "只回答",
      model: undefined,
      project: { projectId: "p", boundChatId: "c", workspaceRoot, workspaceDir: workspace },
      systemPrompt: undefined,
      tools: [],
      runId: "run-1",
      sessionId: "hub-session-1",
      prisma: stubPrisma,
    });

    expect(queryMock.mock.calls[0]?.[0]).toMatchObject({
      options: {
        tools: [],
        allowedTools: [],
      },
    });
  });

  it("loads only the dynamic skills selected by the role", async () => {
    const source = join(root, "skill-source");
    const storeRoot = join(root, "skill-store");
    await mkdir(source);
    await writeFile(join(source, "SKILL.md"), "---\nname: typst\ndescription: Typst\n---\n");
    const installed = await importSkillDirectory({ sourceDir: source, storeRoot });
    process.env["HUB_SKILL_STORE_ROOT"] = storeRoot;
    queryMock.mockReturnValue(messages(assistantMessage("ok"), resultMessage("sdk-session-1")));

    await runAgent({
      prompt: "排版",
      model: undefined,
      project: { projectId: "p", boundChatId: "c", workspaceRoot, workspaceDir: workspace },
      systemPrompt: undefined,
      tools: [],
      skills: [{ name: "typst", version: "0.15.0", contentDigest: installed.contentDigest }],
      runId: "run-skill",
      sessionId: "hub-session-1",
      prisma: stubPrisma,
    });

    expect(queryMock.mock.calls[0]?.[0]).toMatchObject({
      options: {
        tools: ["Skill"],
        plugins: [expect.objectContaining({ type: "local", skipMcpDiscovery: true })],
        skills: ["cph-runtime:typst"],
      },
    });
  });

  it("returns SDK-reported cost when present", async () => {
    queryMock.mockReturnValue(messages(assistantMessage("ok"), resultMessage("sdk-session-1", 0.0042)));

    const result = await runAgent({
      prompt: "算一下成本",
      model: undefined,
      project: { projectId: "p", boundChatId: "c", workspaceRoot, workspaceDir: workspace },
      systemPrompt: undefined,
      runId: "run-1",
      sessionId: "hub-session-1",
      prisma: stubPrisma,
    });

    expect(result.costUsd).toBe(0.0042);
  });

  it("passes only the loopback provider proxy capability and safe runtime env without mutating process env", async () => {
    process.env["DATABASE_URL"] = "postgresql://platform-secret";
    process.env["FEISHU_APP_SECRET"] = "feishu-secret";
    process.env["HUB_SESSION_SECRET"] = "session-secret";
    queryMock.mockReturnValue(messages(assistantMessage("ok"), resultMessage("sdk-session-1")));

    await runAgent({
      prompt: "你好",
      model: undefined,
      project: { projectId: "p", boundChatId: "c", workspaceRoot, workspaceDir: workspace },
      systemPrompt: undefined,
      providerProxyEnv: {
        ANTHROPIC_BASE_URL: "http://127.0.0.1:43123",
        ANTHROPIC_AUTH_TOKEN: "run-proxy-capability",
        ANTHROPIC_API_KEY: "",
      },
      runId: "run-1",
      sessionId: "hub-session-1",
      prisma: stubPrisma,
    });

    const call = queryMock.mock.calls[0]?.[0];
    expect(call).toMatchObject({
      options: {
        env: {
          ANTHROPIC_BASE_URL: "http://127.0.0.1:43123",
          ANTHROPIC_AUTH_TOKEN: "run-proxy-capability",
          ANTHROPIC_API_KEY: "",
        },
        sandbox: {
          credentials: {
            envVars: expect.arrayContaining([
              { name: "ANTHROPIC_AUTH_TOKEN", mode: "deny" },
              { name: "ANTHROPIC_API_KEY", mode: "deny" },
            ]),
          },
        },
      },
    });
    const env = (call as { options: { env: Record<string, string | undefined> } }).options.env;
    expect(env).not.toHaveProperty("DATABASE_URL");
    expect(env).not.toHaveProperty("FEISHU_APP_SECRET");
    expect(env).not.toHaveProperty("HUB_SESSION_SECRET");
    expect(process.env["DATABASE_URL"]).toBe("postgresql://platform-secret");
  });

  it("persists structured user and assistant messages best-effort", async () => {
    queryMock.mockReturnValue(messages(assistantMessage("ok"), resultMessage("sdk-session-1")));
    const createMessage = vi.fn().mockResolvedValue({});
    const prisma = {
      projectAgentLock: { update: async () => ({}) },
      agentMessage: { create: createMessage },
    } as unknown as import("@prisma/client").PrismaClient;

    await runAgent({
      prompt: "你好",
      model: undefined,
      project: { projectId: "p", boundChatId: "c", workspaceRoot, workspaceDir: workspace },
      systemPrompt: undefined,
      runId: "run-1",
      sessionId: "hub-session-1",
      prisma,
    });

    expect(createMessage).toHaveBeenCalledTimes(2);
    expect(createMessage).toHaveBeenNthCalledWith(1, {
      data: expect.objectContaining({
        sessionId: "hub-session-1",
        runId: "run-1",
        role: "user",
        content: "你好",
      }),
    });
    expect(createMessage).toHaveBeenNthCalledWith(2, {
      data: expect.objectContaining({
        sessionId: "hub-session-1",
        runId: "run-1",
        role: "assistant",
        content: "ok",
      }),
    });
  });
  it("returns interrupted status when the abort controller fires", async () => {
    const ac = new AbortController();
    queryMock.mockReturnValue(abortableMessages(ac, assistantMessage("partial"), resultMessage("sdk-session-abort")));

    const runPromise = runAgent({
      prompt: "长任务",
      model: undefined,
      project: { projectId: "p", boundChatId: "c", workspaceRoot, workspaceDir: workspace },
      systemPrompt: undefined,
      runId: "run-abort",
      sessionId: "hub-session-abort",
      prisma: stubPrisma,
      abortController: ac,
    });
    // Let the query yield its messages and block on the abort wait.
    await Promise.resolve();
    ac.abort();

    const result = await runPromise;
    expect(queryMock.mock.calls[0]?.[0]).toMatchObject({ options: { abortController: ac } });
    expect(result.error).toBeUndefined();
    expect(result.sdkSessionId).toBe("sdk-session-abort");
    const call = queryMock.mock.calls[0]?.[0] as { options?: { abortController?: AbortController } } | undefined;
    expect(call?.options?.abortController).toBe(ac);
  });
});
