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

import type { ProjectWorkState } from "@/domain";
import type {
  BackgroundTaskAttachment,
  FocusedProjectGateway,
  PluginConfigGateway,
  PluginEventGateway,
  PluginTaskGateway,
  PluginToolchainGateway,
  PluginWorkspaceGateway,
  ProjectGateway,
  ReattachResult,
  TerminalGateway,
  TerminalHandle,
  WindowGateway,
  WorkStateGateway,
} from "@/ports";
import { createPluginServices } from "./services";

function terminalHandle(sessionId: string): TerminalHandle {
  return {
    sessionId,
    write: vi.fn(),
    resize: vi.fn(),
    detach: vi.fn(),
    close: vi.fn(),
  };
}

function gateways(overrides: {
  focusedProject?: Partial<FocusedProjectGateway>;
  project?: Partial<ProjectGateway>;
  workState?: Partial<WorkStateGateway>;
  terminal?: Partial<TerminalGateway>;
  window?: Partial<WindowGateway>;
  pluginWorkspace?: Partial<PluginWorkspaceGateway>;
  pluginTask?: Partial<PluginTaskGateway>;
  pluginToolchain?: Partial<PluginToolchainGateway>;
  pluginEvents?: Partial<PluginEventGateway>;
  pluginConfig?: Partial<PluginConfigGateway>;
} = {}) {
  const focusedProject: FocusedProjectGateway = {
    setFocusedProject: vi.fn(),
    getFocusedProject: vi.fn(async () => ({
      id: "project-1",
      name: "Project One",
      root: "/workspace/project-one",
    })),
    onFocusedProjectChanged: vi.fn(),
    ...overrides.focusedProject,
  };
  const project: ProjectGateway = {
    listProjects: vi.fn(async () => [
      {
        id: "project-1",
        name: "Project One",
        root: "/workspace/project-one",
        remote: { kind: "local" as const },
        createdAt: 1,
      },
    ]),
    createProject: vi.fn(),
    openProject: vi.fn(),
    closeProject: vi.fn(),
    readProjectContext: vi.fn(async () => "project context"),
    updateProjectContext: vi.fn(),
    ...overrides.project,
  };
  const workState: WorkStateGateway = {
    getProjectWorkState: vi.fn(async (): Promise<ProjectWorkState> => ({
      agents: [],
      conversations: [],
    })),
    attachBackgroundTask: vi.fn(async (taskId): Promise<BackgroundTaskAttachment> => ({
      taskId,
      scrollback: new Uint8Array([65]),
      live: false,
      detach: vi.fn(),
    })),
    cancelBackgroundTask: vi.fn(),
    retryBackgroundTask: vi.fn(),
    ...overrides.workState,
  };
  const terminal: TerminalGateway = {
    openTerminal: vi.fn(async () => terminalHandle("terminal-1")),
    reattach: vi.fn(async (): Promise<ReattachResult> => ({
      handle: terminalHandle("terminal-2"),
      scrollback: new Uint8Array([66]),
    })),
    closeTerminal: vi.fn(),
    ...overrides.terminal,
  };
  const window: WindowGateway = {
    openViewWindow: vi.fn(),
    openPluginLayoutWindow: vi.fn(async (input) => ({
      label: `view-plugin-layout-${input.pluginId}.${input.layoutType}`,
      url: "index.html?pluginLayout=1",
      alreadyOpen: false,
      providerPluginDisplayName: input.pluginId,
      layoutLabel: input.layoutType,
      surface: {
        pluginId: input.pluginId,
        layoutType: input.layoutType,
        state: input.state ?? null,
      },
    })),
    closeViewWindow: vi.fn(),
    listOpenViewWindows: vi.fn(async () => []),
    onViewWindowClosed: vi.fn(async () => () => {}),
    ...overrides.window,
  };
  const pluginWorkspace: PluginWorkspaceGateway = {
    readText: vi.fn(async ({ path }) => ({ path, content: "file text" })),
    readBinary: vi.fn(async ({ path }) => ({ path, bytes: new Uint8Array([67]) })),
    writeText: vi.fn(),
    writeBinary: vi.fn(),
    listDir: vi.fn(async ({ path }) => ({
      path,
      entries: [{ name: "main.ts", path: `${path}/main.ts`, isDir: false }],
    })),
    stat: vi.fn(async ({ path }) => ({
      path,
      exists: true,
      isFile: true,
      isDir: false,
      len: 9,
    })),
    queryProjectStructure: vi.fn(async ({ projectId, path }) => ({
      projectId,
      rootPath: path ?? "",
      entries: [{ path: "package.json", name: "package.json", kind: "file" as const }],
      conventions: [{ id: "node-package", markerPath: "package.json" }],
      modules: [{ path: "", markerPath: "package.json", conventionId: "node-package" }],
      truncated: false,
    })),
    ...overrides.pluginWorkspace,
  };
  const pluginTask: PluginTaskGateway = {
    runCommand: vi.fn(async (input) => ({
      taskId: "task-command-1",
      ownerAgentId: input.ownerAgentId,
      projectId: input.projectId,
      kind: "command",
      state: "running" as const,
      exitCode: null,
      summary: input.label,
      stdoutTail: null,
      stderrTail: null,
      createdAtMs: 10,
      updatedAtMs: 10,
    })),
    getStatus: vi.fn(async ({ taskId }) => ({
      taskId,
      ownerAgentId: "agent-1",
      projectId: "project-1",
      kind: "command",
      state: "completed" as const,
      exitCode: 0,
      summary: "ok",
      stdoutTail: "done",
      stderrTail: null,
      createdAtMs: 10,
      updatedAtMs: 20,
    })),
    ...overrides.pluginTask,
  };
  const pluginToolchain: PluginToolchainGateway = {
    diagnose: vi.fn(async ({ projectId, cwd }) => ({
      projectId,
      cwd: cwd ?? "",
      ok: true,
      tools: [
        {
          id: "node",
          executable: "node",
          present: true,
          ok: true,
          status: "ok" as const,
          required: true,
          exitCode: 0,
          version: "v20.0.0",
          stdout: "v20.0.0\n",
          stderr: null,
          error: null,
        },
      ],
      env: [
        {
          name: "CI",
          present: true,
          ok: true,
          required: false,
          value: "1",
          status: "ok" as const,
        },
      ],
      files: [
        {
          path: "package.json",
          exists: true,
          ok: true,
          required: true,
          kind: "file" as const,
          expectedKind: "file" as const,
          len: 42,
        },
      ],
      messages: [],
    })),
    ...overrides.pluginToolchain,
  };
  const pluginEvents: PluginEventGateway = {
    subscribe: vi.fn(async ({ projectId, eventTypes, capacity }) => ({
      subscriptionId: "subscription-1",
      projectId,
      eventTypes: eventTypes?.length
        ? eventTypes
        : ["workspaceFileChanged", "backgroundTaskChanged"],
      capacity: capacity ?? 100,
      retention: "bestEffortBounded",
    })),
    poll: vi.fn(async ({ subscriptionId }) => ({
      subscriptionId,
      events: [],
      dropped: 0,
    })),
    unsubscribe: vi.fn(async ({ subscriptionId }) => ({
      subscriptionId,
      projectId: "project-1",
      eventTypes: [],
      capacity: 0,
      retention: "disposed",
    })),
    ...overrides.pluginEvents,
  };
  const pluginConfig: PluginConfigGateway = {
    readDocument: vi.fn(async ({ projectId, path, format }) => ({
      projectId,
      path,
      format: format ?? "json",
      value: { enabled: true, nested: { count: 1 } },
    })),
    updateDocument: vi.fn(async ({ projectId, path, format, mode }) => ({
      projectId,
      path,
      format: format ?? "json",
      mode: mode ?? "mergePatch",
      bytesWritten: 42,
    })),
    ...overrides.pluginConfig,
  };

  return {
    focusedProject,
    project,
    workState,
    terminal,
    window,
    pluginWorkspace,
    pluginTask,
    pluginToolchain,
    pluginEvents,
    pluginConfig,
  };
}

