/**
 * Property Update Triggers Service Tests
 *
 * Tests handlePriceChange, handleStatusChange, and handleNewListing
 * from property-update-triggers.service.ts.
 *
 * All external dependencies are mocked: prisma, logger, errors, notifications, cuid2.
 */

// ── Mocks (must be before imports) ─────────────────────────────────────────

jest.mock("@/lib/db");

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

jest.mock("@/lib/errors", () => ({
  getErrorMessage: (e: unknown) =>
    e instanceof Error ? e.message : "Unknown error",
}));

jest.mock("@/lib/services/real-estate/notification.service", () => ({
  sendPriceDropAlert: jest.fn().mockResolvedValue(undefined),
  sendStatusChangeAlert: jest.fn().mockResolvedValue(undefined),
  sendNewMatchNotification: jest.fn().mockResolvedValue(undefined),
}));

jest.mock("@paralleldrive/cuid2", () => ({
  createId: jest.fn(() => "mock-cuid-1"),
}));

// ── Imports ────────────────────────────────────────────────────────────────

import { prisma } from "@/lib/db";
import {
  sendPriceDropAlert,
  sendStatusChangeAlert,
  sendNewMatchNotification,
} from "@/lib/services/real-estate/notification.service";
import {
  handlePriceChange,
  handleStatusChange,
  handleNewListing,
} from "@/lib/services/real-estate/property-update-triggers.service";

// ── Typed mocks ────────────────────────────────────────────────────────────

const mockPrisma = prisma as jest.Mocked<typeof prisma>;
const mockSendPriceDrop = sendPriceDropAlert as jest.MockedFunction<
  typeof sendPriceDropAlert
>;
const mockSendStatusChange = sendStatusChangeAlert as jest.MockedFunction<
  typeof sendStatusChangeAlert
>;
const mockSendNewMatch = sendNewMatchNotification as jest.MockedFunction<
  typeof sendNewMatchNotification
>;

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

beforeEach(() => {
  jest.clearAllMocks();
});

// ═══════════════════════════════════════════════════════════════════════════
// 1. handlePriceChange
// ═══════════════════════════════════════════════════════════════════════════

describe("handlePriceChange", () => {
  it("should send price drop alert when new price is lower than old price", async () => {
    await handlePriceChange("prop-1", 1000000, 850000);

    expect(mockSendPriceDrop).toHaveBeenCalledTimes(1);
    expect(mockSendPriceDrop).toHaveBeenCalledWith("prop-1", 1000000, 850000);
  });

  it("should NOT send alert when new price equals old price", async () => {
    await handlePriceChange("prop-1", 500000, 500000);

    expect(mockSendPriceDrop).not.toHaveBeenCalled();
  });

  it("should NOT send alert when new price is higher (price increase)", async () => {
    await handlePriceChange("prop-1", 500000, 600000);

    expect(mockSendPriceDrop).not.toHaveBeenCalled();
  });

  it("should never throw, even if sendPriceDropAlert rejects", async () => {
    mockSendPriceDrop.mockRejectedValueOnce(new Error("WhatsApp API down"));

    // Should not throw
    await expect(
      handlePriceChange("prop-1", 1000000, 800000),
    ).resolves.toBeUndefined();
  });

  it("should not throw when called with zero prices", async () => {
    await expect(handlePriceChange("prop-1", 0, 0)).resolves.toBeUndefined();
    expect(mockSendPriceDrop).not.toHaveBeenCalled();
  });

  it("should handle marginal price drops (e.g. 1 unit)", async () => {
    await handlePriceChange("prop-1", 100, 99);

    expect(mockSendPriceDrop).toHaveBeenCalledWith("prop-1", 100, 99);
  });
});

// ═══════════════════════════════════════════════════════════════════════════
// 2. handleStatusChange
// ═══════════════════════════════════════════════════════════════════════════

