/**
 * Viewing Reminders Tests
 *
 * Comprehensive tests for 3 cron/reminder API routes:
 * 1. /api/real-estate/viewings/reminders  (GET cron + POST manual)
 * 2. /api/cron/viewing-reminders          (GET + POST)
 * 3. /api/cron/viewing-follow-ups         (GET + POST)
 *
 * 50+ tests covering auth failures, successful scans, empty results,
 * partial failures, no-show detection, edge cases, and POST delegation.
 */

import { NextRequest } from "next/server";

// ── Mocks ──────────────────────────────────────────────────────────────────

jest.mock("@/lib/db");
jest.mock("@/lib/logger", () => ({
  logger: {
    info: jest.fn(),
    error: jest.fn(),
    warn: jest.fn(),
    debug: jest.fn(),
  },
}));
jest.mock("@/lib/errors", () => ({
  getErrorMessage: jest.fn((e: Error) => e?.message || "Unknown"),
}));
jest.mock("@/lib/utils/api-response.util", () => ({
  ApiResponses: {
    unauthorized: jest.fn(
      (msg?: string) =>
        new Response(JSON.stringify({ error: msg || "Unauthorized" }), {
          status: 401,
        }),
    ),
    notFound: jest.fn(
      (msg: string) =>
        new Response(JSON.stringify({ error: msg }), { status: 404 }),
    ),
    badRequest: jest.fn(
      (msg: string) =>
        new Response(JSON.stringify({ error: msg }), { status: 400 }),
    ),
    serverError: jest.fn(
      (msg: string) =>
        new Response(JSON.stringify({ error: msg }), { status: 500 }),
    ),
    success: jest.fn(
      (data: unknown) =>
        new Response(JSON.stringify({ success: true, ...(data as object) }), {
          status: 200,
        }),
    ),
  },
}));
jest.mock("@/lib/services/real-estate/notification.service", () => ({
  sendViewingReminder: jest.fn(() => Promise.resolve({ sent: true })),
  sendPostViewingFollowUp: jest.fn(() => Promise.resolve()),
}));
jest.mock("@/lib/services/system-settings.service", () => ({
  systemSettingsService: {
    validateN8NToken: jest.fn(() => Promise.resolve(false)),
  },
}));
jest.mock("@/lib/auth", () => ({ auth: jest.fn() }));
jest.mock("@/lib/utils/auth-helpers.util", () => ({
  getAuthenticatedEmail: jest.fn(),
}));

// ── Imports (after mocks) ──────────────────────────────────────────────────

import { prisma } from "@/lib/db";
import { getAuthenticatedEmail } from "@/lib/utils/auth-helpers.util";
import {
  sendViewingReminder,
  sendPostViewingFollowUp,
} from "@/lib/services/real-estate/notification.service";
import { systemSettingsService } from "@/lib/services/system-settings.service";

// Route handlers
import {
  GET as remindersGET,
  POST as remindersPOST,
} from "@/app/api/real-estate/viewings/reminders/route";
import {
  GET as cronRemindersGET,
  POST as cronRemindersPOST,
} from "@/app/api/cron/viewing-reminders/route";
import {
  GET as cronFollowUpsGET,
  POST as cronFollowUpsPOST,
} from "@/app/api/cron/viewing-follow-ups/route";

// ── Environment setup ──────────────────────────────────────────────────────

const originalEnv = process.env;

beforeEach(() => {
  process.env = { ...originalEnv, CRON_SECRET: "test-cron-secret" };
  jest.clearAllMocks();
  (sendViewingReminder as jest.Mock).mockResolvedValue({ sent: true });
  (sendPostViewingFollowUp as jest.Mock).mockResolvedValue(undefined);
  (systemSettingsService.validateN8NToken as jest.Mock).mockResolvedValue(
    false,
  );
});

afterAll(() => {
  process.env = originalEnv;
});

// ── Helpers ────────────────────────────────────────────────────────────────

function makeRequest(
  url: string,
  options?: RequestInit & { body?: unknown },
): NextRequest {
  const init: Record<string, unknown> = {
    method: options?.method || "GET",
    headers: {},
  };
  if (options?.headers) {
    init.headers = options.headers;
  }
  if (options?.body) {
    init.body = JSON.stringify(options.body);
    (init.headers as Record<string, string>)["content-type"] =
      "application/json";
  }
  return new NextRequest(url, init as RequestInit);
}

/** Create a date that is `hours` hours in the future. */
function hoursFromNow(hours: number): Date {
  return new Date(Date.now() + hours * 60 * 60 * 1000);
}

/** Create a date that is `hours` hours in the past. */
function hoursAgo(hours: number): Date {
  return new Date(Date.now() - hours * 60 * 60 * 1000);
}

/**
 * Split a Date into viewingDate (midnight local) and viewingTime ("HH:MM").
 * Used for the /api/real-estate/viewings/reminders route which uses setHours.
 */
function toViewingParts(date: Date): {
  viewingDate: Date;
  viewingTime: string;
} {
  const viewingDate = new Date(date);
  viewingDate.setHours(0, 0, 0, 0);
  const hours = date.getHours().toString().padStart(2, "0");
  const minutes = date.getMinutes().toString().padStart(2, "0");
  return { viewingDate, viewingTime: `${hours}:${minutes}` };
}

/**
 * Split a Date into viewingDate (midnight UTC) and viewingTime ("HH:MM" UTC).
 * Used for the /api/cron/viewing-reminders route which uses setUTCHours.
 */
function toViewingPartsUTC(date: Date): {
  viewingDate: Date;
  viewingTime: string;
} {
  const viewingDate = new Date(date);
  viewingDate.setUTCHours(0, 0, 0, 0);
  const hours = date.getUTCHours().toString().padStart(2, "0");
  const minutes = date.getUTCMinutes().toString().padStart(2, "0");
  return { viewingDate, viewingTime: `${hours}:${minutes}` };
}

// ==========================================================================
// 1. /api/real-estate/viewings/reminders  -- GET (cron-triggered)
// ==========================================================================

