import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";

const getSessionUserMock = vi.fn();
const transactionMock = vi.fn();
const publishedRuleCreateMock = vi.fn();
const publishedRuleDeleteMock = vi.fn();
const sendInviteMock = vi.fn();
const rateLimitKeyMock = vi.fn(
  (
    _key: string,
    _minIntervalMs: number,
  ): { ok: true } | { ok: false; retryAfterMs: number } => ({ ok: true }),
);
const issueGuestRuleClaimCookieMock = vi.fn();

vi.mock("../../lib/server/env", () => ({
  isDatabaseConfigured: () => true,
  getSessionPepper: () => "test-pepper",
}));

vi.mock("../../lib/server/session", () => ({
  getSessionUser: () => getSessionUserMock(),
}));

vi.mock("../../lib/server/rateLimit", () => ({
  rateLimitKey: (key: string, minIntervalMs: number) =>
    rateLimitKeyMock(key, minIntervalMs),
}));

vi.mock("../../lib/server/mail", () => ({
  sendRuleStakeholderInviteEmail: (...args: unknown[]) =>
    sendInviteMock(...args),
}));

vi.mock("../../lib/server/hash", () => ({
  newSessionToken: () => "x".repeat(32),
  hashSessionToken: (t: string) => `hashed-${t}`,
  hashRuleClaimToken: (t: string) => `claim-${t}`,
}));

vi.mock("../../lib/server/guestRuleClaim", () => ({
  issueGuestRuleClaimCookie: (...args: unknown[]) =>
    issueGuestRuleClaimCookieMock(...args),
}));

vi.mock("../../lib/server/db", () => ({
  prisma: {
    $transaction: (fn: (tx: unknown) => Promise<unknown>) =>
      transactionMock(fn),
    publishedRule: {
      create: (...args: unknown[]) => publishedRuleCreateMock(...args),
      delete: (...args: unknown[]) => publishedRuleDeleteMock(...args),
    },
    ruleStakeholder: {
      create: vi.fn().mockResolvedValue({}),
    },
  },
}));

import { POST } from "../../app/api/rules/route";

beforeEach(() => {
  getSessionUserMock.mockReset();
  transactionMock.mockReset();
  publishedRuleCreateMock.mockReset();
  publishedRuleDeleteMock.mockReset();
  sendInviteMock.mockReset();
  rateLimitKeyMock.mockReset();
  issueGuestRuleClaimCookieMock.mockReset();
  rateLimitKeyMock.mockReturnValue({ ok: true as const });
  getSessionUserMock.mockResolvedValue({
    id: "user-1",
    email: "owner@example.com",
  });
});