describe("handleStatusChange", () => {
  describe("off-market transitions (SOLD, RENTED, OFF_MARKET)", () => {
    it.each(["SOLD", "RENTED", "OFF_MARKET"])(
      "should cancel pending viewings when status changes to %s",
      async (newStatus) => {
        (mockPrisma.propertyViewing.updateMany as jest.Mock).mockResolvedValue({
          count: 3,
        });

        await handleStatusChange("prop-1", "AVAILABLE", newStatus, "user-1");

        expect(mockPrisma.propertyViewing.updateMany).toHaveBeenCalledWith({
          where: {
            propertyId: "prop-1",
            status: { in: ["REQUESTED", "CONFIRMED"] },
          },
          data: {
            status: "CANCELLED",
          },
        });
      },
    );

    it.each(["SOLD", "RENTED", "OFF_MARKET"])(
      "should send status change alert when status changes to %s",
      async (newStatus) => {
        (mockPrisma.propertyViewing.updateMany as jest.Mock).mockResolvedValue({
          count: 0,
        });

        await handleStatusChange("prop-1", "AVAILABLE", newStatus, "user-1");

        expect(mockSendStatusChange).toHaveBeenCalledWith("prop-1", newStatus);
      },
    );

    it("should handle zero cancelled viewings gracefully", async () => {
      (mockPrisma.propertyViewing.updateMany as jest.Mock).mockResolvedValue({
        count: 0,
      });

      await expect(
        handleStatusChange("prop-1", "AVAILABLE", "SOLD", "user-1"),
      ).resolves.toBeUndefined();

      expect(mockPrisma.propertyViewing.updateMany).toHaveBeenCalled();
      expect(mockSendStatusChange).toHaveBeenCalled();
    });
  });

  describe("re-listing to AVAILABLE", () => {
    it("should notify previously interested clients when re-listed", async () => {
      (mockPrisma.propertyMatch.findMany as jest.Mock).mockResolvedValue([
        { requirementId: "req-1" },
        { requirementId: "req-2" },
        { requirementId: "req-1" }, // duplicate requirement
      ]);

      await handleStatusChange("prop-1", "SOLD", "AVAILABLE", "user-1");

      // Should deduplicate requirement IDs
      expect(mockSendNewMatch).toHaveBeenCalledTimes(2);
      expect(mockSendNewMatch).toHaveBeenCalledWith("req-1", ["prop-1"]);
      expect(mockSendNewMatch).toHaveBeenCalledWith("req-2", ["prop-1"]);
    });

    it("should query matches with correct statuses for re-listing", async () => {
      (mockPrisma.propertyMatch.findMany as jest.Mock).mockResolvedValue([]);

      await handleStatusChange("prop-1", "OFF_MARKET", "AVAILABLE", "user-1");

      expect(mockPrisma.propertyMatch.findMany).toHaveBeenCalledWith({
        where: {
          propertyId: "prop-1",
          status: { in: ["interested", "viewed", "sent"] },
        },
        select: {
          requirementId: true,
        },
      });
    });

    it("should not send notifications when no previously interested clients exist", async () => {
      (mockPrisma.propertyMatch.findMany as jest.Mock).mockResolvedValue([]);

      await handleStatusChange("prop-1", "SOLD", "AVAILABLE", "user-1");

      expect(mockSendNewMatch).not.toHaveBeenCalled();
    });

    it("should NOT cancel viewings when re-listing to AVAILABLE", async () => {
      (mockPrisma.propertyMatch.findMany as jest.Mock).mockResolvedValue([]);

      await handleStatusChange("prop-1", "SOLD", "AVAILABLE", "user-1");

      expect(mockPrisma.propertyViewing.updateMany).not.toHaveBeenCalled();
    });
  });

  describe("same-status transitions", () => {
    it("should not re-list notify for AVAILABLE -> AVAILABLE", async () => {
      await handleStatusChange("prop-1", "AVAILABLE", "AVAILABLE", "user-1");

      // isRelisted = newStatus === 'AVAILABLE' && oldStatus !== 'AVAILABLE' => false
      expect(mockPrisma.propertyMatch.findMany).not.toHaveBeenCalled();
      expect(mockSendNewMatch).not.toHaveBeenCalled();
    });
  });

  describe("error handling", () => {
    it("should never throw, even if prisma rejects", async () => {
      (mockPrisma.propertyViewing.updateMany as jest.Mock).mockRejectedValue(
        new Error("Database connection lost"),
      );

      await expect(
        handleStatusChange("prop-1", "AVAILABLE", "SOLD", "user-1"),
      ).resolves.toBeUndefined();
    });

    it("should never throw, even if notification service rejects", async () => {
      (mockPrisma.propertyViewing.updateMany as jest.Mock).mockResolvedValue({
        count: 0,
      });
      mockSendStatusChange.mockRejectedValueOnce(
        new Error("Notification failed"),
      );

      await expect(
        handleStatusChange("prop-1", "AVAILABLE", "SOLD", "user-1"),
      ).resolves.toBeUndefined();
    });
  });
});

