import {
  ConflictException,
  Injectable,
  Logger,
  NotFoundException,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import {
  BillingCycle,
  InvoiceItemType,
  InvoiceStatus,
  PaymentIntentStatus,
  Prisma,
  type MerchantInvoice,
  type MerchantInvoiceItem,
  type MerchantPricingPlan,
} from "@prisma/client";
import { newId } from "../common/ids/id.util";
import { PrismaService } from "../prisma/prisma.service";
import type { InvoiceDto } from "./dto/invoice.dto";

const MONTHS_IN_CYCLE: Record<BillingCycle, number> = {
  monthly: 1,
  quarterly: 3,
  yearly: 12,
};

interface GenerateOptions {
  /** YYYY-MM. Si se omite, se factura el mes anterior al actual (UTC). */
  period?: string;
  merchantId?: string;
}

export interface GenerationResult {
  generated: InvoiceDto[];
  skipped: { merchantId: string; reason: string }[];
}

@Injectable()
export class InvoiceService {
  private readonly logger = new Logger(InvoiceService.name);

  constructor(
    private readonly prisma: PrismaService,
    private readonly config?: ConfigService,
  ) {}

  /**
   * Genera la factura del período para uno o todos los comercios activos con
   * plan vigente. Idempotente: si ya existe `(merchantId, period)`, lo skipea.
   */
  async generate(opts: GenerateOptions = {}): Promise<GenerationResult> {
    const period = opts.period ?? this.previousPeriod();
    if (!/^\d{4}-(0[1-9]|1[0-2])$/.test(period)) {
      throw new ConflictException("period debe tener formato YYYY-MM");
    }

    const merchants = await this.prisma.merchant.findMany({
      where: {
        status: "active",
        ...(opts.merchantId ? { id: opts.merchantId } : {}),
      },
      select: { id: true },
    });

    const generated: InvoiceDto[] = [];
    const skipped: { merchantId: string; reason: string }[] = [];

    for (const m of merchants) {
      try {
        const invoice = await this.generateForMerchant(m.id, period);
        if (invoice) generated.push(invoice);
        else skipped.push({ merchantId: m.id, reason: "sin plan vigente o sin actividad facturable" });
      } catch (err) {
        if (err instanceof ConflictException) {
          skipped.push({ merchantId: m.id, reason: err.message });
        } else {
          this.logger.error(`Falló generación para ${m.id}: ${(err as Error).message}`);
          skipped.push({ merchantId: m.id, reason: (err as Error).message });
        }
      }
    }

    return { generated, skipped };
  }

  async list(filters: {
    status?: InvoiceStatus;
    period?: string;
    merchantId?: string;
    limit?: number;
    cursor?: string;
  }) {
    const take = Math.min(Math.max(filters.limit ?? 20, 1), 100);
    const where: Prisma.MerchantInvoiceWhereInput = {
      ...(filters.status && { status: filters.status }),
      ...(filters.period && { period: filters.period }),
      ...(filters.merchantId && { merchantId: filters.merchantId }),
    };
    const rows = await this.prisma.merchantInvoice.findMany({
      where,
      take: take + 1,
      ...(filters.cursor ? { cursor: { id: filters.cursor }, skip: 1 } : {}),
      orderBy: { issuedAt: "desc" },
      include: {
        items: true,
        merchant: { select: { name: true } },
      },
    });
    const hasMore = rows.length > take;
    const data = (hasMore ? rows.slice(0, take) : rows).map((r) =>
      this.toDto(r, r.items, r.merchant?.name),
    );
    return {
      data,
      nextCursor: hasMore ? data[data.length - 1].id : null,
    };
  }

  async findOne(id: string): Promise<InvoiceDto> {
    const row = await this.prisma.merchantInvoice.findUnique({
      where: { id },
      include: { items: true, merchant: { select: { name: true } } },
    });
    if (!row) throw new NotFoundException("Invoice not found");
    return this.toDto(row, row.items, row.merchant?.name);
  }

  async markPaid(id: string): Promise<InvoiceDto> {
    const existing = await this.prisma.merchantInvoice.findUnique({
      where: { id },
    });
    if (!existing) throw new NotFoundException("Invoice not found");
    if (existing.status === InvoiceStatus.paid) {
      throw new ConflictException("Invoice ya estaba marcada como paid");
    }
    if (existing.status === InvoiceStatus.void) {
      throw new ConflictException("Invoice está anulada, no se puede marcar paid");
    }
    const updated = await this.prisma.merchantInvoice.update({
      where: { id },
      data: { status: InvoiceStatus.paid, paidAt: new Date() },
      include: { items: true, merchant: { select: { name: true } } },
    });
    return this.toDto(updated, updated.items, updated.merchant?.name);
  }

  // ──────────────────────────────────────────────────────────────────
  // Internals
  // ──────────────────────────────────────────────────────────────────

  private async generateForMerchant(
    merchantId: string,
    period: string,
  ): Promise<InvoiceDto | null> {
    const plan = await this.prisma.merchantPricingPlan.findFirst({
      where: { merchantId, active: true },
    });
    if (!plan) return null;

    if (plan.free) {
      throw new ConflictException("plan free — sin cobro");
    }

    if (!this.isCycleDue(plan.billingCycle, period)) {
      throw new ConflictException(
        `cycle ${plan.billingCycle} no factura en ${period}`,
      );
    }

    const existing = await this.prisma.merchantInvoice.findUnique({
      where: { merchantId_period: { merchantId, period } },
    });
    if (existing) throw new ConflictException(`Invoice ${period} ya existe`);

    const { start, end } = this.cycleRange(plan.billingCycle, period);
    const months = MONTHS_IN_CYCLE[plan.billingCycle];

    const approved = await this.prisma.paymentIntent.aggregate({
      where: {
        merchantId,
        status: PaymentIntentStatus.approved,
        createdAt: { gte: start, lt: end },
      },
      _count: { _all: true },
      _sum: { amount: true },
    });

    const txCount = approved._count._all;
    const approvedVolume = new Prisma.Decimal(
      approved._sum.amount?.toString() ?? "0",
    );

    const monthlyFee = new Prisma.Decimal(plan.monthlyFee.toString()).mul(months);
    const txFeeFixed = new Prisma.Decimal(plan.txFeeFixed.toString());
    const txFeePercentage = new Prisma.Decimal(plan.txFeePercentage.toString());
    const overQuotaFee = plan.overQuotaTxFee
      ? new Prisma.Decimal(plan.overQuotaTxFee.toString())
      : null;
    const includedTx = plan.includedTxPerMonth ?? null;

    const items: Array<Omit<MerchantInvoiceItem, "createdAt" | "invoiceId">> = [];

    if (monthlyFee.greaterThan(0)) {
      const cycleLabel =
        plan.billingCycle === BillingCycle.monthly
          ? "Fee mensual"
          : plan.billingCycle === BillingCycle.quarterly
            ? "Fee trimestral (3 meses)"
            : "Fee anual (12 meses)";
      items.push({
        id: newId("invoiceItem"),
        type: InvoiceItemType.monthly_fee,
        description: `${cycleLabel} — ${plan.name}`,
        quantity: 1,
        unitAmount: monthlyFee,
        totalAmount: monthlyFee,
      });
    }

    // Tx cubiertas por la cuota incluida no pagan tx_fee_fixed adicional.
    const billableFixedTxCount =
      includedTx !== null ? Math.max(0, txCount - includedTx) : txCount;

    if (txFeeFixed.greaterThan(0) && billableFixedTxCount > 0) {
      const total = txFeeFixed.mul(billableFixedTxCount);
      items.push({
        id: newId("invoiceItem"),
        type: InvoiceItemType.tx_fee_fixed,
        description: `Fee fijo por tx (${billableFixedTxCount} tx)`,
        quantity: billableFixedTxCount,
        unitAmount: txFeeFixed,
        totalAmount: total,
      });
    }

    if (txFeePercentage.greaterThan(0) && approvedVolume.greaterThan(0)) {
      const total = approvedVolume.mul(txFeePercentage).div(100);
      items.push({
        id: newId("invoiceItem"),
        type: InvoiceItemType.tx_fee_percentage,
        description: `Fee porcentual ${plan.txFeePercentage.toString()}% sobre volumen aprobado`,
        quantity: 1,
        unitAmount: total,
        totalAmount: total,
      });
    }

    if (overQuotaFee && includedTx !== null && txCount > includedTx) {
      const over = txCount - includedTx;
      const total = overQuotaFee.mul(over);
      items.push({
        id: newId("invoiceItem"),
        type: InvoiceItemType.over_quota_fee,
        description: `Tx sobre cuota (${over} tx)`,
        quantity: over,
        unitAmount: overQuotaFee,
        totalAmount: total,
      });
    }

    if (items.length === 0) return null;

    const monthlyFeeTotal = items
      .filter((i) => i.type === InvoiceItemType.monthly_fee)
      .reduce((acc, i) => acc.plus(i.totalAmount), new Prisma.Decimal(0));
    const txFeesTotal = items
      .filter((i) => i.type !== InvoiceItemType.monthly_fee)
      .reduce((acc, i) => acc.plus(i.totalAmount), new Prisma.Decimal(0));
    const total = monthlyFeeTotal.plus(txFeesTotal);

    const issuedAt = new Date();
    const dueAt = new Date(issuedAt);
    dueAt.setUTCDate(dueAt.getUTCDate() + 10);

    const invoiceId = newId("invoice");
    const created = await this.prisma.$transaction(async (tx) => {
      const inv = await tx.merchantInvoice.create({
        data: {
          id: invoiceId,
          merchantId,
          period,
          monthlyFeeAmount: monthlyFeeTotal,
          txFeesAmount: txFeesTotal,
          txCount,
          totalAmount: total,
          currency: plan.monthlyFeeCurrency,
          status: InvoiceStatus.pending,
          issuedAt,
          dueAt,
        },
      });
      await tx.merchantInvoiceItem.createMany({
        data: items.map((i) => ({
          id: i.id,
          invoiceId: inv.id,
          type: i.type,
          description: i.description,
          quantity: i.quantity,
          unitAmount: i.unitAmount,
          totalAmount: i.totalAmount,
        })),
      });
      return inv;
    });

    const itemsCreated = await this.prisma.merchantInvoiceItem.findMany({
      where: { invoiceId: created.id },
    });

    const selfBillingIntentId = await this.createSelfBillingIntent(plan, created).catch(
      (err: Error) => {
        this.logger.error(
          `createSelfBillingIntent falló para invoice ${created.id}: ${err.message}`,
        );
        return null;
      },
    );
    let finalInvoice = created;
    if (selfBillingIntentId) {
      finalInvoice = await this.prisma.merchantInvoice.update({
        where: { id: created.id },
        data: { selfBillingIntentId },
      });
    }
    return this.toDto(finalInvoice, itemsCreated);
  }

  private toDto(
    inv: MerchantInvoice,
    items: MerchantInvoiceItem[],
    merchantName?: string,
  ): InvoiceDto {
    return {
      id: inv.id,
      merchantId: inv.merchantId,
      merchantName,
      period: inv.period,
      monthlyFeeAmount: inv.monthlyFeeAmount.toString(),
      txFeesAmount: inv.txFeesAmount.toString(),
      txCount: inv.txCount,
      totalAmount: inv.totalAmount.toString(),
      currency: inv.currency,
      status: inv.status,
      issuedAt: inv.issuedAt.toISOString(),
      dueAt: inv.dueAt.toISOString(),
      paidAt: inv.paidAt ? inv.paidAt.toISOString() : null,
      selfBillingIntentId: inv.selfBillingIntentId,
      items: items.map((i) => ({
        id: i.id,
        type: i.type,
        description: i.description,
        quantity: i.quantity,
        unitAmount: i.unitAmount.toString(),
        totalAmount: i.totalAmount.toString(),
      })),
    };
  }

  private previousPeriod(): string {
    const now = new Date();
    const d = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 1, 1));
    return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}`;
  }

  private isCycleDue(cycle: BillingCycle, period: string): boolean {
    const month = Number(period.split("-")[1]);
    switch (cycle) {
      case BillingCycle.monthly:
        return true;
      case BillingCycle.quarterly:
        return [3, 6, 9, 12].includes(month);
      case BillingCycle.yearly:
        return month === 12;
    }
  }

  private cycleRange(
    cycle: BillingCycle,
    period: string,
  ): { start: Date; end: Date } {
    const [year, month] = period.split("-").map(Number);
    const end = new Date(Date.UTC(year, month, 1));
    const monthsBack = MONTHS_IN_CYCLE[cycle];
    const start = new Date(Date.UTC(year, month - monthsBack, 1));
    return { start, end };
  }

  // ──────────────────────────────────────────────────────────────────
  // Self-billing
  // ──────────────────────────────────────────────────────────────────

  /**
   * Si el plan tiene `selfBillingEnabled`, crea un PaymentIntent en el merchant
   * interno de Novasis para que el comercio pague el fee con su propio método.
   * Falla silenciosamente (log) — no debe romper la generación si el merchant
   * interno no está configurado.
   */
  private async createSelfBillingIntent(
    plan: MerchantPricingPlan,
    invoice: MerchantInvoice,
  ): Promise<string | null> {
    if (!plan.selfBillingEnabled) return null;
    if (!invoice.totalAmount || new Prisma.Decimal(invoice.totalAmount).lte(0)) {
      return null;
    }
    const selfMerchantId =
      this.config?.get<string>("SELF_BILLING_MERCHANT_ID") ??
      process.env.SELF_BILLING_MERCHANT_ID;
    if (!selfMerchantId) {
      this.logger.warn(
        `Plan ${plan.id} tiene selfBillingEnabled pero SELF_BILLING_MERCHANT_ID no está configurado`,
      );
      return null;
    }
    const exists = await this.prisma.merchant.findUnique({
      where: { id: selfMerchantId },
      select: { id: true },
    });
    if (!exists) {
      this.logger.warn(
        `SELF_BILLING_MERCHANT_ID=${selfMerchantId} no existe — saltando self-billing`,
      );
      return null;
    }
    const intent = await this.prisma.paymentIntent.create({
      data: {
        id: newId("paymentIntent"),
        merchantId: selfMerchantId,
        amount: invoice.totalAmount,
        currency: invoice.currency,
        country: "PY",
        description: `Novasis Pay — Fee ${invoice.period} (${plan.merchantId})`,
        externalReference: invoice.id,
        status: PaymentIntentStatus.created,
        metadata: {
          kind: "self_billing",
          invoiceId: invoice.id,
          payerMerchantId: plan.merchantId,
        } as Prisma.InputJsonValue,
      },
      select: { id: true },
    });
    return intent.id;
  }

  /**
   * Hook llamado por `PaymentIntentService` cuando un intent transiciona a
   * approved. Si el intent es de self-billing, marca la factura asociada como
   * paid (idempotente). No lanza — los errores quedan logueados.
   */
  async onIntentApproved(
    intentId: string,
    metadata: Prisma.JsonValue,
  ): Promise<void> {
    if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) {
      return;
    }
    const meta = metadata as Record<string, unknown>;
    if (meta.kind !== "self_billing") return;
    const invoiceId = typeof meta.invoiceId === "string" ? meta.invoiceId : null;
    if (!invoiceId) return;

    const inv = await this.prisma.merchantInvoice.findUnique({
      where: { id: invoiceId },
      select: { id: true, status: true },
    });
    if (!inv) {
      this.logger.warn(
        `self-billing intent ${intentId} apunta a invoice ${invoiceId} que no existe`,
      );
      return;
    }
    if (inv.status === InvoiceStatus.paid || inv.status === InvoiceStatus.void) {
      return;
    }
    await this.prisma.merchantInvoice.update({
      where: { id: invoiceId },
      data: {
        status: InvoiceStatus.paid,
        paidAt: new Date(),
        selfBillingIntentId: intentId,
      },
    });
    this.logger.log(
      `invoice ${invoiceId} marcada paid por self-billing intent ${intentId}`,
    );
  }
}