describe("POST /api/rules", () => {
  it("creates rule without transaction when there are no stakeholder emails", async () => {
    publishedRuleCreateMock.mockResolvedValueOnce({
      id: "rule-solo",
      title: "T",
      summary: null,
      createdAt: new Date("2026-01-01T00:00:00.000Z"),
    });

    const res = await POST(
      new NextRequest("https://x.test/api/rules", {
        method: "POST",
        body: JSON.stringify({
          title: "T",
          summary: null,
          document: {},
        }),
      }),
      undefined,
    );

    expect(res.status).toBe(200);
    expect(transactionMock).not.toHaveBeenCalled();
    expect(publishedRuleCreateMock).toHaveBeenCalledTimes(1);
    expect(
      (
        publishedRuleCreateMock.mock.calls[0]?.[0] as {
          data: { claimTokenHash?: unknown };
        }
      ).data.claimTokenHash,
    ).toBeUndefined();
    expect(issueGuestRuleClaimCookieMock).not.toHaveBeenCalled();
    expect(sendInviteMock).not.toHaveBeenCalled();
  });

  it("uses a transaction and sends stakeholder invites", async () => {
    const created = {
      id: "rule-new",
      title: "Published title",
      summary: null,
      createdAt: new Date("2026-01-02T00:00:00.000Z"),
    };
    transactionMock.mockImplementation(
      async (fn: (tx: { publishedRule: { create: typeof vi.fn }; ruleStakeholder: { create: typeof vi.fn } }) => Promise<unknown>) => {
        const tx = {
          publishedRule: {
            create: vi.fn().mockResolvedValue(created),
          },
          ruleStakeholder: {
            create: vi.fn().mockResolvedValue({}),
          },
        };
        return fn(tx);
      },
    );
    sendInviteMock.mockResolvedValue(undefined);

    const res = await POST(
      new NextRequest("https://x.test/api/rules", {
        method: "POST",
        body: JSON.stringify({
          title: "Published title",
          summary: null,
          document: {},
          stakeholderEmails: ["stakeholder@example.com"],
        }),
      }),
      undefined,
    );

    expect(res.status).toBe(200);
    expect(transactionMock).toHaveBeenCalledTimes(1);
    expect(sendInviteMock).toHaveBeenCalledTimes(1);
    expect(sendInviteMock.mock.calls[0][0]).toBe("stakeholder@example.com");
    expect(String(sendInviteMock.mock.calls[0][1])).toContain(
      "/api/invites/rule-stakeholder/verify?token=",
    );
    expect(publishedRuleCreateMock).not.toHaveBeenCalled();
  });

  it("rolls back publish when mail fails", async () => {
    const created = {
      id: "rule-new",
      title: "Published title",
      summary: null,
      createdAt: new Date("2026-01-02T00:00:00.000Z"),
    };
    transactionMock.mockImplementation(
      async (fn: (tx: { publishedRule: { create: typeof vi.fn }; ruleStakeholder: { create: typeof vi.fn } }) => Promise<unknown>) => {
        const tx = {
          publishedRule: {
            create: vi.fn().mockResolvedValue(created),
          },
          ruleStakeholder: {
            create: vi.fn().mockResolvedValue({}),
          },
        };
        return fn(tx);
      },
    );
    sendInviteMock.mockRejectedValueOnce(new Error("smtp down"));
    publishedRuleDeleteMock.mockResolvedValueOnce({});

    const res = await POST(
      new NextRequest("https://x.test/api/rules", {
        method: "POST",
        body: JSON.stringify({
          title: "Published title",
          summary: null,
          document: {},
          stakeholderEmails: ["stakeholder@example.com"],
        }),
      }),
      undefined,
    );

    expect(res.status).toBe(502);
    expect(publishedRuleDeleteMock).toHaveBeenCalledWith({
      where: { id: "rule-new" },
    });
  });

  it("creates an unlisted rule when there is no session", async () => {
    getSessionUserMock.mockResolvedValueOnce(null);
    publishedRuleCreateMock.mockResolvedValueOnce({
      id: "rule-guest",
      title: "Guest rule",
      summary: null,
      createdAt: new Date("2026-01-03T00:00:00.000Z"),
    });

    const res = await POST(
      new NextRequest("https://x.test/api/rules", {
        method: "POST",
        headers: { "x-forwarded-for": "203.0.113.10" },
        body: JSON.stringify({
          title: "Guest rule",
          summary: null,
          document: {},
          stakeholderEmails: ["invitee@example.com"],
        }),
      }),
      undefined,
    );

    expect(res.status).toBe(200);
    expect(rateLimitKeyMock).toHaveBeenCalledWith(
      "publish-anonymous-ip:203.0.113.10",
      60_000,
    );
    expect(publishedRuleCreateMock).toHaveBeenCalledWith({
      data: expect.objectContaining({
        userId: null,
        title: "Guest rule",
        claimTokenHash: `claim-${"x".repeat(32)}`,
      }),
    });
    expect(issueGuestRuleClaimCookieMock).toHaveBeenCalledWith("x".repeat(32));
    expect(transactionMock).not.toHaveBeenCalled();
    expect(sendInviteMock).not.toHaveBeenCalled();
    const body = (await res.json()) as { rule: { id: string } };
    expect(body.rule.id).toBe("rule-guest");
  });

  it("rate-limits anonymous publish by IP", async () => {
    getSessionUserMock.mockResolvedValueOnce(null);
    rateLimitKeyMock.mockReturnValueOnce({
      ok: false as const,
      retryAfterMs: 12_000,
    });

    const res = await POST(
      new NextRequest("https://x.test/api/rules", {
        method: "POST",
        body: JSON.stringify({
          title: "Guest rule",
          summary: null,
          document: {},
        }),
      }),
      undefined,
    );

    expect(res.status).toBe(429);
    expect(publishedRuleCreateMock).not.toHaveBeenCalled();
    expect(issueGuestRuleClaimCookieMock).not.toHaveBeenCalled();
  });
});
