import { describe, test, expect, mock, beforeEach } from "bun:test";
import { PaymentStatus } from "../src/generated/prisma/client";

const mockPrisma = {
  periodicExpense: {
    findMany: mock(),
    findUniqueOrThrow: mock(),
    create: mock(),
    update: mock(),
  },
  expense: {
    findMany: mock(),
    findFirst: mock(),
    create: mock(),
    update: mock(),
    count: mock(),
  },
};

mock.module("../src/lib/prisma", () => ({
  prisma: mockPrisma,
}));

beforeEach(() => {
  for (const model of Object.values(mockPrisma)) {
    for (const fn of Object.values(model as Record<string, ReturnType<typeof mock>>)) {
      fn.mockClear();
    }
  }
  mockPrisma.expense.count.mockResolvedValue(0);
});

const {
  createPeriodicExpenseSchema,
  updatePeriodicExpenseSchema,
  createNonPeriodicExpenseSchema,
  payExpenseSchema,
  buildDueDate,
  getCurrentYearMonthUTC,
  listPeriodicExpenses,
  createPeriodicExpense,
  updatePeriodicExpense,
  softDeletePeriodicExpense,
  listExpenses,
  createNonPeriodicExpense,
  payExpense,
  generateExpensesForCurrentMonth,
  generateMonthlyExpense,
} = await import("../src/modules/expenses/expenses.service");

describe("schemas", () => {
  describe("createPeriodicExpenseSchema", () => {
    test("accepts valid input", () => {
      const result = createPeriodicExpenseSchema.parse({
        description: "Gimnasio",
        defaultDueDay: 10,
        defaultAmount: 1500.50,
        periods: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12],
      });
      expect(result.description).toBe("Gimnasio");
    });

    test("rejects empty description", () => {
      expect(() =>
        createPeriodicExpenseSchema.parse({
          description: "",
          defaultDueDay: 10,
          defaultAmount: 100,
          periods: [1],
        })
      ).toThrow();
    });

    test("rejects description over 50 chars", () => {
      expect(() =>
        createPeriodicExpenseSchema.parse({
          description: "a".repeat(51),
          defaultDueDay: 10,
          defaultAmount: 100,
          periods: [1],
        })
      ).toThrow();
    });

    test("rejects due day < 1", () => {
      expect(() =>
        createPeriodicExpenseSchema.parse({
          description: "Test",
          defaultDueDay: 0,
          defaultAmount: 100,
          periods: [1],
        })
      ).toThrow();
    });

    test("rejects due day > 31", () => {
      expect(() =>
        createPeriodicExpenseSchema.parse({
          description: "Test",
          defaultDueDay: 32,
          defaultAmount: 100,
          periods: [1],
        })
      ).toThrow();
    });

    test("rejects non-positive amount", () => {
      expect(() =>
        createPeriodicExpenseSchema.parse({
          description: "Test",
          defaultDueDay: 15,
          defaultAmount: 0,
          periods: [1],
        })
      ).toThrow();
    });

    test("rejects empty periods", () => {
      expect(() =>
        createPeriodicExpenseSchema.parse({
          description: "Test",
          defaultDueDay: 15,
          defaultAmount: 100,
          periods: [],
        })
      ).toThrow();
    });

    test("rejects invalid period month", () => {
      expect(() =>
        createPeriodicExpenseSchema.parse({
          description: "Test",
          defaultDueDay: 15,
          defaultAmount: 100,
          periods: [0, 13],
        })
      ).toThrow();
    });
  });

  describe("createNonPeriodicExpenseSchema", () => {
    test("accepts valid input", () => {
      const result = createNonPeriodicExpenseSchema.parse({
        description: "Ropa",
        amount: 2500,
        dueDate: "2026-06-15T00:00:00.000Z",
      });
      expect(result.description).toBe("Ropa");
    });

    test("rejects negative amount", () => {
      expect(() =>
        createNonPeriodicExpenseSchema.parse({
          description: "Ropa",
          amount: -100,
          dueDate: "2026-06-15T00:00:00.000Z",
        })
      ).toThrow();
    });
  });

  describe("payExpenseSchema", () => {
    test("accepts valid payment", () => {
      const result = payExpenseSchema.parse({
        amountPayed: 1400,
        paymentDate: "2026-06-10T00:00:00.000Z",
      });
      expect(result.amountPayed).toBe(1400);
    });

    test("allows missing paymentDate", () => {
      const result = payExpenseSchema.parse({ amountPayed: 1400 });
      expect(result.amountPayed).toBe(1400);
      expect(result.paymentDate).toBeUndefined();
    });

    test("rejects zero amountPayed", () => {
      expect(() =>
        payExpenseSchema.parse({ amountPayed: 0 })
      ).toThrow();
    });
  });
});

