import {
  BadRequestException,
  Injectable,
  Logger,
  NotFoundException,
  UnprocessableEntityException,
} from '@nestjs/common';
import { AuditService } from 'src/audit/audit.service';
import { CreateFacturaDto } from 'src/facturas/dto/create-factura.dto';
import { AfectacionIva, calcularLineaFiscal, calcularSubtotalFiscal, LineaFiscal } from 'src/facturas/calculo-iva';
import { FacturasService } from 'src/facturas/facturas.service';
import { MonedasService } from 'src/monedas/monedas.service';
import { PrismaService } from 'src/prisma/prisma.service';
import { InusualesInmService } from '../cumplimiento/inusuales.service';
import { ComisionesInmService } from '../comisiones/comisiones.service';
import { ajustarPorIndice } from '../common/ajuste-indice';
import { aFecha, decimalesDe, hoyPy, isoDia, redondear } from '../common/inm-utils';
import { IndicesInmService } from '../config/indices.service';
import { ParametrosInmService } from '../config/parametros.service';
import { CobrarCuotasDto } from './dto/cobro.dto';

export interface LineaCobro {
  cuota_id: string;
  nro: number;
  descripcion: string;
  capital: number;
  ajuste: number;
  facturado: number;
  saldo_restante: number;
}

/**
 * Cobro de cuotas (M116) — decisión 1: cada cobro emite su factura de contado.
 *
 * - Una línea de factura por cuota, con afectación 4 (base gravada y tasa copiadas
 *   en la operación al reservar).
 * - Venta en USD: factura en USD con la cotización del día. Si el comprador paga en
 *   guaraníes, el pago va en Gs. (FacturasService lo soporta desde a54bedc).
 * - Venta en Gs. con IPC: se factura la cuota ajustada (nunca menos que la original).
 *   El cronograma descuenta el capital; el ajuste queda registrado aparte.
 *
 * La factura la emite FacturasService.create, que tiene su propia transacción: no hay
 * rollback posible después. Por eso se valida todo antes y los cobros de una misma
 * operación se serializan (un proceso, modo fork).
 */
@Injectable()
export class CobrosInmService {
  private readonly logger = new Logger(CobrosInmService.name);
  private static readonly enCurso = new Map<string, Promise<unknown>>();

  constructor(
    private readonly prisma: PrismaService,
    private readonly audit: AuditService,
    private readonly facturas: FacturasService,
    private readonly parametros: ParametrosInmService,
    private readonly indices: IndicesInmService,
    private readonly monedas: MonedasService,
    private readonly comisiones: ComisionesInmService,
    private readonly inusuales: InusualesInmService,
  ) {}

  /** Lo que se va a facturar, sin emitir nada. */
  async previsualizar(operacion_id: string, dto: CobrarCuotasDto, empresa_id: string) {
    const { op, calculo } = await this.calcular(operacion_id, dto, empresa_id);
    return { operacion: { id: op.id, numero: op.numero }, ...calculo };
  }

  async cobrar(operacion_id: string, dto: CobrarCuotasDto, empresa_id: string, user_id?: string) {
    // Serializa los cobros de una misma operación: dos cajeros cobrando la misma
    // cuota a la vez emitirían dos facturas.
    const previo = CobrosInmService.enCurso.get(operacion_id) ?? Promise.resolve();
    const actual = previo
      .catch(() => undefined)
      .then(() => this.cobrarSinConcurrencia(operacion_id, dto, empresa_id, user_id));
    CobrosInmService.enCurso.set(operacion_id, actual);
    try {
      return await actual;
    } finally {
      if (CobrosInmService.enCurso.get(operacion_id) === actual) CobrosInmService.enCurso.delete(operacion_id);
    }
  }