describe("GET /api/real-estate/viewings/reminders (cron)", () => {
  const url = "http://localhost/api/real-estate/viewings/reminders";

  it("returns 500 when CRON_SECRET is not configured", async () => {
    delete process.env.CRON_SECRET;

    const req = makeRequest(url, {
      headers: { authorization: "Bearer test-cron-secret" },
    });

    const res = await remindersGET(req);
    expect(res.status).toBe(500);

    const body = await res.json();
    expect(body.error).toBe("Server misconfiguration");
  });

  it("returns 401 when no Authorization header is present", async () => {
    const req = makeRequest(url);

    const res = await remindersGET(req);
    expect(res.status).toBe(401);
  });

  it("returns 401 when Authorization header has wrong secret", async () => {
    const req = makeRequest(url, {
      headers: { authorization: "Bearer wrong-secret" },
    });

    const res = await remindersGET(req);
    expect(res.status).toBe(401);
  });

  it("returns 401 when Authorization header format is invalid (no Bearer prefix)", async () => {
    const req = makeRequest(url, {
      headers: { authorization: "test-cron-secret" },
    });

    const res = await remindersGET(req);
    expect(res.status).toBe(401);
  });

  it("finds viewings 20-26h away for 24h reminders", async () => {
    const futureDate = hoursFromNow(23);
    const parts = toViewingParts(futureDate);

    (prisma.propertyViewing.findMany as jest.Mock).mockResolvedValue([
      {
        id: "v-24h",
        viewingDate: parts.viewingDate,
        viewingTime: parts.viewingTime,
        reminder24hSentAt: null,
        reminder2hSentAt: null,
        clientName: "Alice",
      },
    ]);

    const req = makeRequest(url, {
      headers: { authorization: "Bearer test-cron-secret" },
    });

    const res = await remindersGET(req);
    expect(res.status).toBe(200);

    const body = await res.json();

    expect(body.reminders24h.attempted).toBe(1);
    expect(body.reminders24h.sent).toBe(1);
    expect(sendViewingReminder).toHaveBeenCalledWith("v-24h", "24h");
  });

  it("finds viewings 1-4h away for 2h reminders", async () => {
    const futureDate = hoursFromNow(2);
    const parts = toViewingParts(futureDate);

    (prisma.propertyViewing.findMany as jest.Mock).mockResolvedValue([
      {
        id: "v-2h",
        viewingDate: parts.viewingDate,
        viewingTime: parts.viewingTime,
        reminder24hSentAt: new Date(), // 24h already sent
        reminder2hSentAt: null,
        clientName: "Bob",
      },
    ]);

    const req = makeRequest(url, {
      headers: { authorization: "Bearer test-cron-secret" },
    });

    const res = await remindersGET(req);
    expect(res.status).toBe(200);

    const body = await res.json();
    expect(body.reminders2h.attempted).toBe(1);
    expect(body.reminders2h.sent).toBe(1);
    expect(sendViewingReminder).toHaveBeenCalledWith("v-2h", "2h");
  });

  it("skips viewings where 24h reminder already sent", async () => {
    const futureDate = hoursFromNow(23);
    const parts = toViewingParts(futureDate);

    (prisma.propertyViewing.findMany as jest.Mock).mockResolvedValue([
      {
        id: "v-already-sent",
        viewingDate: parts.viewingDate,
        viewingTime: parts.viewingTime,
        reminder24hSentAt: new Date(),
        reminder2hSentAt: null,
        clientName: "Charlie",
      },
    ]);

    const req = makeRequest(url, {
      headers: { authorization: "Bearer test-cron-secret" },
    });

    const res = await remindersGET(req);
    expect(res.status).toBe(200);

    const body = await res.json();
    expect(body.reminders24h.attempted).toBe(0);
  });

  it("only queries CONFIRMED or REQUESTED viewings", async () => {
    (prisma.propertyViewing.findMany as jest.Mock).mockResolvedValue([]);

    const req = makeRequest(url, {
      headers: { authorization: "Bearer test-cron-secret" },
    });

    await remindersGET(req);

    const callArgs = (prisma.propertyViewing.findMany as jest.Mock).mock
      .calls[0][0];
    expect(callArgs.where.status).toEqual({ in: ["CONFIRMED", "REQUESTED"] });
  });

  it("returns summary with sent/failed counts when reminders partially fail", async () => {
    const futureDate24h = hoursFromNow(23);
    const parts24h = toViewingParts(futureDate24h);

    const futureDate2h = hoursFromNow(2);
    const parts2h = toViewingParts(futureDate2h);

    (prisma.propertyViewing.findMany as jest.Mock).mockResolvedValue([
      {
        id: "v-ok",
        viewingDate: parts24h.viewingDate,
        viewingTime: parts24h.viewingTime,
        reminder24hSentAt: null,
        reminder2hSentAt: null,
        clientName: "Dave",
      },
      {
        id: "v-fail",
        viewingDate: parts2h.viewingDate,
        viewingTime: parts2h.viewingTime,
        reminder24hSentAt: new Date(),
        reminder2hSentAt: null,
        clientName: "Eve",
      },
    ]);

    (sendViewingReminder as jest.Mock)
      .mockResolvedValueOnce({ sent: true })
      .mockRejectedValueOnce(new Error("Notification service down"));

    const req = makeRequest(url, {
      headers: { authorization: "Bearer test-cron-secret" },
    });

    const res = await remindersGET(req);
    expect(res.status).toBe(200);

    const body = await res.json();
    expect(body.summary).toBeDefined();
    expect(body.summary.totalSent).toBe(1);
    expect(body.summary.totalFailed).toBe(1);
    expect(body.reminders2h.failed).toBe(1);
    expect(body.reminders2h.errors).toBeDefined();
  });

  it("handles empty results when no viewings need reminders", async () => {
    (prisma.propertyViewing.findMany as jest.Mock).mockResolvedValue([]);

    const req = makeRequest(url, {
      headers: { authorization: "Bearer test-cron-secret" },
    });

    const res = await remindersGET(req);
    expect(res.status).toBe(200);

    const body = await res.json();
    expect(body.summary.totalSent).toBe(0);
    expect(body.summary.totalFailed).toBe(0);
    expect(body.reminders24h.attempted).toBe(0);
    expect(body.reminders2h.attempted).toBe(0);
  });

  it("isolates individual reminder failures (one failure does not block others)", async () => {
    const futureDate = hoursFromNow(23);
    const parts = toViewingParts(futureDate);

    (prisma.propertyViewing.findMany as jest.Mock).mockResolvedValue([
      {
        id: "v-a",
        viewingDate: parts.viewingDate,
        viewingTime: parts.viewingTime,
        reminder24hSentAt: null,
        reminder2hSentAt: null,
        clientName: "Fail",
      },
      {
        id: "v-b",
        viewingDate: parts.viewingDate,
        viewingTime: parts.viewingTime,
        reminder24hSentAt: null,
        reminder2hSentAt: null,
        clientName: "Success",
      },
    ]);

    (sendViewingReminder as jest.Mock)
      .mockRejectedValueOnce(new Error("Service down"))
      .mockResolvedValueOnce({ sent: true });

    const req = makeRequest(url, {
      headers: { authorization: "Bearer test-cron-secret" },
    });

    const res = await remindersGET(req);
    expect(res.status).toBe(200);

    const body = await res.json();
    expect(body.reminders24h.attempted).toBe(2);
    expect(body.reminders24h.sent).toBe(1);
    expect(body.reminders24h.failed).toBe(1);
  });

  it("handles sendViewingReminder returning { sent: false, reason }", async () => {
    const futureDate = hoursFromNow(23);
    const parts = toViewingParts(futureDate);

    (prisma.propertyViewing.findMany as jest.Mock).mockResolvedValue([
      {
        id: "v-not-sent",
        viewingDate: parts.viewingDate,
        viewingTime: parts.viewingTime,
        reminder24hSentAt: null,
        reminder2hSentAt: null,
        clientName: "Skipped",
      },
    ]);

    (sendViewingReminder as jest.Mock).mockResolvedValue({
      sent: false,
      reason: "No contact email",
    });

    const req = makeRequest(url, {
      headers: { authorization: "Bearer test-cron-secret" },
    });

    const res = await remindersGET(req);
    expect(res.status).toBe(200);

    const body = await res.json();
    expect(body.reminders24h.sent).toBe(0);
    expect(body.reminders24h.failed).toBe(1);
    expect(body.reminders24h.errors).toContain("v-not-sent: No contact email");
  });

  it("includes errors array only when there are failures with reasons", async () => {
    const futureDate = hoursFromNow(23);
    const parts = toViewingParts(futureDate);

    (prisma.propertyViewing.findMany as jest.Mock).mockResolvedValue([
      {
        id: "v-ok-only",
        viewingDate: parts.viewingDate,
        viewingTime: parts.viewingTime,
        reminder24hSentAt: null,
        reminder2hSentAt: null,
        clientName: "Good Client",
      },
    ]);

    const req = makeRequest(url, {
      headers: { authorization: "Bearer test-cron-secret" },
    });

    const res = await remindersGET(req);
    const body = await res.json();

    // When all succeed, errors should not be present
    expect(body.reminders24h.errors).toBeUndefined();
    expect(body.reminders2h.errors).toBeUndefined();
  });

  it("returns 500 when findMany throws unexpectedly", async () => {
    (prisma.propertyViewing.findMany as jest.Mock).mockRejectedValue(
      new Error("Connection refused"),
    );

    const req = makeRequest(url, {
      headers: { authorization: "Bearer test-cron-secret" },
    });

    const res = await remindersGET(req);
    expect(res.status).toBe(500);

    const body = await res.json();
    expect(body.error).toBe("Failed to process viewing reminders");
  });

  it("can send both 24h and 2h reminders for the same viewing", async () => {
    // A viewing that is 2 hours away AND also has reminder24hSentAt null
    // (this is a valid scenario if it was in the 24h window on a previous run
    // but the route also processes it for 2h if in 1-4h window)
    const futureDate = hoursFromNow(2);
    const parts = toViewingParts(futureDate);

    (prisma.propertyViewing.findMany as jest.Mock).mockResolvedValue([
      {
        id: "v-both",
        viewingDate: parts.viewingDate,
        viewingTime: parts.viewingTime,
        reminder24hSentAt: null,
        reminder2hSentAt: null,
        clientName: "Both Reminders",
      },
    ]);

    const req = makeRequest(url, {
      headers: { authorization: "Bearer test-cron-secret" },
    });

    const res = await remindersGET(req);
    expect(res.status).toBe(200);

    const body = await res.json();
    // 2h from now is in the 1-4h window but NOT in the 20-26h window
    expect(body.reminders2h.attempted).toBe(1);
    expect(body.reminders24h.attempted).toBe(0);
  });
});