describe("buildDueDate", () => {
  test("creates valid date", () => {
    const date = buildDueDate(2026, 6, 15);
    expect(date.getUTCFullYear()).toBe(2026);
    expect(date.getUTCMonth()).toBe(5); // 0-indexed
    expect(date.getUTCDate()).toBe(15);
  });

  test("clamps day to last day of month when dueDay exceeds month length", () => {
    const date = buildDueDate(2026, 2, 31);
    expect(date.getUTCMonth()).toBe(1); // February (0-indexed)
    expect(date.getUTCDate()).toBe(28);
  });

  test("handles december correctly", () => {
    const date = buildDueDate(2026, 12, 31);
    expect(date.getUTCFullYear()).toBe(2026);
    expect(date.getUTCMonth()).toBe(11);
    expect(date.getUTCDate()).toBe(31);
  });

  test("handles leap year february", () => {
    const date = buildDueDate(2024, 2, 29);
    expect(date.getUTCFullYear()).toBe(2024);
    expect(date.getUTCMonth()).toBe(1);
    expect(date.getUTCDate()).toBe(29);
  });
});

describe("getCurrentYearMonthUTC", () => {
  test("returns valid year and month", () => {
    const { year, month } = getCurrentYearMonthUTC();
    expect(year).toBeGreaterThan(2020);
    expect(month).toBeGreaterThanOrEqual(1);
    expect(month).toBeLessThanOrEqual(12);
  });
});

describe("listPeriodicExpenses", () => {
  test("returns only non-deleted expenses ordered by createdAt desc", async () => {
    const mockItems = [
      { id: 1, description: "Gas", isDeleted: false, createdAt: new Date() },
    ];
    mockPrisma.periodicExpense.findMany.mockResolvedValueOnce(mockItems);

    const result = await listPeriodicExpenses();
    expect(result).toEqual(mockItems);
    expect(mockPrisma.periodicExpense.findMany).toHaveBeenCalledWith({
      where: { isDeleted: false },
      orderBy: { description: "asc" },
    });
  });
});

describe("createPeriodicExpense", () => {
  test("creates and detects current month applicability", async () => {
    const input = {
      description: "Gimnasio",
      defaultDueDay: 10,
      defaultAmount: 1500,
      periods: [6],
    };
    const created = { id: 1, ...input, isDeleted: false, createdAt: new Date() };
    mockPrisma.periodicExpense.create.mockResolvedValueOnce(created);

    const result = await createPeriodicExpense(input);
    expect(result.id).toBe(1);
    // currentMonthApplicable depends on actual month; just verify truthy/falsy
    expect("currentMonthApplicable" in result).toBe(true);
  });
});

describe("updatePeriodicExpense", () => {
  test("updates and returns the record", async () => {
    const input = {
      description: "Gas actualizado",
      defaultDueDay: 5,
      defaultAmount: 2000,
      periods: [1, 7],
    };
    const updated = { id: 1, ...input, isDeleted: false };
    mockPrisma.periodicExpense.update.mockResolvedValueOnce(updated);

    const result = await updatePeriodicExpense(1, input);
    expect(result.description).toBe("Gas actualizado");
    expect(mockPrisma.periodicExpense.update).toHaveBeenCalledWith({
      where: { id: 1 },
      data: input,
    });
  });
});

