import { describe, expect, it, vi } from "vitest";
import {
  createChannelApprovalHandlerFromCapability,
  createLazyChannelApprovalNativeRuntimeAdapter,
  type ChannelApprovalNativeRuntimeAdapter,
} from "./approval-handler-runtime.js";
import type { ExecApprovalRequest } from "./exec-approvals.js";

type ApprovalCapability = NonNullable<
  Parameters<typeof createChannelApprovalHandlerFromCapability>[0]["capability"]
>;
type ApprovalNativeAdapter = NonNullable<ApprovalCapability["native"]>;

const TEST_HANDLER_PARAMS = {
  label: "test/approval-handler",
  clientDisplayName: "Test Approval Handler",
  channel: "test",
  channelLabel: "Test",
  cfg: { channels: {} } as never,
} as const;

function makeSequentialPendingDeliveryMock() {
  return vi
    .fn()
    .mockResolvedValueOnce({ messageId: "1" })
    .mockResolvedValueOnce({ messageId: "2" });
}

function makeSequentialPendingBindingMock() {
  return vi
    .fn()
    .mockResolvedValueOnce({ bindingId: "bound-1" })
    .mockResolvedValueOnce({ bindingId: "bound-2" });
}

function makeExecApprovalRequest(id: string): ExecApprovalRequest {
  return {
    id,
    expiresAtMs: Date.now() + 60_000,
    request: {
      command: "echo hi",
      turnSourceChannel: "test",
      turnSourceTo: "origin-chat",
    },
    createdAtMs: Date.now(),
  };
}

function makeNativeApprovalCapability(
  params: {
    preferredSurface?: ReturnType<
      ApprovalNativeAdapter["describeDeliveryCapabilities"]
    >["preferredSurface"];
    supportsApproverDmSurface?: boolean;
    resolveApproverDmTargets?: ApprovalNativeAdapter["resolveApproverDmTargets"];
    resolveApprovalKind?: ChannelApprovalNativeRuntimeAdapter["resolveApprovalKind"];
    buildResolvedResult?: ChannelApprovalNativeRuntimeAdapter["presentation"]["buildResolvedResult"];
    unbindPending?: NonNullable<
      ChannelApprovalNativeRuntimeAdapter["interactions"]
    >["unbindPending"];
    prepareTarget?: ChannelApprovalNativeRuntimeAdapter["transport"]["prepareTarget"];
    deliverPending?: ChannelApprovalNativeRuntimeAdapter["transport"]["deliverPending"];
    bindPending?: NonNullable<ChannelApprovalNativeRuntimeAdapter["interactions"]>["bindPending"];
  } = {},
): ApprovalCapability {
  const preferredSurface = params.preferredSurface ?? "origin";
  return {
    native: {
      describeDeliveryCapabilities: vi.fn().mockReturnValue({
        enabled: true,
        preferredSurface,
        supportsOriginSurface: true,
        supportsApproverDmSurface: params.supportsApproverDmSurface ?? false,
        notifyOriginWhenDmOnly: false,
      }),
      resolveOriginTarget: vi.fn().mockReturnValue({ to: "origin-chat" }),
      ...(params.resolveApproverDmTargets
        ? { resolveApproverDmTargets: params.resolveApproverDmTargets }
        : {}),
    },
    nativeRuntime: {
      resolveApprovalKind: params.resolveApprovalKind,
      availability: {
        isConfigured: vi.fn().mockReturnValue(true),
        shouldHandle: vi.fn().mockReturnValue(true),
      },
      presentation: {
        buildPendingPayload: vi.fn().mockResolvedValue({ text: "pending" }),
        buildResolvedResult: params.buildResolvedResult ?? vi.fn(),
        buildExpiredResult: vi.fn(),
      },
      transport: {
        prepareTarget:
          params.prepareTarget ??
          vi.fn().mockResolvedValue({
            dedupeKey: "origin-chat",
            target: { to: "origin-chat" },
          }),
        deliverPending: params.deliverPending ?? vi.fn().mockResolvedValue({ messageId: "1" }),
      },
      interactions: {
        bindPending: params.bindPending ?? vi.fn().mockResolvedValue({ bindingId: "bound" }),
        unbindPending: params.unbindPending,
      },
    },
  };
}

function createTestApprovalHandler(capability: ApprovalCapability) {
  return createChannelApprovalHandlerFromCapability({
    capability,
    ...TEST_HANDLER_PARAMS,
  });
}

