import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import type { OrchestratorEvent, NotifyAction } from "@composio/ao-core";
import { manifest, create } from "./index.js";

function makeEvent(overrides: Partial<OrchestratorEvent> = {}): OrchestratorEvent {
  return {
    id: "evt-1",
    type: "ci.failing",
    priority: "action",
    sessionId: "app-1",
    projectId: "my-project",
    timestamp: new Date("2025-06-15T12:00:00Z"),
    message: "CI check failed on app-1",
    data: { checkName: "lint" },
    ...overrides,
  };
}

describe("notifier-webhook", () => {
  beforeEach(() => {
    vi.restoreAllMocks();
  });

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

  describe("manifest", () => {
    it("has correct metadata", () => {
      expect(manifest.name).toBe("webhook");
      expect(manifest.slot).toBe("notifier");
      expect(manifest.version).toBe("0.1.0");
    });
  });

  describe("create", () => {
    it("returns a notifier with name 'webhook'", () => {
      const notifier = create({ url: "https://example.com/hook" });
      expect(notifier.name).toBe("webhook");
    });

    it("warns when no url configured", () => {
      const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
      create();
      expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("No url configured"));
    });

    it("throws on invalid URL scheme", () => {
      expect(() => create({ url: "ftp://example.com" })).toThrow("must be http(s)");
    });

    it("throws on javascript: URL", () => {
      expect(() => create({ url: "javascript:alert(1)" })).toThrow("must be http(s)");
    });

    it("accepts http:// URL", () => {
      expect(() => create({ url: "http://localhost:8080/hook" })).not.toThrow();
    });

    it("accepts https:// URL", () => {
      expect(() => create({ url: "https://example.com/hook" })).not.toThrow();
    });
  });

  describe("notify", () => {
    it("does nothing when no url", async () => {
      const fetchMock = vi.fn();
      vi.stubGlobal("fetch", fetchMock);
      const notifier = create();
      await notifier.notify(makeEvent());
      expect(fetchMock).not.toHaveBeenCalled();
    });

    it("POSTs event as JSON to the configured URL", async () => {
      const fetchMock = vi.fn().mockResolvedValue({ ok: true });
      vi.stubGlobal("fetch", fetchMock);

      const notifier = create({ url: "https://example.com/hook" });
      await notifier.notify(makeEvent());

      expect(fetchMock).toHaveBeenCalledOnce();
      expect(fetchMock.mock.calls[0][0]).toBe("https://example.com/hook");

      const opts = fetchMock.mock.calls[0][1];
      expect(opts.method).toBe("POST");
      expect(opts.headers["Content-Type"]).toBe("application/json");

      const body = JSON.parse(opts.body);
      expect(body.type).toBe("notification");
      expect(body.event.id).toBe("evt-1");
      expect(body.event.sessionId).toBe("app-1");
      expect(body.event.type).toBe("ci.failing");
    });

    it("serializes event timestamp as ISO string", async () => {
      const fetchMock = vi.fn().mockResolvedValue({ ok: true });
      vi.stubGlobal("fetch", fetchMock);

      const notifier = create({ url: "https://example.com/hook" });
      await notifier.notify(makeEvent());

      const body = JSON.parse(fetchMock.mock.calls[0][1].body);
      expect(body.event.timestamp).toBe("2025-06-15T12:00:00.000Z");
    });

    it("includes custom headers", async () => {
      const fetchMock = vi.fn().mockResolvedValue({ ok: true });
      vi.stubGlobal("fetch", fetchMock);

      const notifier = create({
        url: "https://example.com/hook",
        headers: { Authorization: "Bearer tok123" },
      });
      await notifier.notify(makeEvent());

      const headers = fetchMock.mock.calls[0][1].headers;
      expect(headers["Authorization"]).toBe("Bearer tok123");
      expect(headers["Content-Type"]).toBe("application/json");
    });
  });

  describe("notifyWithActions", () => {
    it("includes actions in payload", async () => {
      const fetchMock = vi.fn().mockResolvedValue({ ok: true });
      vi.stubGlobal("fetch", fetchMock);

      const actions: NotifyAction[] = [
        { label: "Merge", url: "https://github.com/pull/1/merge" },
        { label: "Kill", callbackEndpoint: "/api/kill/app-1" },
      ];

      const notifier = create({ url: "https://example.com/hook" });
      await notifier.notifyWithActions!(makeEvent(), actions);

      const body = JSON.parse(fetchMock.mock.calls[0][1].body);
      expect(body.type).toBe("notification_with_actions");
      expect(body.actions).toHaveLength(2);
    });
  });

  describe("post", () => {
    it("sends a plain message with context", async () => {
      const fetchMock = vi.fn().mockResolvedValue({ ok: true });
      vi.stubGlobal("fetch", fetchMock);

      const notifier = create({ url: "https://example.com/hook" });
      const result = await notifier.post!("All sessions complete", {
        projectId: "my-project",
      });

      const body = JSON.parse(fetchMock.mock.calls[0][1].body);
      expect(body.type).toBe("message");
      expect(body.message).toBe("All sessions complete");
      expect(result).toBeNull();
    });
  });

  describe("retry logic", () => {
    it("retries on 5xx and succeeds", async () => {
      const fetchMock = vi
        .fn()
        .mockResolvedValueOnce({
          ok: false,
          status: 503,
          text: () => Promise.resolve("unavailable"),
        })
        .mockResolvedValueOnce({ ok: true });
      vi.stubGlobal("fetch", fetchMock);

      const notifier = create({
        url: "https://example.com/hook",
        retries: 2,
        retryDelayMs: 1,
      });
      await notifier.notify(makeEvent());
      expect(fetchMock).toHaveBeenCalledTimes(2);
    });

    it("retries on 429 Too Many Requests", async () => {
      const fetchMock = vi
        .fn()
        .mockResolvedValueOnce({
          ok: false,
          status: 429,
          text: () => Promise.resolve("rate limited"),
        })
        .mockResolvedValueOnce({ ok: true });
      vi.stubGlobal("fetch", fetchMock);

      const notifier = create({
        url: "https://example.com/hook",
        retries: 2,
        retryDelayMs: 1,
      });
      await notifier.notify(makeEvent());
      expect(fetchMock).toHaveBeenCalledTimes(2);
    });

    it("does NOT retry on 400 Bad Request", async () => {
      const fetchMock = vi
        .fn()
        .mockResolvedValue({ ok: false, status: 400, text: () => Promise.resolve("bad request") });
      vi.stubGlobal("fetch", fetchMock);

      const notifier = create({
        url: "https://example.com/hook",
        retries: 2,
        retryDelayMs: 1,
      });
      await expect(notifier.notify(makeEvent())).rejects.toThrow("Webhook POST failed (400)");
      expect(fetchMock).toHaveBeenCalledTimes(1);
    });

    it("does NOT retry on 401 Unauthorized", async () => {
      const fetchMock = vi
        .fn()
        .mockResolvedValue({ ok: false, status: 401, text: () => Promise.resolve("unauthorized") });
      vi.stubGlobal("fetch", fetchMock);

      const notifier = create({
        url: "https://example.com/hook",
        retries: 2,
        retryDelayMs: 1,
      });
      await expect(notifier.notify(makeEvent())).rejects.toThrow("Webhook POST failed (401)");
      expect(fetchMock).toHaveBeenCalledTimes(1);
    });

    it("does NOT retry on 403 Forbidden", async () => {
      const fetchMock = vi
        .fn()
        .mockResolvedValue({ ok: false, status: 403, text: () => Promise.resolve("forbidden") });
      vi.stubGlobal("fetch", fetchMock);

      const notifier = create({
        url: "https://example.com/hook",
        retries: 2,
        retryDelayMs: 1,
      });
      await expect(notifier.notify(makeEvent())).rejects.toThrow("Webhook POST failed (403)");
      expect(fetchMock).toHaveBeenCalledTimes(1);
    });

    it("does NOT retry on 404 Not Found", async () => {
      const fetchMock = vi
        .fn()
        .mockResolvedValue({ ok: false, status: 404, text: () => Promise.resolve("not found") });
      vi.stubGlobal("fetch", fetchMock);

      const notifier = create({
        url: "https://example.com/hook",
        retries: 2,
        retryDelayMs: 1,
      });
      await expect(notifier.notify(makeEvent())).rejects.toThrow("Webhook POST failed (404)");
      expect(fetchMock).toHaveBeenCalledTimes(1);
    });

    it("throws after all retries exhausted on 5xx", async () => {
      const fetchMock = vi
        .fn()
        .mockResolvedValue({ ok: false, status: 500, text: () => Promise.resolve("error") });
      vi.stubGlobal("fetch", fetchMock);

      const notifier = create({
        url: "https://example.com/hook",
        retries: 2,
        retryDelayMs: 1,
      });
      await expect(notifier.notify(makeEvent())).rejects.toThrow(
        "Webhook POST failed (500): error",
      );
      // 1 initial + 2 retries = 3
      expect(fetchMock).toHaveBeenCalledTimes(3);
    });

    it("retries on network errors", async () => {
      const fetchMock = vi
        .fn()
        .mockRejectedValueOnce(new Error("ECONNREFUSED"))
        .mockResolvedValueOnce({ ok: true });
      vi.stubGlobal("fetch", fetchMock);

      const notifier = create({
        url: "https://example.com/hook",
        retries: 1,
        retryDelayMs: 1,
      });
      await notifier.notify(makeEvent());
      expect(fetchMock).toHaveBeenCalledTimes(2);
    });

    it("respects retries=0 (no retries)", async () => {
      const fetchMock = vi
        .fn()
        .mockResolvedValue({ ok: false, status: 500, text: () => Promise.resolve("fail") });
      vi.stubGlobal("fetch", fetchMock);

      const notifier = create({
        url: "https://example.com/hook",
        retries: 0,
        retryDelayMs: 1,
      });
      await expect(notifier.notify(makeEvent())).rejects.toThrow("Webhook POST failed");
      expect(fetchMock).toHaveBeenCalledTimes(1);
    });
  });
});
