import { Injectable, Logger } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from 'src/prisma/prisma.service';

/** Prefijo del movimiento de reversión. Sirve además como marca de idempotencia. */
const PREFIJO_REVERSION = 'Anulación Fact.';

/** Códigos SIFEN de medio de pago que afectan el efectivo físico del cajón. */
const MEDIO_EFECTIVO = 1;
const MEDIOS_TARJETA = [3, 4, 8];
const MEDIO_TRANSFERENCIA = 5;

type ResultadoReversion = {
  /** true si ya existía una reversión previa y no se hizo nada. */
  yaRevertida: boolean;
  /** Pagos revertidos sobre la sesión original (todavía abierta). */
  revertidosEnSesionOriginal: number;
  /** Pagos registrados como salida en la sesión abierta actual (la original ya cerró). */
  registradosEnSesionActual: number;
  /** Pagos que no se pudieron registrar: la caja no tiene ninguna sesión abierta. */
  pendientes: number;
};

/**
 * Reversión de los movimientos de caja de una factura anulada/inutilizada.
 *
 * Vive en un servicio propio porque hay DOS caminos que anulan una factura y
 * ambos tienen que revertir la caja:
 *   1. `FacturasService.anularFactura` (endpoint `PATCH /facturas/:id/anular`).
 *   2. `MiddlewareSifenService`, cuando SIFEN aprueba un evento ECAN/EINU
 *      (botón "Eventos SIFEN" del listado y el job que sincroniza estados).
 *
 * Antes esto vivía inline en `anularFactura`, así que el camino 2 revertía
 * stock, contabilidad y orden de venta pero dejaba la caja intacta: el cajero
 * terminaba rindiendo por plata de una factura anulada.
 */
@Injectable()
export class ReversionCajaService {
  private readonly logger = new Logger(ReversionCajaService.name);

  constructor(private readonly prisma: PrismaService) {}

  /**
   * Revierte en caja los pagos de una factura anulada. Es **idempotente**: si
   * ya existe un movimiento de reversión para esta factura no hace nada, así
   * los dos caminos de anulación pueden llamarla sin duplicar la salida.
   */
  async revertirCajaFactura(facturaId: string, empresaId: string, motivo: string): Promise<ResultadoReversion> {
    return this.prisma.$transaction(async (tx) => this.revertirDentroDeTransaccion(tx, facturaId, empresaId, motivo));
  }

  private async revertirDentroDeTransaccion(
    tx: Prisma.TransactionClient,
    facturaId: string,
    empresaId: string,
    motivo: string,
  ): Promise<ResultadoReversion> {
    const resultado: ResultadoReversion = {
      yaRevertida: false,
      revertidosEnSesionOriginal: 0,
      registradosEnSesionActual: 0,
      pendientes: 0,
    };

    // Lock de la fila de la factura: serializa dos reversiones concurrentes
    // (la de `anularFactura` y la del evento SIFEN aprobado), de modo que la
    // segunda vea el movimiento que dejó la primera y salga por el guard.
    await tx.$queryRaw`SELECT id FROM factura_cab WHERE id = ${facturaId}::uuid FOR UPDATE`;

    // Guard de idempotencia. El movimiento de la VENTA también usa
    // `referencia = facturaId`, por eso además se filtra por la descripción.
    const yaExiste = await tx.movimiento_cajas.findFirst({
      where: { referencia: facturaId, descripcion: { startsWith: PREFIJO_REVERSION } },
      select: { id: true },
    });
    if (yaExiste) {
      resultado.yaRevertida = true;
      return resultado;
    }

    const factura = await tx.factura_cab.findFirst({
      where: { id: facturaId, empresa_id: empresaId },
      select: {
        dest: true,
        dpunexp: true,
        dnumdoc: true,
        moneda: { select: { codigo: true } },
        factura_forma_pagos: {
          where: { sesion_caja_id: { not: null } },
          select: {
            sesion_caja_id: true,
            dmontipag: true,
            dticamtipag: true,
            medio_pago: { select: { codigo: true, descripcion: true } },
          },
        },
      },
    });
    if (!factura || factura.factura_forma_pagos.length === 0) return resultado;

    const nroFact = `${factura.dest}-${factura.dpunexp}-${factura.dnumdoc}`;
    const tipoMovSalida = await tx.tipo_movimiento_cajas.findFirst({
      where: { descripcion: { contains: 'Salida', mode: 'insensitive' } },
      select: { id: true },
    });

    for (const pago of factura.factura_forma_pagos) {
      if (!pago.sesion_caja_id) continue;

      const monto = this.montoEnMonedaFactura(pago, factura.moneda?.codigo);
      if (monto <= 0) continue;

      const sesionOriginal = await tx.sesiones_caja.findUnique({
        where: { id: pago.sesion_caja_id },
        select: { id: true, caja_id: true, estado: true },
      });
      if (!sesionOriginal) continue;

      const codigo = pago.medio_pago?.codigo ?? null;
      const descripcionMedio = pago.medio_pago?.descripcion || 'Pago';

      if (sesionOriginal.estado === 'ABIERTA') {
        // La sesión donde se cobró sigue abierta: se descuenta del mismo
        // contador que la venta incrementó, dejando el arqueo coherente.
        await this.descontarDeSesion(tx, sesionOriginal.id, codigo, monto);
        await this.crearMovimientoSalida(tx, {
          sesionId: sesionOriginal.id,
          empresaId,
          tipoMovId: tipoMovSalida?.id ?? null,
          monto,
          descripcion: `${PREFIJO_REVERSION} ${nroFact} - ${descripcionMedio} | ${motivo}`,
          facturaId,
        });
        resultado.revertidosEnSesionOriginal += 1;
        continue;
      }

      // La sesión original ya cerró: su arqueo está firmado y no se toca.
      // La devolución se registra como salida en la sesión abierta de hoy.
      const sesionActual = sesionOriginal.caja_id
        ? await tx.sesiones_caja.findFirst({
            where: { caja_id: sesionOriginal.caja_id, empresa_id: empresaId, estado: 'ABIERTA' },
            orderBy: { fecha_apertura: 'desc' },
            select: { id: true },
          })
        : null;

      if (!sesionActual) {
        // Sin caja abierta no hay dónde registrar la salida. Queda pendiente y
        // se avisa: es un caso que necesita intervención manual.
        resultado.pendientes += 1;
        this.logger.warn(
          `Reversión de caja pendiente: Fact. ${nroFact} (${descripcionMedio}, ${monto}) — la sesión original está cerrada y la caja no tiene sesión abierta.`,
          'ReversionCajaService',
        );
        continue;
      }

      await tx.sesiones_caja.update({
        where: { id: sesionActual.id },
        data: { monto_salidas_manual: { increment: monto }, updated_at: new Date() },
      });
      await this.crearMovimientoSalida(tx, {
        sesionId: sesionActual.id,
        empresaId,
        tipoMovId: tipoMovSalida?.id ?? null,
        monto,
        descripcion: `${PREFIJO_REVERSION} ${nroFact} - ${descripcionMedio} (cobrada en sesión ya cerrada) | ${motivo}`,
        facturaId,
      });
      resultado.registradosEnSesionActual += 1;
    }

    if (resultado.revertidosEnSesionOriginal || resultado.registradosEnSesionActual) {
      this.logger.log(
        `Caja revertida por anulación Fact. ${nroFact}: ${resultado.revertidosEnSesionOriginal} en sesión original, ${resultado.registradosEnSesionActual} en sesión actual, ${resultado.pendientes} pendiente(s).`,
        'ReversionCajaService',
      );
    }

    return resultado;
  }