async function flushMicrotasks(): Promise<void> {
  await Promise.resolve();
  await Promise.resolve();
}

describe("createPluginServices", () => {
  it("opens only plugin-declared layout windows through the window gateway", async () => {
    const g = gateways();
    const services = createPluginServices(g, {
      pluginId: "dev.acme.plugin",
      declaredLayoutTypes: new Set(["dev.acme.plugin.dashboard"]),
    });

    await expect(
      services.windows.open({
        layoutType: "dev.acme.plugin.dashboard",
        state: { tab: "overview" },
      }),
    ).resolves.toMatchObject({
      label: "view-plugin-layout-dev.acme.plugin.dev.acme.plugin.dashboard",
      surface: {
        pluginId: "dev.acme.plugin",
        layoutType: "dev.acme.plugin.dashboard",
        state: { tab: "overview" },
      },
    });
    expect(g.window.openPluginLayoutWindow).toHaveBeenCalledWith({
      pluginId: "dev.acme.plugin",
      layoutType: "dev.acme.plugin.dashboard",
      state: { tab: "overview" },
    });

    await expect(
      services.windows.open({ layoutType: "dev.acme.plugin.undeclared" }),
    ).rejects.toThrow(/undeclared layout/);
  });

  it("exposes focused workspace project helpers without leaking project gateway DTOs", async () => {
    const g = gateways();
    const services = createPluginServices(g);

    await expect(services.workspace.getCurrentProject()).resolves.toEqual({
      id: "project-1",
      name: "Project One",
      root: "/workspace/project-one",
    });
    await expect(services.workspace.getProjectRoot()).resolves.toBe("/workspace/project-one");
    await expect(services.workspace.readProjectContext()).resolves.toBe("project context");

    await services.workspace.updateProjectContext("next context");
    expect(g.project.updateProjectContext).toHaveBeenCalledWith("project-1", "next context");
  });

  it("maps background tasks through the work-state gateway and wraps output attachment", async () => {
    const detach = vi.fn();
    const onData = vi.fn();
    const g = gateways({
      workState: {
        getProjectWorkState: vi.fn(async (): Promise<ProjectWorkState> => ({
          conversations: [],
          agents: [
            {
              agentId: "agent-1",
              name: "Agent",
              profileId: "profile-1",
              busy: { state: "idle" as const },
              tickets: [],
              backgroundTasks: [
                {
                  taskId: "task-1",
                  ownerAgentId: "agent-1",
                  projectId: "project-1",
                  kind: "command",
                  status: "running" as const,
                  exitCode: null,
                  summary: null,
                  stdoutTail: "ok",
                  stderrTail: null,
                  updatedAtMs: 42,
                },
              ],
            },
          ],
        })),
        attachBackgroundTask: vi.fn(async (taskId) => ({
          taskId,
          scrollback: new Uint8Array([1, 2, 3]),
          live: true,
          detach,
        })),
      },
    });
    const services = createPluginServices(g);

    await expect(services.tasks.getStatus("task-1")).resolves.toMatchObject({
      taskId: "task-1",
      status: "running",
      stdoutTail: "ok",
    });
    await expect(services.tasks.getStatus("missing")).resolves.toBeNull();

    const attachment = await services.tasks.attachOutput("task-1", onData);
    expect(attachment.scrollback).toEqual(new Uint8Array([1, 2, 3]));
    expect(attachment.live).toBe(true);
    attachment.detach();
    expect(detach).toHaveBeenCalled();
  });

  it("launches command-backed tasks through the public plugin task gateway", async () => {
    const g = gateways();
    const services = createPluginServices(g);

    await expect(
      services.tasks.runCommand({
        ownerAgentId: "agent-1",
        command: "npm",
        args: ["test", "--", "workspace"],
        cwd: "frontend",
        env: { CI: "1" },
        recordOnly: true,
        deadlineMs: 123_000,
      }),
    ).resolves.toMatchObject({
      taskId: "task-command-1",
      ownerAgentId: "agent-1",
      projectId: "project-1",
      state: "running",
      summary: "npm test -- workspace",
    });

    expect(g.pluginTask.runCommand).toHaveBeenCalledWith({
      projectId: "project-1",
      ownerAgentId: "agent-1",
      label: "npm test -- workspace",
      command: "npm",
      args: ["test", "--", "workspace"],
      cwd: "frontend",
      env: [["CI", "1"]],
      recordOnly: true,
      deadlineMs: 123_000,
    });

    await expect(services.tasks.getCommandStatus("task-command-1")).resolves.toMatchObject({
      taskId: "task-command-1",
      state: "completed",
      exitCode: 0,
      stdoutTail: "done",
    });
    expect(g.pluginTask.getStatus).toHaveBeenCalledWith({ taskId: "task-command-1" });
  });

  it("delegates workspace file and structure operations through the public plugin gateway", async () => {
    const g = gateways();
    const services = createPluginServices(g);

    await expect(services.workspace.resolvePath("src/main.ts")).resolves.toEqual({
      projectId: "project-1",
      root: "/workspace/project-one",
      path: "src/main.ts",
    });
    await expect(services.workspace.readTextFile("README.md")).resolves.toEqual({
      path: "README.md",
      content: "file text",
    });
    await expect(services.workspace.readBinaryFile("asset.bin")).resolves.toEqual({
      path: "asset.bin",
      bytes: new Uint8Array([67]),
    });

    await services.workspace.writeTextFile("generated.txt", "hello");
    expect(g.pluginWorkspace.writeText).toHaveBeenCalledWith({
      projectId: "project-1",
      path: "generated.txt",
      content: "hello",
    });

    await services.workspace.writeBinaryFile("generated.bin", new Uint8Array([1, 2]));
    expect(g.pluginWorkspace.writeBinary).toHaveBeenCalledWith({
      projectId: "project-1",
      path: "generated.bin",
      bytes: new Uint8Array([1, 2]),
    });

    await expect(services.workspace.listDirectory("src")).resolves.toMatchObject({
      path: "src",
      entries: [{ name: "main.ts", path: "src/main.ts", isDir: false }],
    });
    await expect(services.workspace.stat("README.md")).resolves.toMatchObject({
      path: "README.md",
      exists: true,
    });
    await expect(services.workspace.queryStructure({ maxDepth: 2 })).resolves.toMatchObject({
      projectId: "project-1",
      conventions: [{ id: "node-package", markerPath: "package.json" }],
    });
  });

  it("delegates generic toolchain diagnostics through the public plugin gateway", async () => {
    const g = gateways();
    const services = createPluginServices(g);

    await expect(
      services.tooling.diagnose({
        cwd: "frontend",
        tools: [
          {
            id: "node",
            executable: "node",
            versionArgs: ["--version"],
            required: true,
            env: { CI: "1" },
          },
        ],
        env: [{ name: "CI" }],
        files: [{ path: "package.json", required: true, kind: "file" }],
      }),
    ).resolves.toMatchObject({
      projectId: "project-1",
      ok: true,
      tools: [{ id: "node", version: "v20.0.0" }],
    });

    expect(g.pluginToolchain.diagnose).toHaveBeenCalledWith({
      projectId: "project-1",
      cwd: "frontend",
      tools: [
        {
          id: "node",
          executable: "node",
          versionArgs: ["--version"],
          required: true,
          env: [["CI", "1"]],
        },
      ],
      env: [{ name: "CI" }],
      files: [{ path: "package.json", required: true, kind: "file" }],
    });
  });

  it("subscribes to public plugin events and disposes the host subscription", async () => {
    const handler = vi.fn();
    const onDropped = vi.fn();
    const g = gateways({
      pluginEvents: {
        poll: vi.fn(async ({ subscriptionId }) => ({
          subscriptionId,
          dropped: 2,
          events: [
            {
              type: "backgroundTaskChanged" as const,
              sequence: 7,
              occurredAtMs: 10,
              projectId: "project-1",
              taskId: "task-1",
              ownerAgentId: "agent-1",
              state: "completed",
            },
          ],
        })),
      },
    });
    const services = createPluginServices(g);

    const subscription = await services.events.subscribe(
      {
        eventTypes: ["backgroundTaskChanged"],
        capacity: 10,
        maxEventsPerPoll: 5,
        pollIntervalMs: 60_000,
        onDropped,
      },
      handler,
    );
    await flushMicrotasks();

    expect(subscription.subscriptionId).toBe("subscription-1");
    expect(g.pluginEvents.subscribe).toHaveBeenCalledWith({
      projectId: "project-1",
      eventTypes: ["backgroundTaskChanged"],
      capacity: 10,
    });
    expect(g.pluginEvents.poll).toHaveBeenCalledWith({
      subscriptionId: "subscription-1",
      maxEvents: 5,
    });
    expect(onDropped).toHaveBeenCalledWith(2);
    expect(handler).toHaveBeenCalledWith(
      expect.objectContaining({ type: "backgroundTaskChanged", taskId: "task-1" }),
    );

    subscription.dispose();
    expect(g.pluginEvents.unsubscribe).toHaveBeenCalledWith({
      subscriptionId: "subscription-1",
    });
  });

  it("implements workspace.watch through public workspace file events", async () => {
    const handler = vi.fn();
    const g = gateways({
      pluginWorkspace: {
        stat: vi.fn(async ({ path }) => ({
          path,
          exists: true,
          isFile: false,
          isDir: true,
          len: null,
        })),
      },
      pluginEvents: {
        poll: vi.fn(async ({ subscriptionId }) => ({
          subscriptionId,
          dropped: 0,
          events: [
            {
              type: "workspaceFileChanged" as const,
              sequence: 1,
              occurredAtMs: 11,
              projectId: "project-1",
              path: "src/main.ts",
              operation: "writeText",
            },
            {
              type: "workspaceFileChanged" as const,
              sequence: 2,
              occurredAtMs: 12,
              projectId: "project-1",
              path: "README.md",
              operation: "writeText",
            },
          ],
        })),
      },
    });
    const services = createPluginServices(g);

    const watch = await services.workspace.watch("src", handler);
    await flushMicrotasks();

    expect(g.pluginEvents.subscribe).toHaveBeenCalledWith({
      projectId: "project-1",
      eventTypes: ["workspaceFileChanged"],
      capacity: undefined,
    });
    expect(handler).toHaveBeenCalledTimes(1);
    expect(handler).toHaveBeenCalledWith({
      path: "src/main.ts",
      kind: "created",
      operation: "writeText",
      projectId: "project-1",
    });

    watch.dispose();
    expect(g.pluginEvents.unsubscribe).toHaveBeenCalledWith({
      subscriptionId: "subscription-1",
    });
  });

  it("delegates structured config document operations through the public plugin gateway", async () => {
    const g = gateways();
    const services = createPluginServices(g);

    await expect(
      services.config.readDocument({
        path: "config/settings.json",
      }),
    ).resolves.toMatchObject({
      projectId: "project-1",
      path: "config/settings.json",
      format: "json",
      value: { enabled: true },
    });

    await expect(
      services.config.updateDocument({
        path: "config/settings.json",
        mode: "mergePatch",
        value: { enabled: false, removeMe: null },
      }),
    ).resolves.toMatchObject({
      projectId: "project-1",
      path: "config/settings.json",
      format: "json",
      mode: "mergePatch",
      bytesWritten: 42,
    });

    expect(g.pluginConfig.readDocument).toHaveBeenCalledWith({
      projectId: "project-1",
      path: "config/settings.json",
      format: undefined,
    });
    expect(g.pluginConfig.updateDocument).toHaveBeenCalledWith({
      projectId: "project-1",
      path: "config/settings.json",
      format: undefined,
      mode: "mergePatch",
      value: { enabled: false, removeMe: null },
    });
  });

  it("opens terminal sessions with project-root defaults and delegates controls", async () => {
    const g = gateways();
    const services = createPluginServices(g);

    const session = await services.terminal.open();
    expect(g.terminal.openTerminal).toHaveBeenCalledWith(
      { cwd: "/workspace/project-one", rows: 24, cols: 80 },
      expect.any(Function),
    );
    expect(session.sessionId).toBe("terminal-1");

    const reattached = await services.terminal.reattach("terminal-1");
    expect(g.terminal.reattach).toHaveBeenCalledWith("terminal-1", expect.any(Function));
    expect(reattached.session.sessionId).toBe("terminal-2");
    expect(reattached.scrollback).toEqual(new Uint8Array([66]));

    await services.terminal.close("terminal-1");
    expect(g.terminal.closeTerminal).toHaveBeenCalledWith("terminal-1");
  });
});