// ═══════════════════════════════════════════════════════════════════════════
// 3. handleNewListing
// ═══════════════════════════════════════════════════════════════════════════

describe("handleNewListing", () => {
  const makeProperty = (overrides = {}) => ({
    id: "prop-new",
    price: 800000,
    bedrooms: 3,
    district: "The Pearl",
    city: "Doha",
    amenities: ["pool", "gym", "parking"],
    propertyType: "APARTMENT",
    ...overrides,
  });

  const makeRequirement = (
    id: string,
    overrides: Record<string, unknown> = {},
  ) => ({
    id,
    userId: "user-1",
    isActive: true,
    clientName: `Client for ${id}`,
    clientPhone: "+974 5555 0000",
    clientEmail: null,
    minBudget: 500000,
    maxBudget: 1000000,
    minBedrooms: 2,
    maxBedrooms: 4,
    preferredAreas: ["The Pearl"],
    mustHaves: ["pool", "gym"],
    propertyTypes: ["APARTMENT"],
    ...overrides,
  });

  it("should find property and match against active requirements", async () => {
    const property = makeProperty();
    const requirement = makeRequirement("req-1");

    (mockPrisma.property.findUnique as jest.Mock).mockResolvedValue(property);
    (mockPrisma.clientRequirements.findMany as jest.Mock).mockResolvedValue([
      requirement,
    ]);
    (mockPrisma.propertyMatch.upsert as jest.Mock).mockResolvedValue({});

    await handleNewListing("prop-new", "user-1");

    expect(mockPrisma.property.findUnique).toHaveBeenCalledWith({
      where: { id: "prop-new" },
    });
    expect(mockPrisma.clientRequirements.findMany).toHaveBeenCalledWith({
      where: {
        userId: "user-1",
        isActive: true,
      },
    });
  });

  it("should create PropertyMatch for requirements with score > 60", async () => {
    // Property perfectly matches requirement => score should be 100
    const property = makeProperty();
    const requirement = makeRequirement("req-1");

    (mockPrisma.property.findUnique as jest.Mock).mockResolvedValue(property);
    (mockPrisma.clientRequirements.findMany as jest.Mock).mockResolvedValue([
      requirement,
    ]);
    (mockPrisma.propertyMatch.upsert as jest.Mock).mockResolvedValue({});

    await handleNewListing("prop-new", "user-1");

    expect(mockPrisma.propertyMatch.upsert).toHaveBeenCalledTimes(1);
    expect(mockPrisma.propertyMatch.upsert).toHaveBeenCalledWith(
      expect.objectContaining({
        where: {
          propertyId_requirementId: {
            propertyId: "prop-new",
            requirementId: "req-1",
          },
        },
        create: expect.objectContaining({
          id: "mock-cuid-1",
          propertyId: "prop-new",
          requirementId: "req-1",
          status: "new",
        }),
      }),
    );
  });

  it("should send notification for each match with score > 60", async () => {
    const property = makeProperty();
    const requirement = makeRequirement("req-1");

    (mockPrisma.property.findUnique as jest.Mock).mockResolvedValue(property);
    (mockPrisma.clientRequirements.findMany as jest.Mock).mockResolvedValue([
      requirement,
    ]);
    (mockPrisma.propertyMatch.upsert as jest.Mock).mockResolvedValue({});

    await handleNewListing("prop-new", "user-1");

    expect(mockSendNewMatch).toHaveBeenCalledWith("req-1", ["prop-new"]);
  });

  it("should NOT create match for requirements with score <= 60", async () => {
    // Property that barely matches: wrong area, wrong type, wrong price
    const property = makeProperty({
      price: 2000000, // Way above budget
      bedrooms: 1, // Below min
      district: "Al Khor", // Not preferred
      city: "Al Khor", // Not even same city
      amenities: [], // No matching amenities
      propertyType: "VILLA", // Wrong type
    });

    const requirement = makeRequirement("req-low", {
      maxBudget: 500000,
      minBedrooms: 3,
      maxBedrooms: 4,
      preferredAreas: ["West Bay"],
      mustHaves: ["pool", "gym", "sauna"],
      propertyTypes: ["APARTMENT"],
    });

    (mockPrisma.property.findUnique as jest.Mock).mockResolvedValue(property);
    (mockPrisma.clientRequirements.findMany as jest.Mock).mockResolvedValue([
      requirement,
    ]);

    await handleNewListing("prop-new", "user-1");

    expect(mockPrisma.propertyMatch.upsert).not.toHaveBeenCalled();
    expect(mockSendNewMatch).not.toHaveBeenCalled();
  });

  it("should handle no matching requirements gracefully", async () => {
    const property = makeProperty();

    (mockPrisma.property.findUnique as jest.Mock).mockResolvedValue(property);
    (mockPrisma.clientRequirements.findMany as jest.Mock).mockResolvedValue([]);

    await expect(
      handleNewListing("prop-new", "user-1"),
    ).resolves.toBeUndefined();

    expect(mockPrisma.propertyMatch.upsert).not.toHaveBeenCalled();
    expect(mockSendNewMatch).not.toHaveBeenCalled();
  });

  it("should return early when property is not found", async () => {
    (mockPrisma.property.findUnique as jest.Mock).mockResolvedValue(null);

    await expect(
      handleNewListing("nonexistent", "user-1"),
    ).resolves.toBeUndefined();

    expect(mockPrisma.clientRequirements.findMany).not.toHaveBeenCalled();
    expect(mockPrisma.propertyMatch.upsert).not.toHaveBeenCalled();
  });

  it("should process multiple requirements and only match qualifying ones", async () => {
    const property = makeProperty();

    // req-1: perfect match (score = 100)
    const reqGood = makeRequirement("req-good");

    // req-bad: nothing matches (score ~0)
    const reqBad = makeRequirement("req-bad", {
      maxBudget: 100000, // Way below
      minBedrooms: 6, // Too many
      maxBedrooms: 8,
      preferredAreas: ["Al Khor"],
      mustHaves: ["helipad"],
      propertyTypes: ["LAND"],
    });

    (mockPrisma.property.findUnique as jest.Mock).mockResolvedValue(property);
    (mockPrisma.clientRequirements.findMany as jest.Mock).mockResolvedValue([
      reqGood,
      reqBad,
    ]);
    (mockPrisma.propertyMatch.upsert as jest.Mock).mockResolvedValue({});

    await handleNewListing("prop-new", "user-1");

    // Only one match should be created (the good one)
    expect(mockPrisma.propertyMatch.upsert).toHaveBeenCalledTimes(1);
    expect(mockPrisma.propertyMatch.upsert).toHaveBeenCalledWith(
      expect.objectContaining({
        where: {
          propertyId_requirementId: {
            propertyId: "prop-new",
            requirementId: "req-good",
          },
        },
      }),
    );
    expect(mockSendNewMatch).toHaveBeenCalledTimes(1);
    expect(mockSendNewMatch).toHaveBeenCalledWith("req-good", ["prop-new"]);
  });

  describe("scoring logic", () => {
    it("should give full score when requirement has no constraints", async () => {
      const property = makeProperty();
      const openReq = makeRequirement("req-open", {
        minBudget: null,
        maxBudget: null,
        minBedrooms: null,
        maxBedrooms: null,
        preferredAreas: [],
        mustHaves: [],
        propertyTypes: [],
      });

      (mockPrisma.property.findUnique as jest.Mock).mockResolvedValue(property);
      (mockPrisma.clientRequirements.findMany as jest.Mock).mockResolvedValue([
        openReq,
      ]);
      (mockPrisma.propertyMatch.upsert as jest.Mock).mockResolvedValue({});

      await handleNewListing("prop-new", "user-1");

      // All unconstrained => full 100 score => match created
      expect(mockPrisma.propertyMatch.upsert).toHaveBeenCalledTimes(1);
      const call = (mockPrisma.propertyMatch.upsert as jest.Mock).mock
        .calls[0][0];
      expect(call.create.matchScore).toBe(100);
    });

    it("should give partial area score for city match but not district", async () => {
      const property = makeProperty({ district: "West Bay", city: "Doha" });
      const requirement = makeRequirement("req-city", {
        preferredAreas: ["Doha"], // City matches, district does not
        minBudget: null,
        maxBudget: null,
        minBedrooms: null,
        maxBedrooms: null,
        mustHaves: [],
        propertyTypes: [],
      });

      (mockPrisma.property.findUnique as jest.Mock).mockResolvedValue(property);
      (mockPrisma.clientRequirements.findMany as jest.Mock).mockResolvedValue([
        requirement,
      ]);
      (mockPrisma.propertyMatch.upsert as jest.Mock).mockResolvedValue({});

      await handleNewListing("prop-new", "user-1");

      // City match gives 40% of AREA weight (25) = 10, plus full other scores
      // Total = 30 + 20 + 10 + 15 + 10 = 85 out of 100 => score 85 => above threshold
      expect(mockPrisma.propertyMatch.upsert).toHaveBeenCalledTimes(1);
      const call = (mockPrisma.propertyMatch.upsert as jest.Mock).mock
        .calls[0][0];
      expect(call.create.matchScore).toBe(85);
    });
  });

  describe("error handling", () => {
    it("should never throw, even if prisma.property.findUnique rejects", async () => {
      (mockPrisma.property.findUnique as jest.Mock).mockRejectedValue(
        new Error("DB timeout"),
      );

      await expect(
        handleNewListing("prop-new", "user-1"),
      ).resolves.toBeUndefined();
    });

    it("should never throw, even if upsert fails mid-loop", async () => {
      const property = makeProperty();
      const requirement = makeRequirement("req-1");

      (mockPrisma.property.findUnique as jest.Mock).mockResolvedValue(property);
      (mockPrisma.clientRequirements.findMany as jest.Mock).mockResolvedValue([
        requirement,
      ]);
      (mockPrisma.propertyMatch.upsert as jest.Mock).mockRejectedValue(
        new Error("Unique constraint violation"),
      );

      await expect(
        handleNewListing("prop-new", "user-1"),
      ).resolves.toBeUndefined();
    });

    it("should never throw, even if notification fails", async () => {
      const property = makeProperty();
      const requirement = makeRequirement("req-1");

      (mockPrisma.property.findUnique as jest.Mock).mockResolvedValue(property);
      (mockPrisma.clientRequirements.findMany as jest.Mock).mockResolvedValue([
        requirement,
      ]);
      (mockPrisma.propertyMatch.upsert as jest.Mock).mockResolvedValue({});
      mockSendNewMatch.mockRejectedValueOnce(
        new Error("Notification API error"),
      );

      await expect(
        handleNewListing("prop-new", "user-1"),
      ).resolves.toBeUndefined();
    });
  });
});