// ==========================================================================
// 2. /api/real-estate/viewings/reminders  -- POST (manual trigger)
// ==========================================================================

describe("POST /api/real-estate/viewings/reminders (manual)", () => {
  const url = "http://localhost/api/real-estate/viewings/reminders";

  it("returns 401 when not authenticated", async () => {
    (getAuthenticatedEmail as jest.Mock).mockResolvedValue(null);

    const req = makeRequest(url, {
      method: "POST",
      body: { viewingId: "v-1", type: "24h" },
    });

    const res = await remindersPOST(req);
    expect(res.status).toBe(401);
  });

  it("returns 400 for invalid JSON body", async () => {
    (getAuthenticatedEmail as jest.Mock).mockResolvedValue("staff@test.com");

    const req = new NextRequest(url, {
      method: "POST",
      body: "not-json",
      headers: { "content-type": "application/json" },
    });

    const res = await remindersPOST(req);
    expect(res.status).toBe(400);

    const body = await res.json();
    expect(body.error).toBe("Invalid JSON body");
  });

  it("returns 400 when viewingId is missing", async () => {
    (getAuthenticatedEmail as jest.Mock).mockResolvedValue("staff@test.com");

    const req = makeRequest(url, {
      method: "POST",
      body: { type: "24h" },
    });

    const res = await remindersPOST(req);
    expect(res.status).toBe(400);

    const body = await res.json();
    expect(body.error).toContain("viewingId");
  });

  it("returns 400 when viewingId is not a string", async () => {
    (getAuthenticatedEmail as jest.Mock).mockResolvedValue("staff@test.com");

    const req = makeRequest(url, {
      method: "POST",
      body: { viewingId: 123, type: "24h" },
    });

    const res = await remindersPOST(req);
    expect(res.status).toBe(400);

    const body = await res.json();
    expect(body.error).toContain("viewingId");
  });

  it("returns 400 when type is invalid (not 24h or 2h)", async () => {
    (getAuthenticatedEmail as jest.Mock).mockResolvedValue("staff@test.com");

    const req = makeRequest(url, {
      method: "POST",
      body: { viewingId: "v-1", type: "12h" },
    });

    const res = await remindersPOST(req);
    expect(res.status).toBe(400);

    const body = await res.json();
    expect(body.error).toContain("type");
  });

  it("returns 400 when type is missing", async () => {
    (getAuthenticatedEmail as jest.Mock).mockResolvedValue("staff@test.com");

    const req = makeRequest(url, {
      method: "POST",
      body: { viewingId: "v-1" },
    });

    const res = await remindersPOST(req);
    expect(res.status).toBe(400);

    const body = await res.json();
    expect(body.error).toContain("type");
  });

  it("returns 404 when viewing does not exist", async () => {
    (getAuthenticatedEmail as jest.Mock).mockResolvedValue("staff@test.com");
    (prisma.propertyViewing.findUnique as jest.Mock).mockResolvedValue(null);

    const req = makeRequest(url, {
      method: "POST",
      body: { viewingId: "v-nonexistent", type: "24h" },
    });

    const res = await remindersPOST(req);
    expect(res.status).toBe(404);

    const body = await res.json();
    expect(body.error).toBe("Viewing not found");
  });

  it("sends 24h reminder successfully for an existing viewing", async () => {
    (getAuthenticatedEmail as jest.Mock).mockResolvedValue("staff@test.com");
    (prisma.propertyViewing.findUnique as jest.Mock).mockResolvedValue({
      id: "v-1",
      status: "CONFIRMED",
      viewingDate: new Date(),
      viewingTime: "14:00",
      clientName: "Test Client",
    });
    (sendViewingReminder as jest.Mock).mockResolvedValue({ sent: true });

    const req = makeRequest(url, {
      method: "POST",
      body: { viewingId: "v-1", type: "24h" },
    });

    const res = await remindersPOST(req);
    expect(res.status).toBe(200);

    const body = await res.json();
    expect(body.success).toBe(true);
    expect(body.type).toBe("24h");
    expect(body.message).toContain("24h");
    expect(sendViewingReminder).toHaveBeenCalledWith("v-1", "24h");
  });

  it("sends 2h reminder successfully", async () => {
    (getAuthenticatedEmail as jest.Mock).mockResolvedValue("staff@test.com");
    (prisma.propertyViewing.findUnique as jest.Mock).mockResolvedValue({
      id: "v-2",
      status: "REQUESTED",
      viewingDate: new Date(),
      viewingTime: "10:30",
      clientName: "Client B",
    });
    (sendViewingReminder as jest.Mock).mockResolvedValue({ sent: true });

    const req = makeRequest(url, {
      method: "POST",
      body: { viewingId: "v-2", type: "2h" },
    });

    const res = await remindersPOST(req);
    expect(res.status).toBe(200);

    const body = await res.json();
    expect(body.success).toBe(true);
    expect(body.type).toBe("2h");
  });

  it("returns success: false when notification service says not sent", async () => {
    (getAuthenticatedEmail as jest.Mock).mockResolvedValue("staff@test.com");
    (prisma.propertyViewing.findUnique as jest.Mock).mockResolvedValue({
      id: "v-3",
      status: "CONFIRMED",
      viewingDate: new Date(),
      viewingTime: "09:00",
      clientName: "Client C",
    });
    (sendViewingReminder as jest.Mock).mockResolvedValue({
      sent: false,
      reason: "No email on file",
    });

    const req = makeRequest(url, {
      method: "POST",
      body: { viewingId: "v-3", type: "24h" },
    });

    const res = await remindersPOST(req);
    expect(res.status).toBe(200);

    const body = await res.json();
    // Note: the ApiResponses.success mock wraps data with success: true,
    // but the route explicitly sets success: false in the data payload
    expect(body.reason).toBe("No email on file");
  });

  it("returns success: false with default reason when result.reason is absent", async () => {
    (getAuthenticatedEmail as jest.Mock).mockResolvedValue("staff@test.com");
    (prisma.propertyViewing.findUnique as jest.Mock).mockResolvedValue({
      id: "v-no-reason",
      status: "CONFIRMED",
      viewingDate: new Date(),
      viewingTime: "09:00",
      clientName: "Client No-Reason",
    });
    (sendViewingReminder as jest.Mock).mockResolvedValue({ sent: false });

    const req = makeRequest(url, {
      method: "POST",
      body: { viewingId: "v-no-reason", type: "2h" },
    });

    const res = await remindersPOST(req);
    expect(res.status).toBe(200);

    const body = await res.json();
    expect(body.reason).toBe("Reminder was not sent");
  });

  it("returns 500 when sendViewingReminder throws", async () => {
    (getAuthenticatedEmail as jest.Mock).mockResolvedValue("staff@test.com");
    (prisma.propertyViewing.findUnique as jest.Mock).mockResolvedValue({
      id: "v-4",
      status: "CONFIRMED",
      viewingDate: new Date(),
      viewingTime: "15:00",
      clientName: "Client D",
    });
    (sendViewingReminder as jest.Mock).mockRejectedValue(
      new Error("SMTP failure"),
    );

    const req = makeRequest(url, {
      method: "POST",
      body: { viewingId: "v-4", type: "2h" },
    });

    const res = await remindersPOST(req);
    expect(res.status).toBe(500);

    const body = await res.json();
    expect(body.error).toBeDefined();
  });

  it("passes viewingId to findUnique with correct select fields", async () => {
    (getAuthenticatedEmail as jest.Mock).mockResolvedValue("staff@test.com");
    (prisma.propertyViewing.findUnique as jest.Mock).mockResolvedValue({
      id: "v-check",
      status: "CONFIRMED",
      viewingDate: new Date(),
      viewingTime: "11:00",
      clientName: "Check Client",
    });

    const req = makeRequest(url, {
      method: "POST",
      body: { viewingId: "v-check", type: "24h" },
    });

    await remindersPOST(req);

    expect(prisma.propertyViewing.findUnique).toHaveBeenCalledWith({
      where: { id: "v-check" },
      select: {
        id: true,
        status: true,
        viewingDate: true,
        viewingTime: true,
        clientName: true,
      },
    });
  });
});