  private async cobrarSinConcurrencia(
    operacion_id: string,
    dto: CobrarCuotasDto,
    empresa_id: string,
    user_id?: string,
  ) {
    const params = await this.parametros.obtener(empresa_id);
    const numeracion_id = dto.numeracion_id ?? params.numeracion_id;
    if (!params.producto_cuota_id || !numeracion_id) {
      throw new UnprocessableEntityException(
        'Falta configurar con qué producto y qué timbrado se facturan las cuotas (Configuración inmobiliaria → Parámetros).',
      );
    }
    const { op, calculo } = await this.calcular(operacion_id, dto, empresa_id);

    const [indicador, tipoImpuesto, tipoTransaccion, condicion, medioPago, producto] = await Promise.all([
      this.prisma.indicador_presencia.findFirst({ where: { codigo: 1 } }),
      this.prisma.tipo_impuesto.findFirst({ where: { codigo: 1 } }),
      this.prisma.tipo_transaccion.findFirst({ where: { codigo: 1 } }),
      this.prisma.condicion_operacion.findFirst({ where: { codigo: 1 } }),
      this.prisma.medio_pago.findUnique({ where: { id: dto.medio_pago_id } }),
      this.prisma.productos.findFirst({ where: { id: params.producto_cuota_id, empresa_id } }),
    ]);
    if (!indicador || !tipoImpuesto || !tipoTransaccion || !condicion)
      throw new Error('Faltan maestros de facturación');
    if (!medioPago) throw new BadRequestException('El medio de pago no existe');
    if (!producto) throw new UnprocessableEntityException('El producto configurado para facturar cuotas no existe');

    const lineasFiscales: LineaFiscal[] = calculo.lineas.map((l) =>
      calcularLineaFiscal({
        cantidad: 1,
        precio_unitario: l.facturado,
        afectacion_iva: AfectacionIva.GRAVADO_PARCIAL,
        porcentaje_iva: op.tasa_iva,
        proporcion_iva: Number(op.proporcion_gravada),
      }),
    );
    const tc = calculo.tipo_cambio;
    const subtotal = calcularSubtotalFiscal(lineasFiscales, tc);
    const ref = `${op.inm_unidades.codigo} — ${op.inm_proyectos.nombre} (${op.numero})`;

    const monedaPagoId =
      calculo.moneda_pago !== calculo.moneda
        ? (await this.prisma.moneda.findFirst({ where: { codigo: calculo.moneda_pago }, select: { id: true } }))?.id
        : undefined;

    const dtoFactura: CreateFacturaDto = {
      cabecera: {
        empresa_id,
        cliente_id: op.cliente_id,
        tipo_transaccion_id: tipoTransaccion.id,
        tipo_impuesto_id: tipoImpuesto.id,
        moneda_id: op.moneda_id,
        indicador_presencia_id: indicador.id,
        condicion_operacion_id: condicion.id,
        numeracion_id,
        dfeemide: new Date(Date.now() - 3 * 3600_000).toISOString().slice(0, 19).replace('T', ' '),
        ...(tc && { dticam: tc, dcondticam: 'G' }),
        dinfadic: `Cobro de cuotas — ${ref}`.slice(0, 5000),
      } as CreateFacturaDto['cabecera'],
      items: calculo.lineas.map((l, i) => {
        const f = lineasFiscales[i];
        return {
          producto_id: producto.id,
          unidad_medida: 'UNI',
          descripcion: `${l.descripcion} — ${ref}`.slice(0, 2000),
          cantidad: 1,
          precio_unitario: f.precio_unitario,
          total_bruto: f.total_bruto,
          descuento: 0,
          porcentaje_descuento: 0,
          descuento_global: 0,
          total_operacion: f.total_operacion,
          total_gs: tc ? Math.round(f.total_operacion * tc) : f.total_operacion,
          afectacion_iva: f.afectacion_iva,
          porcentaje_iva: f.porcentaje_iva,
          proporcion_iva: f.proporcion_iva,
          base_gravada: f.base_gravada,
          liquidacion_iva: f.liquidacion_iva,
          base_gravada_exenta: f.base_gravada_exenta,
        };
      }) as CreateFacturaDto['items'],
      pagos: [
        {
          medio_pago_id: medioPago.id,
          moneda_id: op.moneda_id,
          monto_pago: calculo.monto_moneda_pago,
          ...(monedaPagoId && { moneda_pago_id: monedaPagoId, monto_moneda_original: calculo.monto_moneda_pago }),
          sesion_caja_id: dto.sesion_caja_id,
          tarjeta_id: dto.tarjeta_id,
          forma_procesamiento_pago_id: dto.forma_procesamiento_pago_id,
          nro_autorizacion_procesadora: dto.nro_autorizacion,
          numero_cheque: dto.numero_cheque,
          banco_emisor: dto.banco_emisor,
        },
      ] as CreateFacturaDto['pagos'],
      subtotal: subtotal as unknown as CreateFacturaDto['subtotal'],
    };

    const result = await this.facturas.create(dtoFactura, empresa_id, user_id);
    const factura_cab_id: string | undefined = (result as { data?: { id?: string } })?.data?.id;
    if (!factura_cab_id) throw new Error('FacturasService.create() no devolvió la factura');

    // Equivalente en guaraníes, para las alertas de efectivo de cumplimiento.
    const montoGs = calculo.moneda === 'PYG' ? calculo.total : Math.round(calculo.total * Number(tc ?? 0));
    // La factura ya existe: de acá en adelante no se puede abortar sin dejarla huérfana.
    const cobro = await this.prisma.$transaction(async (tx) => {
      const c = await tx.inm_cobros.create({
        data: {
          empresa_id,
          operacion_id: op.id,
          factura_cab_id,
          fecha: aFecha(hoyPy()),
          moneda_id: op.moneda_id,
          total: calculo.total,
          tipo_cambio: tc,
          moneda_pago_codigo: calculo.moneda_pago,
          monto_moneda_pago: calculo.monto_moneda_pago,
          indice_vigente_valor: calculo.indice?.valor ?? null,
          medio_pago_codigo: medioPago.codigo ?? null,
          monto_gs: montoGs,
          created_by: user_id ?? null,
          inm_cobro_cuotas: {
            create: calculo.lineas.map((l) => ({
              empresa_id,
              cuota_id: l.cuota_id,
              monto_capital: l.capital,
              monto_ajuste: l.ajuste,
              monto_facturado: l.facturado,
            })),
          },
        },
      });
      for (const l of calculo.lineas) {
        await tx.inm_operacion_cuotas.update({
          where: { id: l.cuota_id },
          data: {
            monto_cobrado: { increment: l.capital },
            estado: l.saldo_restante <= 0 ? 'PAGADA' : 'PARCIAL',
            updated_at: new Date(),
          },
        });
      }
      if (op.estado === 'BOLETO_FIRMADO') {
        const pendientes = await tx.inm_operacion_cuotas.count({
          where: { operacion_id: op.id, estado: { in: ['PENDIENTE', 'PARCIAL'] } },
        });
        if (pendientes === 0)
          await tx.inm_operaciones.update({
            where: { id: op.id },
            data: { estado: 'CANCELADA_TOTAL', updated_at: new Date() },
          });
      }
      return c;
    });

    await this.audit.log({
      empresa_id,
      user_id,
      action: 'CREAR',
      entity_type: 'inm_cobros',
      entity_id: cobro.id,
      new_value: {
        operacion: op.numero,
        factura_cab_id,
        total: calculo.total,
        moneda: calculo.moneda,
        cuotas: calculo.lineas.map((l) => l.nro),
      },
    });
    this.logger.log(
      `Cobro ${cobro.id} de ${op.numero}: factura ${factura_cab_id} por ${calculo.total} ${calculo.moneda}`,
    );
    // Un hito "cobrado X %" puede haberse cumplido. Si falla, no frena el cobro.
    await this.comisiones
      .devengar(op.id, empresa_id)
      .catch((e) => this.logger.error(`Devengo de comisiones ${op.numero}: ${(e as Error).message}`));
    // Cumplimiento: efectivo por el umbral o más queda en el registro de inusuales.
    await this.inusuales
      .evaluarCobro(empresa_id, {
        cobro_id: cobro.id,
        operacion_id: op.id,
        numero: op.numero,
        cliente_id: op.cliente_id,
        medio_codigo: medioPago.codigo ?? null,
        monto_gs: montoGs,
      })
      .catch((e) => this.logger.error(`Alerta de efectivo ${op.numero}: ${(e as Error).message}`));
    return { cobro_id: cobro.id, factura_cab_id, factura: (result as { data?: unknown }).data, ...calculo };
  }