describe("createChannelApprovalHandlerFromCapability", () => {
  it("returns null when the capability does not expose a native runtime", async () => {
    await expect(
      createChannelApprovalHandlerFromCapability({
        capability: {},
        ...TEST_HANDLER_PARAMS,
      }),
    ).resolves.toBeNull();
  });

  it("returns a runtime when the capability exposes a native runtime", async () => {
    const runtime = await createChannelApprovalHandlerFromCapability({
      capability: {
        nativeRuntime: {
          availability: {
            isConfigured: vi.fn().mockReturnValue(true),
            shouldHandle: vi.fn().mockReturnValue(true),
          },
          presentation: {
            buildPendingPayload: vi.fn(),
            buildResolvedResult: vi.fn(),
            buildExpiredResult: vi.fn(),
          },
          transport: {
            prepareTarget: vi.fn(),
            deliverPending: vi.fn(),
          },
        },
      },
      ...TEST_HANDLER_PARAMS,
    });

    expect(runtime).not.toBeNull();
  });

  it("preserves the original request and resolved approval kind when stop-time cleanup unbinds", async () => {
    const unbindPending = vi.fn();
    const runtime = await createTestApprovalHandler(
      makeNativeApprovalCapability({
        resolveApprovalKind: vi.fn().mockReturnValue("plugin"),
        unbindPending,
      }),
    );

    expect(runtime).not.toBeNull();
    const request = {
      id: "custom:1",
      expiresAtMs: Date.now() + 60_000,
      request: {
        turnSourceChannel: "test",
        turnSourceTo: "origin-chat",
      },
    } as never;

    await runtime?.handleRequested(request);
    await runtime?.stop();

    expect(unbindPending).toHaveBeenCalledWith(
      expect.objectContaining({
        request,
        approvalKind: "plugin",
      }),
    );
  });

  it("ignores duplicate pending request ids before finalization", async () => {
    const unbindPending = vi.fn();
    const buildResolvedResult = vi.fn().mockResolvedValue({ kind: "leave" });
    const runtime = await createTestApprovalHandler(
      makeNativeApprovalCapability({
        buildResolvedResult,
        deliverPending: makeSequentialPendingDeliveryMock(),
        bindPending: makeSequentialPendingBindingMock(),
        unbindPending,
      }),
    );

    expect(runtime).not.toBeNull();
    const request = makeExecApprovalRequest("exec:1");

    await runtime?.handleRequested(request);
    await runtime?.handleRequested(request);
    await runtime?.handleResolved({
      id: "exec:1",
      decision: "approved",
      resolvedBy: "operator",
    } as never);

    expect(unbindPending).toHaveBeenCalledTimes(1);
    expect(unbindPending).toHaveBeenCalledWith(
      expect.objectContaining({
        entry: { messageId: "1" },
        binding: { bindingId: "bound-1" },
        request,
      }),
    );
    expect(buildResolvedResult).toHaveBeenCalledTimes(1);
  });

  it("continues finalization cleanup after one resolved entry unbind failure", async () => {
    const unbindPending = vi
      .fn()
      .mockRejectedValueOnce(new Error("unbind failed"))
      .mockResolvedValueOnce(undefined);
    const buildResolvedResult = vi.fn().mockResolvedValue({ kind: "leave" });
    const runtime = await createTestApprovalHandler(
      makeNativeApprovalCapability({
        preferredSurface: "both",
        supportsApproverDmSurface: true,
        resolveApproverDmTargets: vi.fn().mockResolvedValue([{ to: "approver-dm" }]),
        buildResolvedResult,
        prepareTarget: vi.fn().mockImplementation(async ({ plannedTarget }) => ({
          dedupeKey: String(plannedTarget.target.to),
          target: { to: plannedTarget.target.to },
        })),
        deliverPending: makeSequentialPendingDeliveryMock(),
        bindPending: makeSequentialPendingBindingMock(),
        unbindPending,
      }),
    );

    const request = makeExecApprovalRequest("exec:2");

    await runtime?.handleRequested(request);
    await expect(
      runtime?.handleResolved({
        id: "exec:2",
        decision: "approved",
        resolvedBy: "operator",
      } as never),
    ).resolves.toBeUndefined();

    expect(unbindPending).toHaveBeenCalledTimes(2);
    expect(buildResolvedResult).toHaveBeenCalledTimes(1);
    expect(buildResolvedResult).toHaveBeenCalledWith(
      expect.objectContaining({
        entry: { messageId: "2" },
      }),
    );
  });

  it("continues stop-time unbind cleanup when one binding throws", async () => {
    const unbindPending = vi
      .fn()
      .mockRejectedValueOnce(new Error("unbind failed"))
      .mockResolvedValueOnce(undefined);
    const runtime = await createTestApprovalHandler(
      makeNativeApprovalCapability({
        deliverPending: makeSequentialPendingDeliveryMock(),
        bindPending: makeSequentialPendingBindingMock(),
        unbindPending,
      }),
    );

    const request = makeExecApprovalRequest("exec:stop-1");

    await runtime?.handleRequested(request);
    await runtime?.handleRequested({
      ...request,
      id: "exec:stop-2",
    });

    await expect(runtime?.stop()).resolves.toBeUndefined();
    expect(unbindPending).toHaveBeenCalledTimes(2);
    await expect(runtime?.stop()).resolves.toBeUndefined();
    expect(unbindPending).toHaveBeenCalledTimes(2);
  });
});