  /**
   * Monto del pago expresado en la moneda de la factura, que es el mismo que
   * la venta sumó a la caja (`montoBase`). Ese campo no se persiste, así que
   * se reconstruye desde el tipo de cambio guardado en el pago.
   */
  private montoEnMonedaFactura(
    pago: { dmontipag: unknown; dticamtipag: unknown },
    codigoMonedaFactura?: string,
  ): number {
    const montoPago = Number(pago.dmontipag) || 0;
    const tipoCambio = Number(pago.dticamtipag) || 0;
    // Sólo se convirtió cuando la factura es en guaraníes y el pago vino en
    // otra moneda (ver construcción de `montoBase` en facturas.service.ts).
    if (codigoMonedaFactura === 'PYG' && tipoCambio > 0) return montoPago * tipoCambio;
    return montoPago;
  }

  /**
   * Descuento atómico con piso en 0 sobre el contador que corresponde al medio
   * de pago. `GREATEST` en una sola sentencia evita la race condition de
   * leer-restar-escribir cuando hay ventas concurrentes en la misma sesión.
   */
  private async descontarDeSesion(
    tx: Prisma.TransactionClient,
    sesionId: string,
    codigoMedio: number | null,
    monto: number,
  ) {
    if (codigoMedio === MEDIO_EFECTIVO) {
      await tx.$executeRaw`
        UPDATE sesiones_caja
        SET monto_ventas_efectivo = GREATEST(monto_ventas_efectivo - ${monto}, 0), updated_at = NOW()
        WHERE id = ${sesionId}::uuid`;
      return;
    }
    if (codigoMedio !== null && MEDIOS_TARJETA.includes(codigoMedio)) {
      await tx.$executeRaw`
        UPDATE sesiones_caja
        SET monto_ventas_tarjeta = GREATEST(monto_ventas_tarjeta - ${monto}, 0), updated_at = NOW()
        WHERE id = ${sesionId}::uuid`;
      return;
    }
    if (codigoMedio === MEDIO_TRANSFERENCIA) {
      await tx.$executeRaw`
        UPDATE sesiones_caja
        SET monto_ventas_transferencia = GREATEST(monto_ventas_transferencia - ${monto}, 0), updated_at = NOW()
        WHERE id = ${sesionId}::uuid`;
      return;
    }
    await tx.$executeRaw`
      UPDATE sesiones_caja
      SET monto_entradas_manual = GREATEST(monto_entradas_manual - ${monto}, 0), updated_at = NOW()
      WHERE id = ${sesionId}::uuid`;
  }

  private async crearMovimientoSalida(
    tx: Prisma.TransactionClient,
    args: {
      sesionId: string;
      empresaId: string;
      tipoMovId: string | null;
      monto: number;
      descripcion: string;
      facturaId: string;
    },
  ) {
    await tx.movimiento_cajas.create({
      data: {
        sesion_id: args.sesionId,
        empresa_id: args.empresaId,
        tipo_movimiento_caja_id: args.tipoMovId,
        monto: args.monto,
        descripcion: args.descripcion,
        referencia: args.facturaId,
        fecha_mov: new Date().toISOString(),
      },
    });
  }
}