  /**
   * Arma el cobro: valida cuotas y montos, aplica el ajuste por IPC y resuelve la
   * cotización y la moneda de pago. No escribe nada.
   */
  private async calcular(operacion_id: string, dto: CobrarCuotasDto, empresa_id: string) {
    const op = await this.prisma.inm_operaciones.findFirst({
      where: { id: operacion_id, empresa_id },
      include: {
        moneda: { select: { codigo: true } },
        inm_unidades: { select: { codigo: true } },
        inm_proyectos: { select: { nombre: true } },
        inm_operacion_cuotas: true,
      },
    });
    if (!op) throw new NotFoundException('Operación no encontrada');
    if (!['RESERVADA', 'BOLETO_FIRMADO', 'ESCRITURADA', 'ENTREGADA'].includes(op.estado)) {
      throw new BadRequestException(
        `La operación está ${op.estado.toLowerCase().replace('_', ' ')}: no tiene cuotas para cobrar`,
      );
    }
    const moneda = op.moneda?.codigo ?? 'PYG';
    const dec = decimalesDe(moneda);
    const hoy = hoyPy();

    const ids = new Set<string>();
    const cuotas = dto.cuotas.map((pedido) => {
      if (ids.has(pedido.cuota_id)) throw new BadRequestException('Una cuota está repetida en el cobro');
      ids.add(pedido.cuota_id);
      const c = op.inm_operacion_cuotas.find((x) => x.id === pedido.cuota_id);
      if (!c) throw new BadRequestException('Hay una cuota que no es de esta operación');
      if (c.estado === 'PAGADA' || c.estado === 'ANULADA')
        throw new BadRequestException(`La cuota ${c.nro} ya no está pendiente`);
      if (op.estado === 'RESERVADA' && c.tipo !== 'SENA')
        throw new BadRequestException('Antes del boleto sólo se cobra la seña');
      const saldo = redondear(Number(c.monto) - Number(c.monto_cobrado), dec);
      const capital = redondear(pedido.monto ?? saldo, dec);
      if (capital <= 0 || capital > saldo + 1e-9) {
        throw new BadRequestException(
          `La cuota ${c.nro} tiene un saldo de ${saldo}: no se le pueden cobrar ${capital}`,
        );
      }
      return { c, capital, saldo };
    });
    cuotas.sort((a, b) => a.c.nro - b.c.nro);

    // Ajuste por IPC: sólo ventas en guaraníes con el IPC de firma fijado (boleto firmado).
    let indice: { periodo: string | null; valor: number } | null = null;
    if (op.ajuste === 'IPC_BCP' && op.indice_firma_valor) {
      const vig = await this.indices.vigente(empresa_id, hoy);
      if (!vig)
        throw new UnprocessableEntityException('No hay IPC cargado: no se puede calcular el ajuste de la cuota');
      indice = { periodo: isoDia(vig.periodo), valor: Number(vig.valor) };
    }

    const lineas: LineaCobro[] = cuotas.map(({ c, capital, saldo }) => {
      const facturado = indice ? ajustarPorIndice(capital, Number(op.indice_firma_valor), indice.valor) : capital;
      return {
        cuota_id: c.id,
        nro: c.nro,
        descripcion:
          (capital < saldo ? `${c.descripcion ?? 'Cuota'} (pago parcial)` : (c.descripcion ?? 'Cuota')) +
          (facturado > capital ? ' — ajustada por IPC' : ''),
        capital,
        ajuste: redondear(facturado - capital, dec),
        facturado,
        saldo_restante: redondear(saldo - capital, dec),
      };
    });
    const total = redondear(
      lineas.reduce((a, l) => a + l.facturado, 0),
      dec,
    );

    // Cotización: la factura en moneda extranjera lleva la del día; y si se paga en
    // la otra moneda, se convierte con ella.
    const monedaPago = dto.moneda_pago ?? (moneda === 'PYG' ? 'PYG' : moneda);
    let tipo_cambio: number | null = null;
    let fecha_cotizacion: string | null = null;
    // Cotización vigente unificada del ERP (contabilidad → facturación). Su último
    // recurso es 1: con eso una cuota en USD se facturaría a 1 guaraní, así que acá
    // es un error y no un valor.
    const cotizacionDe = async (codigo: string) => {
      const cot = await this.monedas.getCotizacionVigente(empresa_id, codigo);
      if (cot.fuente === 'fallback' || !(cot.tasa > 0)) {
        throw new UnprocessableEntityException(
          `No hay cotización de ${codigo} cargada. Cargá la del día antes de cobrar.`,
        );
      }
      return { valor: cot.tasa, fecha: cot.fecha_efectiva };
    };
    if (moneda !== 'PYG') {
      const cot = await cotizacionDe(moneda);
      tipo_cambio = cot.valor;
      fecha_cotizacion = cot.fecha;
    }

    let monto_moneda_pago = total;
    if (monedaPago !== moneda) {
      if (moneda !== 'PYG' && monedaPago === 'PYG') monto_moneda_pago = Math.round(total * tipo_cambio);
      else if (moneda === 'PYG' && monedaPago === 'USD') {
        const cot = await cotizacionDe('USD');
        tipo_cambio = null;
        fecha_cotizacion = cot.fecha;
        monto_moneda_pago = redondear(total / cot.valor, 2);
      } else throw new BadRequestException(`No se puede pagar en ${monedaPago} una venta en ${moneda}`);
    }

    return {
      op,
      calculo: {
        moneda,
        moneda_pago: monedaPago,
        lineas,
        total,
        tipo_cambio,
        fecha_cotizacion,
        monto_moneda_pago,
        indice,
        indice_firma: op.indice_firma_valor ? Number(op.indice_firma_valor) : null,
      },
    };
  }
}
