import {
  BadRequestException,
  ConflictException,
  Injectable,
  NotFoundException,
  UnprocessableEntityException,
} from '@nestjs/common';
import { inm_ajuste_tipo, inm_operacion_estado, Prisma } from '@prisma/client';
import { AuditService } from 'src/audit/audit.service';
import { PersonasService } from 'src/personas/personas.service';
import { LegajosClienteInmService } from '../cumplimiento/legajos-cliente.service';
import { ComisionesInmService } from '../comisiones/comisiones.service';
import { PrismaService } from 'src/prisma/prisma.service';
import { CronogramaError, generarCronograma, TramoPlan } from '../common/cronograma';
import { calcularEstadoCuenta } from '../common/estado-cuenta';
import {
  aFecha,
  datosClientes,
  decimalesDe,
  hoyPy,
  isoDia,
  sumarDias,
  transicionarUnidad,
  clienteDe,
} from '../common/inm-utils';
import { NumeracionInmService } from '../common/numeracion-inm.service';
import { IndicesInmService } from '../config/indices.service';
import { ParametrosInmService } from '../config/parametros.service';
import { CotizacionesInmService, OpcionResuelta } from '../cotizaciones/cotizaciones.service';
import { renderBoleto } from './boleto';
import {
  ActualizarCuotaDto,
  FirmarBoletoDto,
  LegajoDto,
  MotivoDto,
  ProrrogarReservaDto,
  ReservarDto,
  TitularDto,
} from './dto/operacion.dto';

const TIPO_UNIDAD: Record<string, string> = {
  DEPARTAMENTO: 'Departamento',
  CASA: 'Casa',
  LOTE: 'Lote',
  LOCAL: 'Local',
  OFICINA: 'Oficina',
  COCHERA: 'Cochera',
  BAULERA: 'Baulera',
  OTRO: 'Unidad',
};

/** Estados con la venta viva (la unidad no está libre). */
export const ESTADOS_VIVOS: inm_operacion_estado[] = [
  'RESERVADA',
  'BOLETO_FIRMADO',
  'CANCELADA_TOTAL',
  'ESCRITURADA',
  'ENTREGADA',
];

/**
 * Operación de venta (M114–M115): nace con la reserva y llega hasta la entrega.
 *
 *   reservar ─► RESERVADA ─► firmarBoleto ─► BOLETO_FIRMADO ─► (todo cobrado) CANCELADA_TOTAL
 *                  └─► desistir ─► DESISTIDA (la unidad vuelve a estar disponible)
 *
 * Al reservar se crea sólo la cuota de seña; el resto del cronograma nace al firmar
 * el boleto, con la fecha de firma como base. Base gravada, IVA y el IPC de firma se
 * copian a la operación: cambiarlos después en los parámetros no toca esta venta.
 */
@Injectable()
export class OperacionesInmService {
  constructor(
    private readonly prisma: PrismaService,
    private readonly audit: AuditService,
    private readonly numeracion: NumeracionInmService,
    private readonly parametros: ParametrosInmService,
    private readonly indices: IndicesInmService,
    private readonly cotizaciones: CotizacionesInmService,
    private readonly personas: PersonasService,
    private readonly comisiones: ComisionesInmService,
    private readonly legajosCliente: LegajosClienteInmService,
  ) {}

  // ----------------------------------------------------------------- reservar