// ==========================================================================
// 3. /api/cron/viewing-reminders  -- GET + POST
// ==========================================================================

describe("GET /api/cron/viewing-reminders", () => {
  const url = "http://localhost/api/cron/viewing-reminders";

  it("returns 401 without any authorization header", async () => {
    const req = new Request(url, { method: "GET" });

    const res = await cronRemindersGET(req);
    expect(res.status).toBe(401);

    const body = await res.json();
    expect(body.error).toBe("Unauthorized");
  });

  it("returns 401 when CRON_SECRET env is not set", async () => {
    delete process.env.CRON_SECRET;

    const req = new Request(url, {
      method: "GET",
      headers: { authorization: "Bearer test-cron-secret" },
    });

    const res = await cronRemindersGET(req);
    expect(res.status).toBe(401);
  });

  it("returns 401 with wrong secret and invalid n8n token", async () => {
    const req = new Request(url, {
      method: "GET",
      headers: { authorization: "Bearer wrong-secret" },
    });

    const res = await cronRemindersGET(req);
    expect(res.status).toBe(401);
  });

  it("authorizes with valid CRON_SECRET and processes viewings", async () => {
    (prisma.propertyViewing.findMany as jest.Mock).mockResolvedValue([]);

    const req = new Request(url, {
      method: "GET",
      headers: { authorization: "Bearer test-cron-secret" },
    });

    const res = await cronRemindersGET(req);
    expect(res.status).toBe(200);

    const body = await res.json();
    expect(body.success).toBe(true);
    expect(body.reminders24h).toBeDefined();
    expect(body.reminders2h).toBeDefined();
  });

  it("authorizes with valid n8n token when CRON_SECRET does not match", async () => {
    (systemSettingsService.validateN8NToken as jest.Mock).mockResolvedValue(
      true,
    );
    (prisma.propertyViewing.findMany as jest.Mock).mockResolvedValue([]);

    const req = new Request(url, {
      method: "GET",
      headers: { authorization: "Bearer some-n8n-token" },
    });

    const res = await cronRemindersGET(req);
    expect(res.status).toBe(200);
  });

  it("sends 24h reminders for viewings in the 22-26h window", async () => {
    const futureDate = hoursFromNow(24);
    const parts = toViewingPartsUTC(futureDate);

    (prisma.propertyViewing.findMany as jest.Mock)
      .mockResolvedValueOnce([
        {
          id: "cron-v-24h",
          clientName: "Cron Client 24h",
          viewingDate: parts.viewingDate,
          viewingTime: parts.viewingTime,
        },
      ])
      .mockResolvedValueOnce([]);

    const req = new Request(url, {
      method: "GET",
      headers: { authorization: "Bearer test-cron-secret" },
    });

    const res = await cronRemindersGET(req);
    expect(res.status).toBe(200);

    const body = await res.json();
    expect(body.reminders24h.sent).toBe(1);
    expect(sendViewingReminder).toHaveBeenCalledWith("cron-v-24h", "24h");
  });

  it("sends 2h reminders using combineDateTime for in-memory filtering", async () => {
    const futureDate = hoursFromNow(2);
    const parts = toViewingPartsUTC(futureDate);

    (prisma.propertyViewing.findMany as jest.Mock)
      .mockResolvedValueOnce([]) // 24h query
      .mockResolvedValueOnce([
        {
          id: "cron-v-2h",
          clientName: "Cron Client 2h",
          viewingDate: parts.viewingDate,
          viewingTime: parts.viewingTime,
        },
      ]);

    const req = new Request(url, {
      method: "GET",
      headers: { authorization: "Bearer test-cron-secret" },
    });

    const res = await cronRemindersGET(req);
    expect(res.status).toBe(200);

    const body = await res.json();
    expect(body.reminders2h.sent).toBe(1);
    expect(sendViewingReminder).toHaveBeenCalledWith("cron-v-2h", "2h");
  });

  it("filters out 2h candidates that fall outside the 1.5-2.5h window", async () => {
    // A viewing 5 hours from now should NOT be in the 2h window
    const futureDate = hoursFromNow(5);
    const parts = toViewingPartsUTC(futureDate);

    (prisma.propertyViewing.findMany as jest.Mock)
      .mockResolvedValueOnce([]) // 24h query
      .mockResolvedValueOnce([
        {
          id: "cron-v-too-far",
          clientName: "Too Far Client",
          viewingDate: parts.viewingDate,
          viewingTime: parts.viewingTime,
        },
      ]);

    const req = new Request(url, {
      method: "GET",
      headers: { authorization: "Bearer test-cron-secret" },
    });

    const res = await cronRemindersGET(req);
    const body = await res.json();

    // In-memory filter should exclude this viewing
    expect(body.reminders2h.sent).toBe(0);
    expect(body.reminders2h.skipped).toBe(0);
    expect(sendViewingReminder).not.toHaveBeenCalledWith(
      "cron-v-too-far",
      "2h",
    );
  });

  it("returns empty results when no viewings need reminders", async () => {
    (prisma.propertyViewing.findMany as jest.Mock)
      .mockResolvedValueOnce([])
      .mockResolvedValueOnce([]);

    const req = new Request(url, {
      method: "GET",
      headers: { authorization: "Bearer test-cron-secret" },
    });

    const res = await cronRemindersGET(req);
    expect(res.status).toBe(200);

    const body = await res.json();
    expect(body.success).toBe(true);
    expect(body.reminders24h.sent).toBe(0);
    expect(body.reminders24h.skipped).toBe(0);
    expect(body.reminders2h.sent).toBe(0);
    expect(body.reminders2h.skipped).toBe(0);
  });

  it("isolates individual reminder failures (error isolation)", async () => {
    const futureDate = hoursFromNow(24);
    const parts = toViewingPartsUTC(futureDate);

    (prisma.propertyViewing.findMany as jest.Mock)
      .mockResolvedValueOnce([
        {
          id: "v-fail",
          clientName: "Fail Client",
          viewingDate: parts.viewingDate,
          viewingTime: parts.viewingTime,
        },
        {
          id: "v-ok",
          clientName: "OK Client",
          viewingDate: parts.viewingDate,
          viewingTime: parts.viewingTime,
        },
      ])
      .mockResolvedValueOnce([]);

    (sendViewingReminder as jest.Mock)
      .mockRejectedValueOnce(new Error("Email service down"))
      .mockResolvedValueOnce({ sent: true });

    const req = new Request(url, {
      method: "GET",
      headers: { authorization: "Bearer test-cron-secret" },
    });

    const res = await cronRemindersGET(req);
    expect(res.status).toBe(200);

    const body = await res.json();
    expect(body.reminders24h.sent).toBe(1);
    expect(body.reminders24h.skipped).toBe(1);
    expect(body.errors).toBeDefined();
    expect(body.errors.length).toBe(1);
    expect(body.errors[0]).toContain("v-fail");
  });

  it("counts skipped when sendViewingReminder returns { sent: false }", async () => {
    const futureDate = hoursFromNow(24);
    const parts = toViewingPartsUTC(futureDate);

    (prisma.propertyViewing.findMany as jest.Mock)
      .mockResolvedValueOnce([
        {
          id: "v-skip",
          clientName: "Skip Client",
          viewingDate: parts.viewingDate,
          viewingTime: parts.viewingTime,
        },
      ])
      .mockResolvedValueOnce([]);

    (sendViewingReminder as jest.Mock).mockResolvedValue({
      sent: false,
      reason: "Client opted out",
    });

    const req = new Request(url, {
      method: "GET",
      headers: { authorization: "Bearer test-cron-secret" },
    });

    const res = await cronRemindersGET(req);
    expect(res.status).toBe(200);

    const body = await res.json();
    expect(body.reminders24h.skipped).toBe(1);
    expect(body.reminders24h.sent).toBe(0);
  });

  it("includes duration in response", async () => {
    (prisma.propertyViewing.findMany as jest.Mock)
      .mockResolvedValueOnce([])
      .mockResolvedValueOnce([]);

    const req = new Request(url, {
      method: "GET",
      headers: { authorization: "Bearer test-cron-secret" },
    });

    const res = await cronRemindersGET(req);
    const body = await res.json();
    expect(body.duration).toBeDefined();
    expect(body.duration).toMatch(/\d+ms/);
  });

  it("returns 500 when database query throws", async () => {
    (prisma.propertyViewing.findMany as jest.Mock).mockRejectedValue(
      new Error("DB connection lost"),
    );

    const req = new Request(url, {
      method: "GET",
      headers: { authorization: "Bearer test-cron-secret" },
    });

    const res = await cronRemindersGET(req);
    expect(res.status).toBe(500);

    const body = await res.json();
    expect(body.success).toBe(false);
    expect(body.error).toBeDefined();
  });

  it("sets success: false when there are errors but still returns 200", async () => {
    const futureDate = hoursFromNow(24);
    const parts = toViewingPartsUTC(futureDate);

    (prisma.propertyViewing.findMany as jest.Mock)
      .mockResolvedValueOnce([
        {
          id: "v-err",
          clientName: "Error Client",
          viewingDate: parts.viewingDate,
          viewingTime: parts.viewingTime,
        },
      ])
      .mockResolvedValueOnce([]);

    (sendViewingReminder as jest.Mock).mockRejectedValue(
      new Error("Service error"),
    );

    const req = new Request(url, {
      method: "GET",
      headers: { authorization: "Bearer test-cron-secret" },
    });

    const res = await cronRemindersGET(req);
    expect(res.status).toBe(200);

    const body = await res.json();
    expect(body.success).toBe(false);
    expect(body.errors).toBeDefined();
  });

  it("POST delegates to GET handler", async () => {
    (prisma.propertyViewing.findMany as jest.Mock)
      .mockResolvedValueOnce([])
      .mockResolvedValueOnce([]);

    const req = new Request(url, {
      method: "POST",
      headers: { authorization: "Bearer test-cron-secret" },
    });

    const res = await cronRemindersPOST(req);
    expect(res.status).toBe(200);

    const body = await res.json();
    expect(body.success).toBe(true);
    expect(body.reminders24h).toBeDefined();
    expect(body.reminders2h).toBeDefined();
  });

  it("POST also returns 401 for unauthorized requests", async () => {
    const req = new Request(url, { method: "POST" });

    const res = await cronRemindersPOST(req);
    expect(res.status).toBe(401);
  });

  it("queries 24h viewings with correct Prisma filter shape", async () => {
    (prisma.propertyViewing.findMany as jest.Mock)
      .mockResolvedValueOnce([])
      .mockResolvedValueOnce([]);

    const req = new Request(url, {
      method: "GET",
      headers: { authorization: "Bearer test-cron-secret" },
    });

    await cronRemindersGET(req);

    // First findMany is the 24h query
    const firstCall = (prisma.propertyViewing.findMany as jest.Mock).mock
      .calls[0][0];
    expect(firstCall.where.status).toEqual({ in: ["CONFIRMED", "REQUESTED"] });
    expect(firstCall.where.reminder24hSentAt).toBeNull();
    expect(firstCall.where.viewingDate).toBeDefined();
    expect(firstCall.where.viewingDate.gte).toBeInstanceOf(Date);
    expect(firstCall.where.viewingDate.lte).toBeInstanceOf(Date);
  });

  it("queries 2h candidates with today/tomorrow date range and reminder2hSentAt: null", async () => {
    (prisma.propertyViewing.findMany as jest.Mock)
      .mockResolvedValueOnce([])
      .mockResolvedValueOnce([]);

    const req = new Request(url, {
      method: "GET",
      headers: { authorization: "Bearer test-cron-secret" },
    });

    await cronRemindersGET(req);

    // Second findMany is the 2h candidates query
    const secondCall = (prisma.propertyViewing.findMany as jest.Mock).mock
      .calls[1][0];
    expect(secondCall.where.status).toEqual({ in: ["CONFIRMED", "REQUESTED"] });
    expect(secondCall.where.reminder2hSentAt).toBeNull();
    expect(secondCall.where.viewingDate.gte).toBeInstanceOf(Date);
    expect(secondCall.where.viewingDate.lt).toBeInstanceOf(Date);
  });
});