describe("softDeletePeriodicExpense", () => {
  test("sets isDeleted to true", async () => {
    const deleted = { id: 1, isDeleted: true };
    mockPrisma.periodicExpense.update.mockResolvedValueOnce(deleted);

    const result = await softDeletePeriodicExpense(1);
    expect(result.isDeleted).toBe(true);
    expect(mockPrisma.periodicExpense.update).toHaveBeenCalledWith({
      where: { id: 1 },
      data: { isDeleted: true },
    });
  });
});

describe("listExpenses", () => {
  test("returns all expenses when no status filter", async () => {
    mockPrisma.expense.findMany.mockResolvedValueOnce([]);
    await listExpenses({});
    expect(mockPrisma.expense.findMany).toHaveBeenCalledWith({
      where: {},
      include: { periodicExpense: true },
      orderBy: { dueDate: "desc" },
      skip: 0,
      take: 10,
    });
  });

  test("filters by status when provided", async () => {
    mockPrisma.expense.findMany.mockResolvedValueOnce([]);
    await listExpenses({ status: "PENDING" });
    expect(mockPrisma.expense.findMany).toHaveBeenCalledWith({
      where: { status: "PENDING" },
      include: { periodicExpense: true },
      orderBy: { dueDate: "desc" },
      skip: 0,
      take: 10,
    });
  });

  test("filters by periodicExpenseId when provided", async () => {
    mockPrisma.expense.findMany.mockResolvedValueOnce([]);
    await listExpenses({ periodicExpenseId: 3 });
    expect(mockPrisma.expense.findMany).toHaveBeenCalledWith({
      where: { periodicExpenseId: 3 },
      include: { periodicExpense: true },
      orderBy: { dueDate: "desc" },
      skip: 0,
      take: 10,
    });
  });

  test("filters by search query with case-insensitive contains", async () => {
    mockPrisma.expense.findMany.mockResolvedValueOnce([]);
    await listExpenses({ search: "Gim" });
    expect(mockPrisma.expense.findMany).toHaveBeenCalledWith({
      where: { description: { contains: "Gim", mode: "insensitive" } },
      include: { periodicExpense: true },
      orderBy: { dueDate: "desc" },
      skip: 0,
      take: 10,
    });
  });

  test("combines status, periodicExpenseId, and search filters", async () => {
    mockPrisma.expense.findMany.mockResolvedValueOnce([]);
    await listExpenses({ status: "PENDING", periodicExpenseId: 1, search: "Agua" });
    expect(mockPrisma.expense.findMany).toHaveBeenCalledWith({
      where: {
        status: "PENDING",
        periodicExpenseId: 1,
        description: { contains: "Agua", mode: "insensitive" },
      },
      include: { periodicExpense: true },
      orderBy: { dueDate: "desc" },
      skip: 0,
      take: 10,
    });
  });
});

describe("createNonPeriodicExpense", () => {
  test("creates expense with PAYED status and sets amountPayed", async () => {
    const input = {
      description: "Ropa",
      amount: 2500,
      dueDate: "2026-06-15T00:00:00.000Z",
    };
    const created = {
      id: 1,
      description: "Ropa",
      amount: 2500,
      amountPayed: 2500,
      status: PaymentStatus.PAYED,
      year: 2026,
      month: 6,
    };
    mockPrisma.expense.create.mockResolvedValueOnce(created);

    const result = await createNonPeriodicExpense(input);
    expect(result.status).toBe(PaymentStatus.PAYED);
    expect(result.amountPayed).toBe(2500);
    expect(mockPrisma.expense.create).toHaveBeenCalledWith({
      data: expect.objectContaining({
        description: "Ropa",
        amount: 2500,
        amountPayed: 2500,
        status: PaymentStatus.PAYED,
        paymentDate: expect.any(Date),
      }),
    });
  });
});