describe("createLazyChannelApprovalNativeRuntimeAdapter", () => {
  it("loads the runtime lazily and reuses the loaded adapter", async () => {
    const explicitIsConfigured = vi.fn().mockReturnValue(true);
    const explicitShouldHandle = vi.fn().mockReturnValue(false);
    const buildPendingPayload = vi.fn().mockResolvedValue({ text: "pending" });
    const load = vi.fn().mockResolvedValue({
      availability: {
        isConfigured: vi.fn(),
        shouldHandle: vi.fn(),
      },
      presentation: {
        buildPendingPayload,
        buildResolvedResult: vi.fn(),
        buildExpiredResult: vi.fn(),
      },
      transport: {
        prepareTarget: vi.fn(),
        deliverPending: vi.fn(),
      },
    });
    const adapter = createLazyChannelApprovalNativeRuntimeAdapter({
      eventKinds: ["exec"],
      isConfigured: explicitIsConfigured,
      shouldHandle: explicitShouldHandle,
      load,
    });
    const cfg = { channels: {} } as never;
    const request = { id: "exec:1" } as never;
    const view = {} as never;

    expect(adapter.eventKinds).toEqual(["exec"]);
    expect(adapter.availability.isConfigured({ cfg })).toBe(true);
    expect(adapter.availability.shouldHandle({ cfg, request })).toBe(false);
    await expect(
      adapter.presentation.buildPendingPayload({
        cfg,
        request,
        approvalKind: "exec",
        nowMs: 1,
        view,
      }),
    ).resolves.toEqual({ text: "pending" });
    expect(load).toHaveBeenCalledTimes(1);
    expect(explicitIsConfigured).toHaveBeenCalledWith({ cfg });
    expect(explicitShouldHandle).toHaveBeenCalledWith({ cfg, request });
    expect(buildPendingPayload).toHaveBeenCalledWith({
      cfg,
      request,
      approvalKind: "exec",
      nowMs: 1,
      view,
    });
  });

  it("keeps observe hooks synchronous and only uses the already-loaded runtime", async () => {
    const onDelivered = vi.fn();
    const load = vi.fn().mockResolvedValue({
      availability: {
        isConfigured: vi.fn(),
        shouldHandle: vi.fn(),
      },
      presentation: {
        buildPendingPayload: vi.fn().mockResolvedValue({ text: "pending" }),
        buildResolvedResult: vi.fn(),
        buildExpiredResult: vi.fn(),
      },
      transport: {
        prepareTarget: vi.fn(),
        deliverPending: vi.fn(),
      },
      observe: {
        onDelivered,
      },
    });
    const adapter = createLazyChannelApprovalNativeRuntimeAdapter({
      isConfigured: vi.fn().mockReturnValue(true),
      shouldHandle: vi.fn().mockReturnValue(true),
      load,
    });

    adapter.observe?.onDelivered?.({ request: { id: "exec:1" } } as never);
    expect(load).not.toHaveBeenCalled();
    expect(onDelivered).not.toHaveBeenCalled();

    await adapter.presentation.buildPendingPayload({
      cfg: {} as never,
      request: { id: "exec:1" } as never,
      approvalKind: "exec",
      nowMs: 1,
      view: {} as never,
    });
    expect(load).toHaveBeenCalledTimes(1);

    adapter.observe?.onDelivered?.({ request: { id: "exec:1" } } as never);
    expect(onDelivered).toHaveBeenCalledWith({ request: { id: "exec:1" } });
    expect(load).toHaveBeenCalledTimes(1);
  });
});