// ==========================================================================
// 4. /api/cron/viewing-follow-ups  -- GET + POST
// ==========================================================================

describe("GET /api/cron/viewing-follow-ups", () => {
  const url = "http://localhost/api/cron/viewing-follow-ups";

  it("returns 401 without any authorization header", async () => {
    const req = new Request(url, { method: "GET" });

    const res = await cronFollowUpsGET(req);
    expect(res.status).toBe(401);
  });

  it("returns 401 when CRON_SECRET env is not set", async () => {
    delete process.env.CRON_SECRET;

    const req = new Request(url, {
      method: "GET",
      headers: { authorization: "Bearer test-cron-secret" },
    });

    const res = await cronFollowUpsGET(req);
    expect(res.status).toBe(401);
  });

  it("authorizes with valid CRON_SECRET", async () => {
    (prisma.propertyViewing.findMany as jest.Mock).mockResolvedValue([]);

    const req = new Request(url, {
      method: "GET",
      headers: { authorization: "Bearer test-cron-secret" },
    });

    const res = await cronFollowUpsGET(req);
    expect(res.status).toBe(200);

    const body = await res.json();
    expect(body.success).toBe(true);
  });

  it("authorizes with valid n8n token", async () => {
    (systemSettingsService.validateN8NToken as jest.Mock).mockResolvedValue(
      true,
    );
    (prisma.propertyViewing.findMany as jest.Mock).mockResolvedValue([]);

    const req = new Request(url, {
      method: "GET",
      headers: { authorization: "Bearer n8n-token" },
    });

    const res = await cronFollowUpsGET(req);
    expect(res.status).toBe(200);
  });

  // -- Part 1: Post-viewing follow-ups --

  it("identifies completed viewings that need follow-up emails", async () => {
    (prisma.propertyViewing.findMany as jest.Mock)
      .mockResolvedValueOnce([{ id: "v-completed-1" }, { id: "v-completed-2" }])
      .mockResolvedValueOnce([]);

    const req = new Request(url, {
      method: "GET",
      headers: { authorization: "Bearer test-cron-secret" },
    });

    const res = await cronFollowUpsGET(req);
    expect(res.status).toBe(200);

    const body = await res.json();
    expect(body.followUps.processed).toBe(2);
    expect(body.followUps.sent).toBe(2);
    expect(sendPostViewingFollowUp).toHaveBeenCalledWith("v-completed-1");
    expect(sendPostViewingFollowUp).toHaveBeenCalledWith("v-completed-2");
  });

  it("queries for VIEW_COMPLETED viewings with followUpSentAt null and updatedAt 4-48h ago", async () => {
    (prisma.propertyViewing.findMany as jest.Mock)
      .mockResolvedValueOnce([])
      .mockResolvedValueOnce([]);

    const req = new Request(url, {
      method: "GET",
      headers: { authorization: "Bearer test-cron-secret" },
    });

    await cronFollowUpsGET(req);

    const firstCall = (prisma.propertyViewing.findMany as jest.Mock).mock
      .calls[0][0];
    expect(firstCall.where.status).toBe("VIEW_COMPLETED");
    expect(firstCall.where.followUpSentAt).toBeNull();
    expect(firstCall.where.updatedAt).toBeDefined();
    expect(firstCall.where.updatedAt.lt).toBeInstanceOf(Date);
    expect(firstCall.where.updatedAt.gt).toBeInstanceOf(Date);
  });

  it("skips follow-up on error but continues processing remaining viewings", async () => {
    (prisma.propertyViewing.findMany as jest.Mock)
      .mockResolvedValueOnce([{ id: "v-fail-fu" }, { id: "v-ok-fu" }])
      .mockResolvedValueOnce([]);

    (sendPostViewingFollowUp as jest.Mock)
      .mockRejectedValueOnce(new Error("Template error"))
      .mockResolvedValueOnce(undefined);

    const req = new Request(url, {
      method: "GET",
      headers: { authorization: "Bearer test-cron-secret" },
    });

    const res = await cronFollowUpsGET(req);
    expect(res.status).toBe(200);

    const body = await res.json();
    expect(body.followUps.processed).toBe(2);
    expect(body.followUps.sent).toBe(1);
    expect(body.followUps.skipped).toBe(1);
  });

  it("handles case where all follow-ups fail gracefully", async () => {
    (prisma.propertyViewing.findMany as jest.Mock)
      .mockResolvedValueOnce([{ id: "v-f1" }, { id: "v-f2" }])
      .mockResolvedValueOnce([]);

    (sendPostViewingFollowUp as jest.Mock)
      .mockRejectedValueOnce(new Error("Fail 1"))
      .mockRejectedValueOnce(new Error("Fail 2"));

    const req = new Request(url, {
      method: "GET",
      headers: { authorization: "Bearer test-cron-secret" },
    });

    const res = await cronFollowUpsGET(req);
    expect(res.status).toBe(200);

    const body = await res.json();
    expect(body.followUps.processed).toBe(2);
    expect(body.followUps.sent).toBe(0);
    expect(body.followUps.skipped).toBe(2);
  });

  // -- Part 2: No-show detection --

  it("identifies no-shows (past viewing time + 2h, status CONFIRMED)", async () => {
    const pastViewing = hoursAgo(3);
    const parts = toViewingParts(pastViewing);

    (prisma.propertyViewing.findMany as jest.Mock)
      .mockResolvedValueOnce([])
      .mockResolvedValueOnce([
        {
          id: "v-noshow",
          viewingDate: parts.viewingDate,
          viewingTime: parts.viewingTime,
        },
      ]);

    (prisma.propertyViewing.update as jest.Mock).mockResolvedValue({
      id: "v-noshow",
      status: "NO_SHOW",
    });

    const req = new Request(url, {
      method: "GET",
      headers: { authorization: "Bearer test-cron-secret" },
    });

    const res = await cronFollowUpsGET(req);
    expect(res.status).toBe(200);

    const body = await res.json();
    expect(body.noShows.detected).toBe(1);
    expect(prisma.propertyViewing.update).toHaveBeenCalledWith({
      where: { id: "v-noshow" },
      data: { status: "NO_SHOW" },
    });
  });

  it("auto-marks multiple no-shows in one run", async () => {
    const pastViewing = hoursAgo(4);
    const parts = toViewingParts(pastViewing);

    (prisma.propertyViewing.findMany as jest.Mock)
      .mockResolvedValueOnce([])
      .mockResolvedValueOnce([
        {
          id: "v-ns-1",
          viewingDate: parts.viewingDate,
          viewingTime: parts.viewingTime,
        },
        {
          id: "v-ns-2",
          viewingDate: parts.viewingDate,
          viewingTime: parts.viewingTime,
        },
      ]);

    (prisma.propertyViewing.update as jest.Mock).mockResolvedValue({});

    const req = new Request(url, {
      method: "GET",
      headers: { authorization: "Bearer test-cron-secret" },
    });

    const res = await cronFollowUpsGET(req);
    expect(res.status).toBe(200);

    const body = await res.json();
    expect(body.noShows.detected).toBe(2);
    expect(prisma.propertyViewing.update).toHaveBeenCalledTimes(2);
  });

  it("does NOT mark as no-show if viewing time + 2h has not passed yet", async () => {
    const recentViewing = hoursAgo(1); // Only 1 hour ago
    const parts = toViewingParts(recentViewing);

    (prisma.propertyViewing.findMany as jest.Mock)
      .mockResolvedValueOnce([])
      .mockResolvedValueOnce([
        {
          id: "v-recent",
          viewingDate: parts.viewingDate,
          viewingTime: parts.viewingTime,
        },
      ]);

    const req = new Request(url, {
      method: "GET",
      headers: { authorization: "Bearer test-cron-secret" },
    });

    const res = await cronFollowUpsGET(req);
    expect(res.status).toBe(200);

    const body = await res.json();
    expect(body.noShows.detected).toBe(0);
    expect(prisma.propertyViewing.update).not.toHaveBeenCalled();
  });

  it("handles both follow-ups and no-shows in a single run", async () => {
    const pastViewing = hoursAgo(5);
    const parts = toViewingParts(pastViewing);

    (prisma.propertyViewing.findMany as jest.Mock)
      .mockResolvedValueOnce([{ id: "v-completed" }])
      .mockResolvedValueOnce([
        {
          id: "v-noshow",
          viewingDate: parts.viewingDate,
          viewingTime: parts.viewingTime,
        },
      ]);

    (prisma.propertyViewing.update as jest.Mock).mockResolvedValue({});

    const req = new Request(url, {
      method: "GET",
      headers: { authorization: "Bearer test-cron-secret" },
    });

    const res = await cronFollowUpsGET(req);
    expect(res.status).toBe(200);

    const body = await res.json();
    expect(body.followUps.sent).toBe(1);
    expect(body.noShows.detected).toBe(1);
  });

  it("returns empty results when no viewings need follow-up or no-show marking", async () => {
    (prisma.propertyViewing.findMany as jest.Mock)
      .mockResolvedValueOnce([])
      .mockResolvedValueOnce([]);

    const req = new Request(url, {
      method: "GET",
      headers: { authorization: "Bearer test-cron-secret" },
    });

    const res = await cronFollowUpsGET(req);
    expect(res.status).toBe(200);

    const body = await res.json();
    expect(body.followUps.processed).toBe(0);
    expect(body.followUps.sent).toBe(0);
    expect(body.followUps.skipped).toBe(0);
    expect(body.noShows.detected).toBe(0);
  });

  it("includes duration in response", async () => {
    (prisma.propertyViewing.findMany as jest.Mock)
      .mockResolvedValueOnce([])
      .mockResolvedValueOnce([]);

    const req = new Request(url, {
      method: "GET",
      headers: { authorization: "Bearer test-cron-secret" },
    });

    const res = await cronFollowUpsGET(req);
    const body = await res.json();
    expect(body.duration).toBeDefined();
    expect(body.duration).toMatch(/\d+ms/);
  });

  it("returns 500 when database query throws", async () => {
    (prisma.propertyViewing.findMany as jest.Mock).mockRejectedValue(
      new Error("DB connection lost"),
    );

    const req = new Request(url, {
      method: "GET",
      headers: { authorization: "Bearer test-cron-secret" },
    });

    const res = await cronFollowUpsGET(req);
    expect(res.status).toBe(500);

    const body = await res.json();
    expect(body.success).toBe(false);
    expect(body.error).toBeDefined();
  });

  it("queries CONFIRMED viewings with viewingDate in last 24h for no-show check", async () => {
    (prisma.propertyViewing.findMany as jest.Mock)
      .mockResolvedValueOnce([])
      .mockResolvedValueOnce([]);

    const req = new Request(url, {
      method: "GET",
      headers: { authorization: "Bearer test-cron-secret" },
    });

    await cronFollowUpsGET(req);

    const secondCall = (prisma.propertyViewing.findMany as jest.Mock).mock
      .calls[1][0];
    expect(secondCall.where.status).toBe("CONFIRMED");
    expect(secondCall.where.viewingDate).toBeDefined();
    expect(secondCall.where.viewingDate.lte).toBeInstanceOf(Date);
    expect(secondCall.where.viewingDate.gte).toBeInstanceOf(Date);
  });

  it("POST delegates to GET handler", async () => {
    (prisma.propertyViewing.findMany as jest.Mock)
      .mockResolvedValueOnce([])
      .mockResolvedValueOnce([]);

    const req = new Request(url, {
      method: "POST",
      headers: { authorization: "Bearer test-cron-secret" },
    });

    const res = await cronFollowUpsPOST(req);
    expect(res.status).toBe(200);

    const body = await res.json();
    expect(body.success).toBe(true);
    expect(body.followUps).toBeDefined();
    expect(body.noShows).toBeDefined();
  });

  it("POST also returns 401 for unauthorized requests", async () => {
    const req = new Request(url, { method: "POST" });

    const res = await cronFollowUpsPOST(req);
    expect(res.status).toBe(401);
  });

  it("no-show detection uses combineDateTime to check exact scheduled time", async () => {
    // A viewing scheduled exactly 2.5 hours ago should be marked as no-show
    const pastViewing = hoursAgo(2.5);
    const parts = toViewingParts(pastViewing);

    (prisma.propertyViewing.findMany as jest.Mock)
      .mockResolvedValueOnce([])
      .mockResolvedValueOnce([
        {
          id: "v-exact-noshow",
          viewingDate: parts.viewingDate,
          viewingTime: parts.viewingTime,
        },
      ]);

    (prisma.propertyViewing.update as jest.Mock).mockResolvedValue({});

    const req = new Request(url, {
      method: "GET",
      headers: { authorization: "Bearer test-cron-secret" },
    });

    const res = await cronFollowUpsGET(req);
    const body = await res.json();

    expect(body.noShows.detected).toBe(1);
  });

  it("no-show detection does not mark future viewings", async () => {
    // A viewing scheduled 1 hour from NOW should NOT be a no-show
    const futureViewing = hoursFromNow(1);
    const parts = toViewingParts(futureViewing);

    (prisma.propertyViewing.findMany as jest.Mock)
      .mockResolvedValueOnce([])
      .mockResolvedValueOnce([
        {
          id: "v-future",
          viewingDate: parts.viewingDate,
          viewingTime: parts.viewingTime,
        },
      ]);

    const req = new Request(url, {
      method: "GET",
      headers: { authorization: "Bearer test-cron-secret" },
    });

    const res = await cronFollowUpsGET(req);
    const body = await res.json();

    expect(body.noShows.detected).toBe(0);
    expect(prisma.propertyViewing.update).not.toHaveBeenCalled();
  });
});