  async reservar(dto: ReservarDto, empresa_id: string, user_id?: string) {
    const params = await this.parametros.obtener(empresa_id);
    const hoy = hoyPy();

    // 1) Unidad y condiciones: de la cotización elegida o de una reserva directa.
    let unidadId: string;
    let opcion: OpcionResuelta;
    let cotizacion_id: string | null = null;
    let cotizacion_opcion_id: string | null = null;

    if (dto.cotizacion_opcion_id) {
      const o = await this.prisma.inm_cotizacion_opciones.findFirst({
        where: { id: dto.cotizacion_opcion_id, empresa_id },
        include: { inm_cotizaciones: true },
      });
      if (!o) throw new NotFoundException('La opción de cotización no existe');
      if (o.inm_cotizaciones.estado !== 'VIGENTE') {
        throw new BadRequestException(
          `La cotización ${o.inm_cotizaciones.numero} está ${o.inm_cotizaciones.estado.toLowerCase()}`,
        );
      }
      unidadId = o.inm_cotizaciones.unidad_id;
      cotizacion_id = o.cotizacion_id;
      cotizacion_opcion_id = o.id;
      const moneda = await this.monedaDeUnidad(unidadId);
      const tramos = o.tramos as unknown as TramoPlan[];
      opcion = {
        plan_id: o.plan_id,
        nombre: o.nombre,
        descuento_pct: Number(o.descuento_pct),
        precio_final: Number(o.precio_final),
        tramos,
        cronograma: this.cronograma(Number(o.precio_final), tramos, hoy, moneda),
        descuento_autorizado_por: o.descuento_autorizado_por,
      };
    } else {
      if (!dto.unidad_id || !dto.opcion)
        throw new BadRequestException('Indicá la opción de una cotización, o la unidad y el plan');
      unidadId = dto.unidad_id;
      const u = await this.prisma.inm_unidades.findFirst({
        where: { id: unidadId, empresa_id, activo: true },
        include: { moneda: { select: { codigo: true } } },
      });
      if (!u) throw new NotFoundException('Unidad no encontrada');
      opcion = await this.cotizaciones.resolverOpcion(
        empresa_id,
        { proyecto_id: u.proyecto_id, precio_lista: u.precio_lista, moneda_codigo: u.moneda?.codigo ?? 'PYG' },
        dto.opcion,
        user_id,
        { pctDescuentoMax: Number(params.pct_descuento_max), pctSenaMin: Number(params.pct_sena_min) },
        hoy,
      );
    }

    const unidad = await this.prisma.inm_unidades.findFirst({
      where: { id: unidadId, empresa_id },
      include: { moneda: { select: { id: true, codigo: true } }, inm_proyectos: { select: { moneda_id: true } } },
    });
    if (!unidad) throw new NotFoundException('Unidad no encontrada');
    if (unidad.estado !== 'DISPONIBLE') throw new ConflictException(`La unidad ${unidad.codigo} ya no está disponible`);
    const monedaId = unidad.moneda_id ?? unidad.inm_proyectos.moneda_id;
    if (!monedaId) throw new BadRequestException('La unidad no tiene moneda');
    const monedaCodigo = unidad.moneda?.codigo ?? 'PYG';

    // 2) Seña: la exige el flujo y tiene que llegar al mínimo de los parámetros.
    const senas = opcion.cronograma.filter((c) => c.tipo === 'SENA');
    if (!senas.length)
      throw new BadRequestException('El plan no tiene seña: la reserva se hace con el cobro de una seña');
    const totalSena = senas.reduce((a, c) => a + c.monto, 0);
    const minimo = (opcion.precio_final * Number(params.pct_sena_min)) / 100;
    if (totalSena + 1e-9 < minimo) {
      throw new BadRequestException(
        `La seña (${totalSena}) es menor al mínimo del ${Number(params.pct_sena_min)} % del precio (${minimo})`,
      );
    }

    // 3) Al titular principal se le factura cada cuota: sus datos fiscales se validan
    //    acá, con las mismas reglas que FacturasService, y no recién al cobrar la seña.
    await this.validarFacturable(dto.cliente_id, empresa_id);

    // 4) Titulares: el principal + los que vengan; validados contra la empresa.
    const titulares = this.normalizarTitulares(dto.cliente_id, dto.titulares);
    const clientes = await datosClientes(
      this.prisma,
      empresa_id,
      titulares.map((t) => t.cliente_id),
    );
    const faltan = titulares.filter((t) => !clientes.has(t.cliente_id));
    if (faltan.length) throw new BadRequestException('Hay titulares que no son clientes de esta empresa');

    const ajuste: inm_ajuste_tipo = monedaCodigo === 'PYG' ? (dto.ajuste ?? 'IPC_BCP') : 'NINGUNO';

    try {
      const op = await this.prisma.$transaction(async (tx) => {
        await transicionarUnidad(tx, {
          id: unidad.id,
          empresa_id,
          desde: ['DISPONIBLE'],
          hacia: 'RESERVADA',
          version: dto.version,
        });
        const numero = await this.numeracion.siguiente(empresa_id, 'OPI', tx);
        const creada = await tx.inm_operaciones.create({
          data: {
            empresa_id,
            numero,
            proyecto_id: unidad.proyecto_id,
            unidad_id: unidad.id,
            cliente_id: dto.cliente_id,
            cotizacion_id,
            cotizacion_opcion_id,
            vendedor_id: user_id ?? null,
            moneda_id: monedaId,
            precio_lista: unidad.precio_lista,
            descuento_pct: opcion.descuento_pct,
            precio_final: opcion.precio_final,
            tramos: opcion.tramos as unknown as object,
            proporcion_gravada: params.proporcion_gravada,
            tasa_iva: params.tasa_iva,
            ajuste,
            fecha_reserva: aFecha(hoy),
            reserva_vence: aFecha(sumarDias(hoy, params.dias_reserva)),
            notas: dto.notas?.trim() || null,
            created_by: user_id ?? null,
            inm_operacion_titulares: { create: titulares.map((t) => ({ empresa_id, ...t })) },
            inm_operacion_cuotas: {
              create: senas.map((c, i) => ({
                empresa_id,
                nro: i + 1,
                tipo: c.tipo,
                descripcion: c.descripcion,
                fecha_vencimiento: aFecha(c.fecha_vencimiento),
                monto: c.monto,
              })),
            },
          },
        });
        if (cotizacion_id) {
          await tx.inm_cotizaciones.update({
            where: { id: cotizacion_id },
            data: { estado: 'ACEPTADA', updated_at: new Date() },
          });
        }
        return creada;
      });
      await this.audit.log({
        empresa_id,
        user_id,
        action: 'CREAR',
        entity_type: 'inm_operaciones',
        entity_id: op.id,
        new_value: { numero: op.numero, unidad: unidad.codigo, precio: opcion.precio_final },
      });
      await this.comisiones.devengar(op.id, empresa_id);
      return this.detalle(op.id, empresa_id);
    } catch (e) {
      if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
        throw new ConflictException('Otra venta tomó esta unidad al mismo tiempo. Recargá y elegí otra.');
      }
      throw e;
    }
  }

  async prorrogarReserva(id: string, dto: ProrrogarReservaDto, empresa_id: string, user_id?: string) {
    const op = await this.deEmpresa(id, empresa_id);
    this.exigirEstado(op.estado, ['RESERVADA'], 'prorrogar la reserva');
    const base = isoDia(op.reserva_vence) ?? hoyPy();
    const desde = base < hoyPy() ? hoyPy() : base;
    const r = await this.prisma.inm_operaciones.update({
      where: { id },
      data: {
        reserva_vence: aFecha(sumarDias(desde, dto.dias)),
        reserva_prorrogas: { increment: 1 },
        updated_at: new Date(),
      },
    });
    await this.audit.log({
      empresa_id,
      user_id,
      action: 'EDITAR',
      entity_type: 'inm_operaciones',
      entity_id: id,
      old_value: { reserva_vence: op.reserva_vence },
      new_value: { reserva_vence: r.reserva_vence, motivo: dto.motivo },
    });
    return this.detalle(id, empresa_id);
  }

  /**
   * El comprador desiste antes del boleto: la unidad vuelve a estar disponible y las
   * cuotas pendientes se anulan. Si la seña ya se cobró, su devolución (total o con
   * retención) se documenta con una nota de crédito sobre su factura.
   */
  async desistir(id: string, dto: MotivoDto, empresa_id: string, user_id?: string) {
    const op = await this.deEmpresa(id, empresa_id);
    this.exigirEstado(op.estado, ['RESERVADA'], 'desistir (después del boleto es una rescisión)');
    await this.prisma.$transaction(async (tx) => {
      await tx.inm_operaciones.update({
        where: { id },
        data: { estado: 'DESISTIDA', motivo_baja: dto.motivo.trim(), updated_at: new Date() },
      });
      await tx.inm_operacion_cuotas.updateMany({
        where: { operacion_id: id, estado: 'PENDIENTE' },
        data: { estado: 'ANULADA', updated_at: new Date() },
      });
      await transicionarUnidad(tx, { id: op.unidad_id, empresa_id, desde: ['RESERVADA'], hacia: 'DISPONIBLE' });
    });
    await this.audit.log({
      empresa_id,
      user_id,
      action: 'ANULAR',
      entity_type: 'inm_operaciones',
      entity_id: id,
      new_value: { estado: 'DESISTIDA', motivo: dto.motivo },
    });
    await this.comisiones.revertir(id, empresa_id, `Desistida: ${dto.motivo}`);
    const senaCobrada = await this.prisma.inm_cobros.count({ where: { operacion_id: id, estado: 'EMITIDO' } });
    return {
      ...(await this.detalle(id, empresa_id)),
      aviso: senaCobrada
        ? 'La seña ya estaba cobrada: si corresponde devolverla, emití una nota de crédito sobre su factura.'
        : null,
    };
  }

  // ------------------------------------------------------- titulares y legajo

  async agregarTitular(id: string, dto: TitularDto, empresa_id: string, user_id?: string) {
    const op = await this.deEmpresa(id, empresa_id);
    this.exigirEstado(op.estado, ['RESERVADA'], 'cambiar titulares (después del boleto es una cesión)');
    const c = await datosClientes(this.prisma, empresa_id, [dto.cliente_id]);
    if (!c.size) throw new BadRequestException('El cliente no existe en esta empresa');
    try {
      await this.prisma.inm_operacion_titulares.create({
        data: {
          empresa_id,
          operacion_id: id,
          cliente_id: dto.cliente_id,
          rol: dto.rol ?? 'COTITULAR',
          porcentaje: dto.porcentaje ?? 0,
        },
      });
    } catch (e) {
      if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002')
        throw new ConflictException('Ese cliente ya es titular');
      throw e;
    }
    await this.audit.log({
      empresa_id,
      user_id,
      action: 'EDITAR',
      entity_type: 'inm_operaciones',
      entity_id: id,
      new_value: { titular_agregado: dto },
    });
    return this.detalle(id, empresa_id);
  }

  async actualizarTitular(id: string, titularId: string, dto: TitularDto, empresa_id: string, user_id?: string) {
    const op = await this.deEmpresa(id, empresa_id);
    this.exigirEstado(op.estado, ['RESERVADA'], 'cambiar titulares');
    const t = await this.prisma.inm_operacion_titulares.findFirst({
      where: { id: titularId, operacion_id: id, empresa_id },
    });
    if (!t) throw new NotFoundException('Titular no encontrado');
    await this.prisma.inm_operacion_titulares.update({
      where: { id: titularId },
      data: { ...(dto.rol && { rol: dto.rol }), ...(dto.porcentaje !== undefined && { porcentaje: dto.porcentaje }) },
    });
    await this.audit.log({
      empresa_id,
      user_id,
      action: 'EDITAR',
      entity_type: 'inm_operaciones',
      entity_id: id,
      old_value: t,
      new_value: { ...dto },
    });
    return this.detalle(id, empresa_id);
  }

  async quitarTitular(id: string, titularId: string, empresa_id: string, user_id?: string) {
    const op = await this.deEmpresa(id, empresa_id);
    this.exigirEstado(op.estado, ['RESERVADA'], 'cambiar titulares');
    const t = await this.prisma.inm_operacion_titulares.findFirst({
      where: { id: titularId, operacion_id: id, empresa_id },
    });
    if (!t) throw new NotFoundException('Titular no encontrado');
    if (t.cliente_id === op.cliente_id)
      throw new BadRequestException('El titular principal no se quita: es a quien se factura');
    await this.prisma.inm_operacion_titulares.delete({ where: { id: titularId } });
    await this.audit.log({
      empresa_id,
      user_id,
      action: 'EDITAR',
      entity_type: 'inm_operaciones',
      entity_id: id,
      old_value: { titular_quitado: t },
    });
    return this.detalle(id, empresa_id);
  }

  async actualizarLegajo(id: string, dto: LegajoDto, empresa_id: string, user_id?: string) {
    const op = await this.deEmpresa(id, empresa_id);
    const data: Prisma.inm_operacionesUncheckedUpdateInput = {
      ...(dto.pep !== undefined && { legajo_pep: dto.pep }),
      ...(dto.origen_fondos !== undefined && { legajo_origen_fondos: dto.origen_fondos?.trim() || null }),
      ...(dto.observaciones !== undefined && { legajo_observaciones: dto.observaciones?.trim() || null }),
      updated_at: new Date(),
    };
    // Cambiar los datos de un legajo aprobado lo vuelve a dejar pendiente de revisión.
    if (op.legajo_estado === 'APROBADO' && (dto.pep !== undefined || dto.origen_fondos !== undefined)) {
      Object.assign(data, { legajo_estado: 'PENDIENTE', legajo_aprobado_por: null, legajo_aprobado_at: null });
    }
    await this.prisma.inm_operaciones.update({ where: { id }, data });
    await this.audit.log({
      empresa_id,
      user_id,
      action: 'EDITAR',
      entity_type: 'inm_operaciones',
      entity_id: id,
      new_value: { legajo: dto },
    });
    return this.detalle(id, empresa_id);
  }

  async aprobarLegajo(id: string, empresa_id: string, user_id?: string) {
    const op = await this.deEmpresa(id, empresa_id);
    if (!op.legajo_origen_fondos?.trim()) {
      throw new UnprocessableEntityException('Cargá el origen de los fondos antes de aprobar el legajo');
    }
    await this.prisma.inm_operaciones.update({
      where: { id },
      data: {
        legajo_estado: 'APROBADO',
        legajo_aprobado_por: user_id ?? null,
        legajo_aprobado_at: new Date(),
        updated_at: new Date(),
      },
    });
    await this.audit.log({
      empresa_id,
      user_id,
      action: 'APROBAR',
      entity_type: 'inm_operaciones',
      entity_id: id,
      new_value: { legajo_estado: 'APROBADO' },
    });
    return this.detalle(id, empresa_id);
  }

  async observarLegajo(id: string, dto: MotivoDto, empresa_id: string, user_id?: string) {
    await this.deEmpresa(id, empresa_id);
    await this.prisma.inm_operaciones.update({
      where: { id },
      data: {
        legajo_estado: 'OBSERVADO',
        legajo_observaciones: dto.motivo.trim(),
        legajo_aprobado_por: null,
        legajo_aprobado_at: null,
        updated_at: new Date(),
      },
    });
    await this.audit.log({
      empresa_id,
      user_id,
      action: 'EDITAR',
      entity_type: 'inm_operaciones',
      entity_id: id,
      new_value: { legajo_estado: 'OBSERVADO', motivo: dto.motivo },
    });
    return this.detalle(id, empresa_id);
  }

  // ------------------------------------------------------------------- boleto

  /** Vista previa del boleto sin firmar nada (para revisarlo con el cliente). */
  async previsualizarBoleto(id: string, empresa_id: string, fecha?: string) {
    const op = await this.deEmpresa(id, empresa_id);
    const fechaBoleto = fecha?.slice(0, 10) ?? isoDia(op.fecha_boleto) ?? hoyPy();
    const cuotas =
      op.estado === 'RESERVADA' ? await this.cronogramaCompleto(op, fechaBoleto) : await this.cuotasDeOperacion(id);
    return { html: await this.renderizarBoleto(op, fechaBoleto, cuotas) };
  }

  /**
   * Firma del boleto: exige legajo de la operación aprobado y legajo de debida diligencia
   * vigente de cada comprador (422 si no, criterio del spec), la seña
   * cobrada y titulares que sumen 100 %. Genera el resto del cronograma con la fecha
   * de firma como base, fija el IPC de firma (ventas en guaraníes) y guarda el texto.
   */
  async firmarBoleto(id: string, dto: FirmarBoletoDto, empresa_id: string, user_id?: string) {
    const op = await this.deEmpresa(id, empresa_id);
    this.exigirEstado(op.estado, ['RESERVADA'], 'firmar el boleto');
    if (op.legajo_estado !== 'APROBADO')
      throw new UnprocessableEntityException('El legajo del comprador no está aprobado');
    const senaPendiente = await this.prisma.inm_operacion_cuotas.count({
      where: { operacion_id: id, tipo: 'SENA', estado: { in: ['PENDIENTE', 'PARCIAL'] } },
    });
    if (senaPendiente) throw new UnprocessableEntityException('Falta cobrar la seña');
    const titulares = await this.prisma.inm_operacion_titulares.findMany({ where: { operacion_id: id } });
    const suma = titulares.reduce((a, t) => a + Number(t.porcentaje), 0);
    if (Math.abs(suma - 100) > 0.001)
      throw new UnprocessableEntityException(
        `Los porcentajes de los titulares suman ${suma} %, tienen que sumar 100 %`,
      );
    // Debida diligencia (M123): cada comprador y cotitular con legajo aprobado y vigente.
    const impedimentos = await this.legajosCliente.impedimentosBoleto(empresa_id, [
      op.cliente_id,
      ...titulares.map((t) => t.cliente_id),
    ]);
    if (impedimentos.length)
      throw new UnprocessableEntityException(
        `Debida diligencia incompleta. ${impedimentos.join('; ')}. Completalo en Inmobiliario → Cumplimiento.`,
      );

    const fechaBoleto = dto.fecha_boleto?.slice(0, 10) ?? hoyPy();
    if (fechaBoleto > hoyPy()) throw new BadRequestException('La fecha del boleto no puede ser futura');

    let indice: { periodo: Date; valor: Prisma.Decimal } | null = null;
    if (op.ajuste === 'IPC_BCP') {
      indice = await this.indices.vigente(empresa_id, fechaBoleto);
      if (!indice) {
        throw new UnprocessableEntityException(
          'No hay IPC cargado para la fecha de firma. Cargalo en Configuración inmobiliaria → IPC del BCP.',
        );
      }
    }

    const cronograma = await this.cronogramaCompleto(op, fechaBoleto);
    const nuevas = cronograma.filter((c) => c.tipo !== 'SENA');
    const html = await this.renderizarBoleto(op, fechaBoleto, cronograma);

    await this.prisma.$transaction(async (tx) => {
      const existentes = await tx.inm_operacion_cuotas.count({ where: { operacion_id: id } });
      if (nuevas.length) {
        await tx.inm_operacion_cuotas.createMany({
          data: nuevas.map((c, i) => ({
            empresa_id,
            operacion_id: id,
            nro: existentes + i + 1,
            tipo: c.tipo,
            descripcion: c.descripcion,
            fecha_vencimiento: aFecha(c.fecha_vencimiento) ?? null,
            hito_id: c.hito_id,
            monto: c.monto,
          })),
        });
      }
      await tx.inm_operaciones.update({
        where: { id },
        data: {
          estado: nuevas.length ? 'BOLETO_FIRMADO' : 'CANCELADA_TOTAL',
          fecha_boleto: aFecha(fechaBoleto),
          boleto_html: html,
          indice_firma_periodo: indice?.periodo ?? null,
          indice_firma_valor: indice?.valor ?? null,
          updated_at: new Date(),
        },
      });
      await transicionarUnidad(tx, { id: op.unidad_id, empresa_id, desde: ['RESERVADA'], hacia: 'VENDIDA' });
    });
    await this.audit.log({
      empresa_id,
      user_id,
      action: 'CONFIRMAR',
      entity_type: 'inm_operaciones',
      entity_id: id,
      new_value: { estado: 'BOLETO_FIRMADO', fecha_boleto: fechaBoleto, cuotas: nuevas.length },
    });
    await this.comisiones.devengar(id, empresa_id);
    return this.detalle(id, empresa_id);
  }

  /** Fija el vencimiento de una cuota pendiente (ej. la de posesión cuando se conoce la fecha). */
  async actualizarCuota(id: string, cuotaId: string, dto: ActualizarCuotaDto, empresa_id: string, user_id?: string) {
    await this.deEmpresa(id, empresa_id);
    const c = await this.prisma.inm_operacion_cuotas.findFirst({
      where: { id: cuotaId, operacion_id: id, empresa_id },
    });
    if (!c) throw new NotFoundException('Cuota no encontrada');
    if (c.estado === 'PAGADA' || c.estado === 'ANULADA') throw new BadRequestException('La cuota ya no está pendiente');
    await this.prisma.inm_operacion_cuotas.update({
      where: { id: cuotaId },
      data: { fecha_vencimiento: aFecha(dto.fecha_vencimiento), updated_at: new Date() },
    });
    await this.audit.log({
      empresa_id,
      user_id,
      action: 'EDITAR',
      entity_type: 'inm_operacion_cuotas',
      entity_id: cuotaId,
      old_value: { fecha_vencimiento: c.fecha_vencimiento },
      new_value: { ...dto },
    });
    return this.detalle(id, empresa_id);
  }

  // ------------------------------------------------------------------ lecturas

  async listar(empresa_id: string, f: { proyecto_id?: string; estado?: inm_operacion_estado; q?: string }) {
    const q = f.q?.trim();
    const filas = await this.prisma.inm_operaciones.findMany({
      where: {
        empresa_id,
        ...(f.proyecto_id && { proyecto_id: f.proyecto_id }),
        ...(f.estado && { estado: f.estado }),
        ...(q && {
          OR: [
            { numero: { contains: q, mode: 'insensitive' } },
            { inm_unidades: { codigo: { contains: q, mode: 'insensitive' } } },
          ],
        }),
      },
      include: {
        moneda: { select: { codigo: true } },
        inm_unidades: { select: { id: true, codigo: true, tipo: true } },
        inm_proyectos: { select: { id: true, codigo: true, nombre: true } },
        inm_operacion_cuotas: { select: { monto: true, monto_cobrado: true, estado: true } },
      },
      orderBy: { created_at: 'desc' },
      take: 500,
    });
    const clientes = await datosClientes(
      this.prisma,
      empresa_id,
      filas.map((o) => o.cliente_id),
    );
    return filas.map(({ inm_operacion_cuotas: cuotas, ...conBoleto }) => {
      const { boleto_html, ...o } = conBoleto;
      void boleto_html;
      const cobrado = cuotas.filter((c) => c.estado !== 'ANULADA').reduce((a, c) => a + Number(c.monto_cobrado), 0);
      return {
        ...o,
        cliente: clienteDe(clientes, o.cliente_id),
        cobrado,
        pct_pagado: Number(o.precio_final) > 0 ? Math.round((cobrado / Number(o.precio_final)) * 10000) / 100 : 0,
      };
    });
  }

  async detalle(id: string, empresa_id: string) {
    const op = await this.prisma.inm_operaciones.findFirst({
      where: { id, empresa_id },
      include: {
        moneda: { select: { id: true, codigo: true } },
        inm_unidades: true,
        inm_proyectos: { select: { id: true, codigo: true, nombre: true, ciudad: true, direccion: true } },
        inm_cotizaciones: { select: { id: true, numero: true } },
        inm_operacion_titulares: true,
        inm_operacion_cuotas: {
          orderBy: { nro: 'asc' },
          include: { inm_hitos: { select: { id: true, nombre: true, fecha_cumplido: true } } },
        },
        inm_adendas: { orderBy: { created_at: 'desc' } },
        inm_escrituras: { include: { inm_escritura_tareas: { orderBy: [{ orden: 'asc' }, { nombre: 'asc' }] } } },
        inm_entregas: {
          include: { inm_entrega_observaciones: { orderBy: [{ estado: 'asc' }, { created_at: 'asc' }] } },
        },
        inm_comisiones: {
          orderBy: { devengada_at: 'asc' },
          include: { inm_comision_esquemas: { select: { nombre: true, pct: true } } },
        },
        inm_cobros: {
          orderBy: { created_at: 'desc' },
          include: {
            inm_cobro_cuotas: {
              select: { cuota_id: true, monto_capital: true, monto_ajuste: true, monto_facturado: true },
            },
          },
        },
      },
    });
    if (!op) throw new NotFoundException('Operación no encontrada');
    const clientes = await datosClientes(this.prisma, empresa_id, [
      op.cliente_id,
      ...op.inm_operacion_titulares.map((t) => t.cliente_id),
    ]);
    const facturas = await this.prisma.factura_cab.findMany({
      where: { id: { in: op.inm_cobros.map((c) => c.factura_cab_id).filter((x): x is string => !!x) } },
      select: { id: true, dest: true, dpunexp: true, dnumdoc: true, estado: true },
    });
    const facturaPorId = new Map(facturas.map((f) => [f.id, { ...f, numero: `${f.dest}-${f.dpunexp}-${f.dnumdoc}` }]));
    const params = await this.parametros.obtener(empresa_id);
    const ec = this.estadoCuenta(op, Number(params.tasa_mora_mensual));
    const debidaDiligencia = await this.legajosCliente.estadoClientes(empresa_id, [
      op.cliente_id,
      ...op.inm_operacion_titulares.map((t) => t.cliente_id),
    ]);
    const { boleto_html, ...resto } = op;
    return {
      ...resto,
      tiene_boleto: Boolean(boleto_html),
      cliente: clienteDe(clientes, op.cliente_id),
      inm_operacion_titulares: op.inm_operacion_titulares.map((t) => ({
        ...t,
        cliente: clienteDe(clientes, t.cliente_id),
      })),
      inm_cobros: op.inm_cobros.map((c) => ({
        ...c,
        factura: c.factura_cab_id ? (facturaPorId.get(c.factura_cab_id) ?? null) : null,
      })),
      estado_cuenta: ec,
      debida_diligencia: {
        ...debidaDiligencia,
        clientes: debidaDiligencia.clientes.map((c) => ({ ...c, cliente: clienteDe(clientes, c.cliente_id) })),
      },
    };
  }

  async boleto(id: string, empresa_id: string) {
    const op = await this.prisma.inm_operaciones.findFirst({
      where: { id, empresa_id },
      select: { numero: true, boleto_html: true },
    });
    if (!op) throw new NotFoundException('Operación no encontrada');
    if (!op.boleto_html) throw new NotFoundException('El boleto todavía no se firmó');
    return { numero: op.numero, html: op.boleto_html };
  }

  estadoCuenta(
    op: {
      moneda: { codigo: string | null } | null;
      precio_final: unknown;
      inm_operacion_cuotas: Array<Record<string, any>>;
    },
    tasaMora: number,
  ) {
    const hitos = new Map<string, string>();
    for (const c of op.inm_operacion_cuotas) {
      if (c.inm_hitos?.fecha_cumplido) hitos.set(c.inm_hitos.id, isoDia(c.inm_hitos.fecha_cumplido));
    }
    return calcularEstadoCuenta(
      op.inm_operacion_cuotas.map((c) => ({
        id: c.id,
        nro: c.nro,
        tipo: c.tipo,
        descripcion: c.descripcion,
        fecha_vencimiento: isoDia(c.fecha_vencimiento),
        hito_id: c.hito_id,
        monto: Number(c.monto),
        monto_cobrado: Number(c.monto_cobrado),
        estado: c.estado,
      })),
      {
        hoy: hoyPy(),
        hitosCumplidos: hitos,
        tasaMoraMensual: tasaMora,
        decimales: decimalesDe(op.moneda?.codigo),
        precio: Number(op.precio_final),
      },
    );
  }

  // ---------------------------------------------------------------- auxiliares

  private cronograma(precio: number, tramos: TramoPlan[], fecha: string, monedaCodigo: string) {
    try {
      return generarCronograma(precio, tramos, fecha, decimalesDe(monedaCodigo));
    } catch (e) {
      if (e instanceof CronogramaError) throw new BadRequestException(e.message);
      throw e;
    }
  }

  /** Cronograma entero con la fecha de firma: la seña es la ya creada, el resto se genera. */
  private async cronogramaCompleto(
    op: { id: string; precio_final: unknown; tramos: unknown; moneda_id: string },
    fecha: string,
  ) {
    const moneda = await this.prisma.moneda.findUnique({ where: { id: op.moneda_id }, select: { codigo: true } });
    const generado = this.cronograma(Number(op.precio_final), op.tramos as TramoPlan[], fecha, moneda?.codigo ?? 'PYG');
    const senas = await this.prisma.inm_operacion_cuotas.findMany({
      where: { operacion_id: op.id, tipo: 'SENA' },
      orderBy: { nro: 'asc' },
    });
    const generadasSena = generado.filter((c) => c.tipo === 'SENA');
    const totalSenaGen = generadasSena.reduce((a, c) => a + c.monto, 0);
    const totalSenaReal = senas.reduce((a, c) => a + Number(c.monto), 0);
    if (Math.abs(totalSenaGen - totalSenaReal) > 0.001) {
      throw new ConflictException('La seña registrada no coincide con el plan de la operación');
    }
    const resto = generado.filter((c) => c.tipo !== 'SENA');
    return [
      ...senas.map((s) => ({
        nro: s.nro,
        tipo: s.tipo,
        descripcion: s.descripcion ?? 'Seña',
        fecha_vencimiento: isoDia(s.fecha_vencimiento),
        hito_id: null,
        monto: Number(s.monto),
      })),
      ...resto.map((c, i) => ({ ...c, nro: senas.length + i + 1 })),
    ];
  }

  private async cuotasDeOperacion(id: string) {
    const cuotas = await this.prisma.inm_operacion_cuotas.findMany({
      where: { operacion_id: id, estado: { not: 'ANULADA' } },
      orderBy: { nro: 'asc' },
    });
    return cuotas.map((c) => ({
      nro: c.nro,
      tipo: c.tipo,
      descripcion: c.descripcion ?? '',
      fecha_vencimiento: isoDia(c.fecha_vencimiento),
      hito_id: c.hito_id,
      monto: Number(c.monto),
    }));
  }

  private async renderizarBoleto(
    op: Awaited<ReturnType<OperacionesInmService['deEmpresa']>>,
    fecha: string,
    cuotas: Array<{
      nro: number;
      descripcion: string;
      fecha_vencimiento: string | null;
      hito_id: string | null;
      monto: number;
    }>,
  ) {
    const [empresa, proyecto, unidad, moneda, titulares, params] = await Promise.all([
      this.prisma.empresas.findUnique({
        where: { id: op.empresa_id },
        select: { razon_social: true, ruc: true, dv: true },
      }),
      this.prisma.inm_proyectos.findUnique({ where: { id: op.proyecto_id } }),
      this.prisma.inm_unidades.findUnique({ where: { id: op.unidad_id } }),
      this.prisma.moneda.findUnique({ where: { id: op.moneda_id }, select: { codigo: true } }),
      this.prisma.inm_operacion_titulares.findMany({ where: { operacion_id: op.id } }),
      this.parametros.obtener(op.empresa_id),
    ]);
    const hitos = await this.prisma.inm_hitos.findMany({
      where: { id: { in: cuotas.map((c) => c.hito_id).filter((x): x is string => !!x) }, empresa_id: op.empresa_id },
      select: { id: true, nombre: true },
    });
    const hitoNombre = new Map(hitos.map((h) => [h.id, h.nombre]));
    const clientes = await datosClientes(this.prisma, op.empresa_id, [
      op.cliente_id,
      ...titulares.map((t) => t.cliente_id),
    ]);
    const principal = clientes.get(op.cliente_id);
    const descripcion = [
      TIPO_UNIDAD[unidad?.tipo ?? 'OTRO'],
      unidad?.piso != null ? `piso ${unidad.piso}` : null,
      unidad?.manzana ? `manzana ${unidad.manzana}` : null,
      unidad?.lote ? `lote ${unidad.lote}` : null,
      unidad?.tipologia,
      unidad?.m2_propios ? `${Number(unidad.m2_propios)} m² propios` : null,
      unidad?.superficie_terreno ? `${Number(unidad.superficie_terreno)} m² de terreno` : null,
    ]
      .filter(Boolean)
      .join(', ');
    return renderBoleto(params.plantilla_boleto, {
      fecha,
      operacion: { numero: op.numero },
      empresa: {
        razon_social: empresa?.razon_social ?? '',
        ruc: empresa?.ruc ? `${empresa.ruc}${empresa.dv ? `-${empresa.dv}` : ''}` : '',
      },
      comprador: { nombre: principal?.nombre ?? '', documento: principal?.documento ?? '' },
      cotitulares: titulares
        .filter((t) => t.cliente_id !== op.cliente_id)
        .map((t) => ({
          nombre: clientes.get(t.cliente_id)?.nombre ?? '',
          documento: clientes.get(t.cliente_id)?.documento ?? '',
          rol: t.rol,
          porcentaje: Number(t.porcentaje),
        })),
      proyecto: {
        nombre: proyecto?.nombre ?? '',
        direccion: proyecto?.direccion ?? '',
        ciudad: proyecto?.ciudad ?? '',
      },
      unidad: {
        codigo: unidad?.codigo ?? '',
        descripcion,
        cta_catastral: unidad?.cta_catastral ?? '',
        finca: unidad?.finca ?? '',
      },
      moneda: moneda?.codigo ?? 'PYG',
      precio: Number(op.precio_final),
      ajuste: op.ajuste,
      cuotas: cuotas.map((c) => ({ ...c, hito: c.hito_id ? (hitoNombre.get(c.hito_id) ?? null) : null })),
    });
  }

  async validarFacturable(cliente_id: string, empresa_id: string) {
    const cliente = await this.prisma.clientes.findFirst({
      where: { id: cliente_id, personas: { empresa_id } },
      select: { id: true, persona_id: true, tipo_operacion: { select: { codigo: true } } },
    });
    if (!cliente) throw new BadRequestException('El cliente no existe en esta empresa');
    const errores = await this.personas.validatePersonaById(cliente.persona_id, empresa_id, {
      tipoOperacionCodigo: cliente.tipo_operacion?.codigo ?? null,
    });
    if (errores.length) {
      throw new BadRequestException({
        message:
          'El comprador tiene datos incompletos para facturarle. Corregilos en su ficha de cliente antes de reservar:',
        errores,
        cliente_id,
      });
    }
  }

  private normalizarTitulares(principal: string, extra: TitularDto[] = []) {
    const otros = extra.filter((t) => t.cliente_id !== principal);
    const pctOtros = otros.reduce((a, t) => a + Number(t.porcentaje ?? 0), 0);
    return [
      { cliente_id: principal, rol: 'TITULAR' as const, porcentaje: Math.max(0, 100 - pctOtros) },
      ...otros.map((t) => ({
        cliente_id: t.cliente_id,
        rol: t.rol ?? ('COTITULAR' as const),
        porcentaje: Number(t.porcentaje ?? 0),
      })),
    ];
  }

  private exigirEstado(actual: inm_operacion_estado, permitidos: inm_operacion_estado[], accion: string) {
    if (!permitidos.includes(actual)) {
      throw new BadRequestException(
        `No se puede ${accion}: la operación está ${actual.toLowerCase().replace('_', ' ')}`,
      );
    }
  }

  private async monedaDeUnidad(unidad_id: string) {
    const u = await this.prisma.inm_unidades.findUnique({
      where: { id: unidad_id },
      include: { moneda: { select: { codigo: true } } },
    });
    return u?.moneda?.codigo ?? 'PYG';
  }

  async deEmpresa(id: string, empresa_id: string) {
    const op = await this.prisma.inm_operaciones.findFirst({ where: { id, empresa_id } });
    if (!op) throw new NotFoundException('Operación no encontrada');
    return op;
  }
}