describe("payExpense", () => {
  test("updates status to PAYED with amountPayed and paymentDate", async () => {
    const paidExpense = {
      id: 1,
      status: PaymentStatus.PAYED,
      amountPayed: 1400,
      paymentDate: new Date("2026-06-10T00:00:00.000Z"),
    };
    mockPrisma.expense.update.mockResolvedValueOnce(paidExpense);

    const result = await payExpense(1, {
      amountPayed: 1400,
      paymentDate: "2026-06-10T00:00:00.000Z",
    });
    expect(result.status).toBe(PaymentStatus.PAYED);
    expect(result.amountPayed).toBe(1400);
  });

  test("defaults paymentDate to now when not provided", async () => {
    mockPrisma.expense.update.mockImplementationOnce(async ({ where, data }) => ({
      id: where.id,
      ...data,
      status: PaymentStatus.PAYED,
      amountPayed: data.amountPayed,
      paymentDate: data.paymentDate,
    }));

    const result = await payExpense(1, { amountPayed: 1500 });
    expect(result.status).toBe(PaymentStatus.PAYED);
    expect(result.paymentDate).toBeInstanceOf(Date);
  });
});

describe("generateExpensesForCurrentMonth", () => {
  test("skips existing expenses to avoid duplicates", async () => {
    const { month } = getCurrentYearMonthUTC();
    mockPrisma.periodicExpense.findMany.mockResolvedValueOnce([
      { id: 1, description: "Gas", defaultDueDay: 15, defaultAmount: 2000, periods: [month] },
    ]);
    mockPrisma.expense.findFirst.mockResolvedValueOnce({ id: 99 });

    const results = await generateExpensesForCurrentMonth();
    expect(results).toHaveLength(1);
    expect(results[0].status).toBe("already_exists");
    expect(mockPrisma.expense.create).not.toHaveBeenCalled();
  });

  test("creates expenses for applicable periodic expenses", async () => {
    const { month } = getCurrentYearMonthUTC();
    const periodic = {
      id: 1,
      description: "Gas",
      defaultDueDay: 15,
      defaultAmount: 2000,
      periods: [month],
    };
    mockPrisma.periodicExpense.findMany.mockResolvedValueOnce([periodic]);
    mockPrisma.expense.findFirst.mockResolvedValueOnce(null);
    mockPrisma.expense.create.mockResolvedValueOnce({
      id: 1,
      description: "Gas",
      periodicExpenseId: 1,
      amount: 2000,
      status: "PENDING",
    });

    const results = await generateExpensesForCurrentMonth();
    expect(results).toHaveLength(1);
    expect(results[0].status).toBe("created");
  });

  test("ignores deleted periodic expenses", async () => {
    mockPrisma.periodicExpense.findMany.mockResolvedValueOnce([]);

    const results = await generateExpensesForCurrentMonth();
    expect(results).toHaveLength(0);
    expect(mockPrisma.periodicExpense.findMany).toHaveBeenCalledWith({
      where: { isDeleted: false, periods: { has: expect.any(Number) } },
    });
  });
});

describe("generateMonthlyExpense", () => {
  test("creates expense for given periodic expense", async () => {
    const periodic = { id: 1, description: "Gas", defaultDueDay: 15, defaultAmount: 2000, periods: [6] };
    mockPrisma.periodicExpense.findUniqueOrThrow.mockResolvedValueOnce(periodic);
    mockPrisma.expense.findFirst.mockResolvedValueOnce(null);
    mockPrisma.expense.create.mockResolvedValueOnce({
      id: 1, description: "Gas", periodicExpenseId: 1, amount: 2000, status: "PENDING",
    });

    const result = await generateMonthlyExpense(1);
    expect(result.id).toBe(1);
  });

  test("returns existing expense instead of duplicating", async () => {
    const existing = { id: 99, description: "Gas", periodicExpenseId: 1 };
    mockPrisma.periodicExpense.findUniqueOrThrow.mockResolvedValueOnce({
      id: 1, description: "Gas", defaultDueDay: 15, defaultAmount: 2000, periods: [6],
    });
    mockPrisma.expense.findFirst.mockResolvedValueOnce(existing);

    const result = await generateMonthlyExpense(1);
    expect(result.id).toBe(99);
    expect(result).toEqual(existing);
  });
});
