import { ConflictException } from "@nestjs/common";
import { BillingCycle, InvoiceItemType, Prisma } from "@prisma/client";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { InvoiceService } from "./invoice.service";

type AnyFn = ReturnType<typeof vi.fn>;
interface PrismaMock {
  merchant: { findMany: AnyFn };
  merchantInvoice: {
    findUnique: AnyFn;
    create: AnyFn;
    findMany: AnyFn;
    update: AnyFn;
  };
  merchantInvoiceItem: { createMany: AnyFn; findMany: AnyFn };
  merchantPricingPlan: { findFirst: AnyFn };
  paymentIntent: { aggregate: AnyFn };
  $transaction: AnyFn;
}

function makePrisma(): PrismaMock {
  const prisma: PrismaMock = {
    merchant: { findMany: vi.fn() },
    merchantInvoice: {
      findUnique: vi.fn(),
      create: vi.fn(),
      findMany: vi.fn(),
      update: vi.fn(),
    },
    merchantInvoiceItem: { createMany: vi.fn(), findMany: vi.fn() },
    merchantPricingPlan: { findFirst: vi.fn() },
    paymentIntent: { aggregate: vi.fn() },
    $transaction: vi.fn(async (cb: (tx: PrismaMock) => Promise<unknown>) => cb(prisma)),
  };
  return prisma;
}

function makePlan(overrides: Partial<Record<string, unknown>> = {}) {
  return {
    id: "plan_test",
    merchantId: "mer_test",
    name: "Plan Test",
    monthlyFee: new Prisma.Decimal("100000"),
    monthlyFeeCurrency: "PYG",
    txFeeFixed: new Prisma.Decimal("500"),
    txFeePercentage: new Prisma.Decimal("0.3"),
    txFeeCurrency: "PYG",
    includedTxPerMonth: null,
    overQuotaTxFee: null,
    billingCycle: BillingCycle.monthly,
    free: false,
    selfBillingEnabled: false,
    active: true,
    ...overrides,
  };
}

