import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import { getGlobalConfigPath } from "#src/config-paths";
import piPermissionSystemExtension from "#src/index";
import type {
  PermissionDecisionEvent,
  PermissionsReadyEvent,
  PermissionUiPromptEvent,
} from "#src/permission-events";
import {
  emitDecisionEvent,
  emitReadyEvent,
  emitUiPromptEvent,
  PERMISSIONS_DECISION_CHANNEL,
  PERMISSIONS_READY_CHANNEL,
  PERMISSIONS_UI_PROMPT_CHANNEL,
} from "#src/permission-events";
import { makePromptPayload } from "#test/helpers/prompt-details-fixtures";

// ── Minimal EventBus stub ──────────────────────────────────────────────────

function makeEventBus() {
  return {
    emit: vi.fn(),
    on: vi.fn().mockReturnValue(() => undefined),
  };
}

// ── Constants ──────────────────────────────────────────────────────────────

describe("constants", () => {
  it("channel names have the correct values", () => {
    expect(PERMISSIONS_READY_CHANNEL).toBe("permissions:ready");
    expect(PERMISSIONS_UI_PROMPT_CHANNEL).toBe("permissions:ui_prompt");
    expect(PERMISSIONS_DECISION_CHANNEL).toBe("permissions:decision");
  });
});

// ── emitReadyEvent ─────────────────────────────────────────────────────────

describe("emitReadyEvent", () => {
  it("emits an empty payload on the permissions:ready channel", () => {
    const bus = makeEventBus();
    emitReadyEvent(bus);
    expect(bus.emit).toHaveBeenCalledOnce();
    expect(bus.emit).toHaveBeenCalledWith("permissions:ready", {});
  });

  it("carries no protocolVersion (the broadcast contract is types + semver)", () => {
    const bus = makeEventBus();
    emitReadyEvent(bus);
    const payload = bus.emit.mock.calls[0][1] as PermissionsReadyEvent;
    expect(payload).not.toHaveProperty("protocolVersion");
  });

  it("swallows event bus errors because broadcasts are best-effort", () => {
    const bus = {
      emit: vi.fn(() => {
        throw new Error("listener failed");
      }),
      on: vi.fn().mockReturnValue(() => undefined),
    };

    expect(() => emitReadyEvent(bus)).not.toThrow();
  });
});

// ── emitUiPromptEvent ──────────────────────────────────────────────────────

describe("emitUiPromptEvent", () => {
  function makeUiPromptEvent(
    overrides: Partial<PermissionUiPromptEvent> = {},
  ): PermissionUiPromptEvent {
    return {
      requestId: "req-123",
      source: "tool_call",
      surface: "bash",
      value: "git status",
      agentName: "Explore",
      request: makePromptPayload().request,
      forwarding: null,
      ...overrides,
    };
  }

  it("emits on the permissions:ui_prompt channel", () => {
    const bus = makeEventBus();
    emitUiPromptEvent(bus, makeUiPromptEvent());
    expect(bus.emit).toHaveBeenCalledOnce();
    expect(bus.emit.mock.calls[0][0]).toBe("permissions:ui_prompt");
  });

  it("forwards the full payload unchanged", () => {
    const bus = makeEventBus();
    const event = makeUiPromptEvent({
      forwarding: { requesterAgentName: "Worker", requesterSessionId: "child" },
    });
    emitUiPromptEvent(bus, event);
    expect(bus.emit.mock.calls[0][1]).toEqual(event);
  });

  it("swallows event bus errors because UI prompt broadcasts are observational", () => {
    const bus = {
      emit: vi.fn(() => {
        throw new Error("listener failed");
      }),
      on: vi.fn().mockReturnValue(() => undefined),
    };

    expect(() => emitUiPromptEvent(bus, makeUiPromptEvent())).not.toThrow();
  });
});

// ── emitDecisionEvent ──────────────────────────────────────────────────────

