import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { logger } from "../lib/logger.js";
import { dispatchWebhooks } from "../lib/webhook-dispatcher.js";

const { sendSlackNotification } = vi.hoisted(() => ({
  sendSlackNotification: vi.fn(),
}));

vi.mock("../lib/slack-notify.js", () => ({
  sendSlackNotification,
}));

vi.mock("../lib/logger.js", () => ({
  logger: {
    error: vi.fn(),
    warn: vi.fn(),
    info: vi.fn(),
    debug: vi.fn(),
  },
}));

// Dispatcher now resolves+validates DNS before opening the HTTPS socket.
// Mock node:dns/promises so tests do not require real network.
vi.mock("node:dns/promises", () => ({
  lookup: vi.fn(async (_hostname: string, _opts?: unknown) => [
    { address: "93.184.216.34", family: 4 },
  ]),
}));

// Mock node:https so we never open a real socket. The dispatcher calls
// https.request(opts, cb); we return a minimal EventEmitter-like stub.
const { httpsRequestMock } = vi.hoisted(() => ({
  httpsRequestMock: vi.fn(),
}));
vi.mock("node:https", () => ({
  Agent: vi.fn(() => ({})),
  request: httpsRequestMock,
}));

describe("webhook dispatcher logging", () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });

  afterEach(() => {
    vi.unstubAllGlobals();
  });

  it("logs dispatcher-level failures with event context", async () => {
    const db = {
      webhook: {
        findMany: vi.fn().mockRejectedValue(new Error("database unavailable")),
      },
    };

    dispatchWebhooks(db, "estimate.submitted", { id: "estimate_1" });

    await vi.waitFor(() => {
      expect(logger.error).toHaveBeenCalledWith(
        {
          err: expect.any(Error),
          event: "estimate.submitted",
        },
        "Failed to dispatch webhooks",
      );
    });
  });

  it("logs webhook delivery failures with webhook metadata", async () => {
    sendSlackNotification.mockRejectedValueOnce(new Error("slack down"));

    const db = {
      webhook: {
        findMany: vi.fn().mockResolvedValue([
          {
            id: "wh_1",
            name: "Ops Slack",
            url: "https://hooks.slack.com/services/test",
            secret: null,
            events: ["project.created"],
          },
        ]),
      },
    };

    dispatchWebhooks(db, "project.created", { id: "project_1", name: "Project One" });

    await vi.waitFor(() => {
      expect(logger.warn).toHaveBeenCalledWith(
        {
          err: expect.any(Error),
          event: "project.created",
          webhookId: "wh_1",
          webhookName: "Ops Slack",
          webhookUrl: "https://hooks.slack.com/services/test",
        },
        "Webhook delivery failed",
      );
    });
  });

  it("treats non-2xx HTTP webhook responses as delivery failures", async () => {
    // Stub https.request to deliver a 500 response synchronously via the
    // response callback, so the dispatcher sees a non-2xx and logs a warn.
    httpsRequestMock.mockImplementation(
      (_opts: unknown, cb: (res: { statusCode: number; resume: () => void }) => void) => {
        queueMicrotask(() => cb({ statusCode: 500, resume: () => {} }));
        return {
          on: vi.fn(),
          write: vi.fn(),
          end: vi.fn(),
          destroy: vi.fn(),
        };
      },
    );

    const db = {
      webhook: {
        findMany: vi.fn().mockResolvedValue([
          {
            id: "wh_http_1",
            name: "Project Webhook",
            url: "https://example.com/webhooks/project",
            secret: "secret",
            events: ["project.created"],
          },
        ]),
      },
    };

    dispatchWebhooks(db, "project.created", { id: "project_1", name: "Project One" });

    await vi.waitFor(() => {
      expect(logger.warn).toHaveBeenCalledWith(
        {
          err: expect.any(Error),
          event: "project.created",
          webhookId: "wh_http_1",
          webhookName: "Project Webhook",
          webhookUrl: "https://example.com/webhooks/project",
        },
        "Webhook delivery failed",
      );
    });

    expect(httpsRequestMock).toHaveBeenCalledTimes(1);
    // Verify the pinned IP was passed via the lookup override on the Agent.
    const firstCall = httpsRequestMock.mock.calls[0]![0] as {
      host: string;
      servername: string;
      agent: { lookup?: unknown };
    };
    expect(firstCall.host).toBe("example.com");
    expect(firstCall.servername).toBe("example.com");
  });

  it("pins the validated IP via the HTTPS Agent.lookup override (DNS-rebind defence)", async () => {
    const { Agent } = await import("node:https");
    const AgentMock = vi.mocked(Agent);
    AgentMock.mockClear();

    httpsRequestMock.mockImplementation(
      (_opts: unknown, cb: (res: { statusCode: number; resume: () => void }) => void) => {
        queueMicrotask(() => cb({ statusCode: 204, resume: () => {} }));
        return {
          on: vi.fn(),
          write: vi.fn(),
          end: vi.fn(),
          destroy: vi.fn(),
        };
      },
    );

    const db = {
      webhook: {
        findMany: vi.fn().mockResolvedValue([
          {
            id: "wh_rebind_1",
            name: "Pinned Webhook",
            url: "https://example.com/hook",
            secret: null,
            events: ["project.created"],
          },
        ]),
      },
    };

    dispatchWebhooks(db, "project.created", { id: "p1" });

    await vi.waitFor(() => expect(httpsRequestMock).toHaveBeenCalledTimes(1));

    expect(AgentMock).toHaveBeenCalledTimes(1);
    const agentOptions = AgentMock.mock.calls[0]![0] as {
      lookup?: (
        host: string,
        opts: unknown,
        cb: (err: null, addr: string, family: number) => void,
      ) => void;
    };
    expect(typeof agentOptions.lookup).toBe("function");

    // Invoke the lookup override to confirm it returns the pre-validated IP,
    // NOT whatever DNS might be returning right now.
    const cb = vi.fn();
    agentOptions.lookup!("example.com", {}, cb);
    expect(cb).toHaveBeenCalledWith(null, "93.184.216.34", 4);
  });
});