describe("InvoiceService.generate", () => {
  let prisma: PrismaMock;
  let service: InvoiceService;

  beforeEach(() => {
    prisma = makePrisma();
    service = new InvoiceService(prisma as unknown as never);

    prisma.merchant.findMany.mockResolvedValue([{ id: "mer_test" }]);
    prisma.merchantInvoice.findUnique.mockResolvedValue(null);
    prisma.merchantInvoice.create.mockImplementation(({ data }: { data: Record<string, unknown> }) =>
      Promise.resolve({
        ...data,
        createdAt: new Date(),
        updatedAt: new Date(),
        paidAt: null,
      }),
    );
    prisma.merchantInvoiceItem.createMany.mockResolvedValue({ count: 0 });
    prisma.merchantInvoiceItem.findMany.mockImplementation(() =>
      Promise.resolve([]),
    );
  });

  it("skipea si ya existe la factura del período (idempotente)", async () => {
    prisma.merchantInvoice.findUnique.mockResolvedValue({
      id: "inv_existing",
    });
    prisma.merchantPricingPlan.findFirst.mockResolvedValue(makePlan());

    const result = await service.generate({ period: "2026-05" });

    expect(result.generated).toHaveLength(0);
    expect(result.skipped).toHaveLength(1);
    expect(result.skipped[0].reason).toMatch(/ya existe/);
    expect(prisma.merchantInvoice.create).not.toHaveBeenCalled();
  });

  it("skipea si el comercio no tiene plan vigente", async () => {
    prisma.merchantPricingPlan.findFirst.mockResolvedValue(null);

    const result = await service.generate({ period: "2026-05" });

    expect(result.generated).toHaveLength(0);
    expect(result.skipped).toHaveLength(1);
    expect(result.skipped[0].reason).toMatch(/sin plan/);
  });

  it("con 0 tx, genera solo monthly_fee", async () => {
    prisma.merchantPricingPlan.findFirst.mockResolvedValue(makePlan());
    prisma.paymentIntent.aggregate.mockResolvedValue({
      _count: { _all: 0 },
      _sum: { amount: null },
    });
    let capturedItems: Array<Record<string, unknown>> = [];
    prisma.merchantInvoiceItem.createMany.mockImplementation(
      ({ data }: { data: Array<Record<string, unknown>> }) => {
        capturedItems = data;
        return Promise.resolve({ count: data.length });
      },
    );
    prisma.merchantInvoiceItem.findMany.mockImplementation(() =>
      Promise.resolve(capturedItems),
    );

    const result = await service.generate({ period: "2026-05" });

    expect(result.generated).toHaveLength(1);
    const inv = result.generated[0];
    expect(inv.txCount).toBe(0);
    expect(capturedItems).toHaveLength(1);
    expect(capturedItems[0].type).toBe(InvoiceItemType.monthly_fee);
    expect(inv.monthlyFeeAmount).toMatch(/^100000/);
    expect(inv.txFeesAmount).toMatch(/^0/);
  });

  it("aplica fee fijo + fee porcentual sobre tx aprobadas", async () => {
    prisma.merchantPricingPlan.findFirst.mockResolvedValue(makePlan());
    prisma.paymentIntent.aggregate.mockResolvedValue({
      _count: { _all: 10 },
      _sum: { amount: new Prisma.Decimal("1000000") },
    });
    let capturedItems: Array<Record<string, unknown>> = [];
    prisma.merchantInvoiceItem.createMany.mockImplementation(
      ({ data }: { data: Array<Record<string, unknown>> }) => {
        capturedItems = data;
        return Promise.resolve({ count: data.length });
      },
    );
    prisma.merchantInvoiceItem.findMany.mockImplementation(() =>
      Promise.resolve(capturedItems),
    );

    const result = await service.generate({ period: "2026-05" });

    expect(result.generated).toHaveLength(1);
    const types = capturedItems.map((i) => i.type);
    expect(types).toContain(InvoiceItemType.monthly_fee);
    expect(types).toContain(InvoiceItemType.tx_fee_fixed);
    expect(types).toContain(InvoiceItemType.tx_fee_percentage);

    // tx_fee_fixed = 500 * 10 = 5000
    const fixed = capturedItems.find((i) => i.type === InvoiceItemType.tx_fee_fixed);
    expect((fixed!.totalAmount as Prisma.Decimal).toString()).toBe("5000");

    // tx_fee_percentage = 1000000 * 0.3 / 100 = 3000
    const pct = capturedItems.find(
      (i) => i.type === InvoiceItemType.tx_fee_percentage,
    );
    expect((pct!.totalAmount as Prisma.Decimal).toString()).toBe("3000");
  });

  it("no cobra fixed por tx incluidas en la cuota y cobra over_quota por el exceso", async () => {
    prisma.merchantPricingPlan.findFirst.mockResolvedValue(
      makePlan({
        includedTxPerMonth: 5,
        overQuotaTxFee: new Prisma.Decimal("800"),
        txFeePercentage: new Prisma.Decimal("0"),
      }),
    );
    prisma.paymentIntent.aggregate.mockResolvedValue({
      _count: { _all: 8 },
      _sum: { amount: new Prisma.Decimal("400000") },
    });
    let capturedItems: Array<Record<string, unknown>> = [];
    prisma.merchantInvoiceItem.createMany.mockImplementation(
      ({ data }: { data: Array<Record<string, unknown>> }) => {
        capturedItems = data;
        return Promise.resolve({ count: data.length });
      },
    );
    prisma.merchantInvoiceItem.findMany.mockImplementation(() =>
      Promise.resolve(capturedItems),
    );

    await service.generate({ period: "2026-05" });

    const fixed = capturedItems.find(
      (i) => i.type === InvoiceItemType.tx_fee_fixed,
    );
    // 8 tx - 5 incluidas = 3 billable, * 500 = 1500
    expect((fixed!.totalAmount as Prisma.Decimal).toString()).toBe("1500");
    expect(fixed!.quantity).toBe(3);

    const over = capturedItems.find(
      (i) => i.type === InvoiceItemType.over_quota_fee,
    );
    // 3 tx over * 800 = 2400
    expect((over!.totalAmount as Prisma.Decimal).toString()).toBe("2400");
    expect(over!.quantity).toBe(3);
  });

  it("rechaza period con formato inválido", async () => {
    await expect(service.generate({ period: "2026/05" })).rejects.toBeInstanceOf(
      ConflictException,
    );
  });

  it("skipea si el plan es free", async () => {
    prisma.merchantPricingPlan.findFirst.mockResolvedValue(
      makePlan({ free: true }),
    );

    const result = await service.generate({ period: "2026-05" });

    expect(result.generated).toHaveLength(0);
    expect(result.skipped[0].reason).toMatch(/free/);
  });

  it("yearly: skipea en meses != diciembre, factura en diciembre con monthlyFee * 12", async () => {
    prisma.merchantPricingPlan.findFirst.mockResolvedValue(
      makePlan({ billingCycle: BillingCycle.yearly, txFeeFixed: new Prisma.Decimal("0") }),
    );
    prisma.paymentIntent.aggregate.mockResolvedValue({
      _count: { _all: 0 },
      _sum: { amount: null },
    });

    const skipResult = await service.generate({ period: "2026-05" });
    expect(skipResult.generated).toHaveLength(0);
    expect(skipResult.skipped[0].reason).toMatch(/yearly/);

    let capturedItems: Array<Record<string, unknown>> = [];
    prisma.merchantInvoiceItem.createMany.mockImplementation(
      ({ data }: { data: Array<Record<string, unknown>> }) => {
        capturedItems = data;
        return Promise.resolve({ count: data.length });
      },
    );
    prisma.merchantInvoiceItem.findMany.mockImplementation(() =>
      Promise.resolve(capturedItems),
    );

    const okResult = await service.generate({ period: "2026-12" });
    expect(okResult.generated).toHaveLength(1);
    const fee = capturedItems.find((i) => i.type === InvoiceItemType.monthly_fee);
    // 100000 * 12 = 1200000
    expect((fee!.totalAmount as Prisma.Decimal).toString()).toBe("1200000");
  });

  it("quarterly: solo factura en cierre de trimestre", async () => {
    prisma.merchantPricingPlan.findFirst.mockResolvedValue(
      makePlan({ billingCycle: BillingCycle.quarterly, txFeeFixed: new Prisma.Decimal("0") }),
    );
    prisma.paymentIntent.aggregate.mockResolvedValue({
      _count: { _all: 0 },
      _sum: { amount: null },
    });

    const may = await service.generate({ period: "2026-05" });
    expect(may.skipped[0].reason).toMatch(/quarterly/);

    let capturedItems: Array<Record<string, unknown>> = [];
    prisma.merchantInvoiceItem.createMany.mockImplementation(
      ({ data }: { data: Array<Record<string, unknown>> }) => {
        capturedItems = data;
        return Promise.resolve({ count: data.length });
      },
    );
    prisma.merchantInvoiceItem.findMany.mockImplementation(() =>
      Promise.resolve(capturedItems),
    );

    const june = await service.generate({ period: "2026-06" });
    expect(june.generated).toHaveLength(1);
    const fee = capturedItems.find((i) => i.type === InvoiceItemType.monthly_fee);
    // 100000 * 3 = 300000
    expect((fee!.totalAmount as Prisma.Decimal).toString()).toBe("300000");
  });
});