describe("emitDecisionEvent", () => {
  function makeDecisionEvent(
    overrides: Partial<PermissionDecisionEvent> = {},
  ): PermissionDecisionEvent {
    return {
      requestId: "perm-00000000-0000-4000-8000-000000000000",
      surface: "bash",
      value: "git status",
      result: "allow",
      resolution: "policy_allow",
      origin: "global",
      agentName: null,
      matchedPattern: "*",
      ...overrides,
    };
  }

  it("carries the request id that identifies the decided request", () => {
    const bus = makeEventBus();
    emitDecisionEvent(bus, makeDecisionEvent({ requestId: "perm-abc" }));
    const payload = bus.emit.mock.calls[0][1] as PermissionDecisionEvent;
    expect(payload.requestId).toBe("perm-abc");
  });

  it("emits on the permissions:decision channel", () => {
    const bus = makeEventBus();
    emitDecisionEvent(bus, makeDecisionEvent());
    expect(bus.emit).toHaveBeenCalledOnce();
    expect(bus.emit.mock.calls[0][0]).toBe("permissions:decision");
  });

  it("forwards the full payload unchanged", () => {
    const bus = makeEventBus();
    const event = makeDecisionEvent({
      surface: "mcp",
      value: "exa:search",
      result: "deny",
      resolution: "policy_deny",
      origin: "project",
      agentName: "Worker",
      matchedPattern: "exa:*",
    });
    emitDecisionEvent(bus, event);
    expect(bus.emit.mock.calls[0][1]).toEqual(event);
  });

  it("accepts all defined resolution values", () => {
    const resolutions: PermissionDecisionEvent["resolution"][] = [
      "policy_allow",
      "policy_deny",
      "session_approved",
      "infrastructure_auto_allowed",
      "user_approved",
      "user_approved_for_session",
      "user_denied",
      "auto_approved",
      "confirmation_unavailable",
    ];
    const bus = makeEventBus();
    for (const resolution of resolutions) {
      emitDecisionEvent(bus, makeDecisionEvent({ resolution }));
    }
    expect(bus.emit).toHaveBeenCalledTimes(resolutions.length);
  });

  it("accepts null for optional fields", () => {
    const bus = makeEventBus();
    emitDecisionEvent(
      bus,
      makeDecisionEvent({
        origin: null,
        agentName: null,
        matchedPattern: null,
      }),
    );
    const payload = bus.emit.mock.calls[0][1] as PermissionDecisionEvent;
    expect(payload.origin).toBeNull();
    expect(payload.agentName).toBeNull();
    expect(payload.matchedPattern).toBeNull();
  });

  it("swallows event bus errors because broadcasts are best-effort", () => {
    const bus = {
      emit: vi.fn(() => {
        throw new Error("listener failed");
      }),
      on: vi.fn().mockReturnValue(() => undefined),
    };

    expect(() => emitDecisionEvent(bus, makeDecisionEvent())).not.toThrow();
  });
});

// ── piPermissionSystemExtension emits permissions:ready ────────────────────

describe("piPermissionSystemExtension ready event wiring", () => {
  let baseDir: string;
  let originalAgentDir: string | undefined;

  beforeEach(() => {
    baseDir = mkdtempSync(join(tmpdir(), "pi-perm-events-test-"));
    originalAgentDir = process.env.PI_CODING_AGENT_DIR;
    const globalConfigPath = getGlobalConfigPath(baseDir);
    mkdirSync(dirname(globalConfigPath), { recursive: true });
    mkdirSync(join(baseDir, "agents"), { recursive: true });
    writeFileSync(
      globalConfigPath,
      `${JSON.stringify({ permission: { "*": "ask" } })}\n`,
      "utf8",
    );
    process.env.PI_CODING_AGENT_DIR = baseDir;
  });

  afterEach(() => {
    if (originalAgentDir === undefined) {
      delete process.env.PI_CODING_AGENT_DIR;
    } else {
      process.env.PI_CODING_AGENT_DIR = originalAgentDir;
    }
    rmSync(baseDir, { recursive: true, force: true });
  });

  it("emits permissions:ready at session_start", async () => {
    const emitSpy = vi.fn();
    const handlers = new Map<
      string,
      (event: unknown, ctx: unknown) => unknown
    >();
    piPermissionSystemExtension({
      on: vi.fn(
        (event: string, handler: (e: unknown, c: unknown) => unknown) => {
          handlers.set(event, handler);
        },
      ),
      registerCommand: vi.fn(),
      getAllTools: vi.fn().mockReturnValue([]),
      getActiveTools: vi.fn().mockReturnValue([]),
      setActiveTools: vi.fn(),
      registerProvider: vi.fn(),
      events: { emit: emitSpy, on: vi.fn().mockReturnValue(() => undefined) },
    } as never);

    // ready is not emitted at load — only after session_start publishes.
    expect(
      emitSpy.mock.calls.filter(([c]) => c === PERMISSIONS_READY_CHANNEL),
    ).toHaveLength(0);

    const ctx = {
      cwd: baseDir,
      hasUI: false,
      isProjectTrusted: (): boolean => true,
      sessionManager: {
        getEntries: (): unknown[] => [],
        getSessionId: (): string => "top-session",
        getSessionDir: (): string => baseDir,
      },
      ui: {
        notify: (): void => {},
        setStatus: (): void => {},
        select: async (): Promise<string | undefined> => undefined,
        input: async (): Promise<string | undefined> => undefined,
      },
    };
    await handlers.get("session_start")?.({ reason: "start" }, ctx);

    const readyCalls = emitSpy.mock.calls.filter(
      ([channel]) => channel === PERMISSIONS_READY_CHANNEL,
    );
    expect(readyCalls).toHaveLength(1);
    expect(readyCalls[0][1]).toEqual({});
  });
});