describe("InvoiceService.onIntentApproved", () => {
  let prisma: PrismaMock;
  let service: InvoiceService;

  beforeEach(() => {
    prisma = makePrisma();
    service = new InvoiceService(prisma as unknown as never);
  });

  it("ignora intents sin metadata de self_billing", async () => {
    await service.onIntentApproved("pi_x", { foo: "bar" });
    await service.onIntentApproved("pi_x", null);
    expect(prisma.merchantInvoice.findUnique).not.toHaveBeenCalled();
  });

  it("marca factura como paid cuando el intent es self_billing", async () => {
    prisma.merchantInvoice.findUnique.mockResolvedValue({
      id: "inv_1",
      status: "pending",
    });
    const updateSpy = vi.fn().mockResolvedValue({ id: "inv_1" });
    prisma.merchantInvoice.update = updateSpy;

    await service.onIntentApproved("pi_self", {
      kind: "self_billing",
      invoiceId: "inv_1",
    });

    expect(updateSpy).toHaveBeenCalledWith(
      expect.objectContaining({
        where: { id: "inv_1" },
        data: expect.objectContaining({
          status: "paid",
          selfBillingIntentId: "pi_self",
        }),
      }),
    );
  });

  it("es idempotente: si la factura ya está paid, no actualiza", async () => {
    prisma.merchantInvoice.findUnique.mockResolvedValue({
      id: "inv_1",
      status: "paid",
    });
    const updateSpy = vi.fn();
    prisma.merchantInvoice.update = updateSpy;

    await service.onIntentApproved("pi_self", {
      kind: "self_billing",
      invoiceId: "inv_1",
    });

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