import { BadRequestException, ForbiddenException, Injectable, NotFoundException, Logger } from '@nestjs/common';
import { ContabilidadIntegracionService } from '../../contabilidad/services/integracion.service';
import { PrismaService } from '../../prisma/prisma.service';
import { resolverOrigenes, type DatosDocumento } from './origen-movimiento';
import {
  AnularTesMovimientoDto,
  CreateTesMovimientoDto,
} from '../dto/tes-movimiento.dto';
import { UpdateTesMovimientoDto } from '../dto/update-tes-movimiento.dto';

const UMBRAL_APROBACION_DEFAULT = 0; // 0 = sin límite (aprobación desactivada por defecto)

// Movimientos generados automáticamente por otro módulo (origen_tipo no nulo) no
// se anulan desde acá: si se anulara solo el movimiento, el documento de origen
// (Gasto, OP, Rendición, etc.) quedaría desincronizado sin saberlo. Se debe
// anular desde la pantalla de origen, que revierte todo en conjunto.
const ORIGEN_TIPO_LABELS: Record<string, string> = {
  ORDEN_PAGO_PROVEEDOR: 'la Orden de Pago',
  gasto_cab: 'el Gasto',
  RENDICION_ADELANTO: 'la Rendición de Viáticos (adelanto)',
  RENDICION_DEVOLUCION: 'la Rendición de Viáticos (devolución/reintegro)',
  recibos_multi: 'el Recibo',
  rrhh_acreditacion: 'la Acreditación Bancaria',
  rrhh_desvinculacion: 'la Desvinculación',
  rrhh_reporte_ips: 'el Reporte IPS',
  // Cheques: el movimiento es el reflejo bancario del cheque. Anularlo suelto
  // revertiría el saldo pero dejaría el cheque en DEPOSITADO/DEBITADO apuntando
  // a un movimiento anulado — plata que no cuadra con la cartera de cheques.
  CHEQUE_DEPOSITADO: 'el Cheque (Tesorería › Cheques → Rechazar)',
  CHEQUE_DEBITADO: 'el Cheque (Tesorería › Cheques → Anular)',
  CHEQUE_RECHAZADO: 'el Cheque que originó esta reversa',
  CHEQUE_ANULADO: 'el Cheque que originó esta reversa',
  TRANSFERENCIA_ORIGEN: 'la Transferencia (Tesorería › Transferencias)',
  TRANSFERENCIA_DESTINO: 'la Transferencia (Tesorería › Transferencias)',
};

/**
 * Convierte a número lo tecleado en el buscador si es un importe escrito como se
 * muestra ("Gs. 350.000", "350.000", "1.379.980,50"). Devuelve null si no es un
 * importe, para no ensuciar la búsqueda de texto.
 */
function parseMontoBuscado(texto: string): number | null {
  const limpio = texto.replace(/gs\.?/i, '').replace(/[\s.]/g, '').replace(',', '.');
  if (!/^\d+(\.\d+)?$/.test(limpio)) return null;
  const n = Number(limpio);
  return Number.isFinite(n) ? n : null;
}

@Injectable()
export class TesMovimientosService {
  private readonly logger = new Logger(TesMovimientosService.name);
  constructor(
    private readonly prisma: PrismaService,
    private readonly contabilidad: ContabilidadIntegracionService,
  ) {}

  async create(dto: CreateTesMovimientoDto, empresaId: string, userId: string) {
    // 1. Verificar que la cuenta pertenece a la empresa
    const cuenta = await this.prisma.tes_cuentas.findFirst({
      where: { id: dto.cuenta_id, empresa_id: empresaId, activo: true },
    });
    if (!cuenta) {
      throw new NotFoundException(`Cuenta de tesorería ${dto.cuenta_id} no encontrada o inactiva`);
    }

    // 2. Verificar config: ¿el monto supera el umbral? → requiere_aprobacion
    // Convención de la UI: umbral=0 desactiva la aprobación por monto.
    // Si no hay config persistida, se aplica el default.
    const config = await this.prisma.tes_config.findFirst({
      where: { empresa_id: empresaId },
    });
    const umbralRaw = config?.monto_umbral_aprobacion;
    const umbral = umbralRaw == null ? UMBRAL_APROBACION_DEFAULT : Number(umbralRaw);
    const requiereAprobacion = umbral > 0 && dto.monto >= umbral;

    // 2b. Pago imputado a una importación: el embarque tiene que existir, ser de
    // la empresa y aceptar costos todavía. Se valida acá y no al contabilizar
    // porque para entonces el movimiento ya está guardado.
    if (dto.embarque_id || dto.tipo_pago_importacion) {
      if (!dto.embarque_id || !dto.tipo_pago_importacion) {
        throw new BadRequestException(
          'Para imputar un pago a una importación hacen falta embarque_id y tipo_pago_importacion.',
        );
      }
      const embarque = await this.prisma.imp_embarques.findFirst({
        where: { id: dto.embarque_id, empresa_id: empresaId },
        select: { estado: true, numero: true },
      });
      if (!embarque) throw new NotFoundException('El embarque indicado no existe');
      if (['CERRADO', 'SELLADO'].includes(String(embarque.estado))) {
        throw new BadRequestException(
          `No se puede imputar un pago a un embarque ${embarque.estado}.`,
        );
      }
      // El cliente pidió un solo pago de cada tipo por importación. Hay un índice
      // único que lo garantiza, pero el mensaje del índice no le dice nada al usuario.
      const yaExiste = await this.prisma.tes_movimientos.findFirst({
        where: {
          embarque_id: dto.embarque_id,
          tipo_pago_importacion: dto.tipo_pago_importacion,
          estado: { not: 'ANULADO' },
        },
        select: { id: true },
      });
      if (yaExiste) {
        throw new BadRequestException(
          `El embarque ${embarque.numero} ya tiene un pago de tipo ${dto.tipo_pago_importacion}. Anulá el anterior si querés reemplazarlo.`,
        );
      }
    }

    // 3. Crear tes_movimientos con estado BORRADOR
    const movimiento = await this.prisma.$transaction(async (tx) => {
      const mov = await tx.tes_movimientos.create({
        data: {
          empresa_id: empresaId,
          cuenta_id: dto.cuenta_id,
          tipo: dto.tipo,
          fecha: new Date(dto.fecha),
          monto: dto.monto,
          descripcion: dto.descripcion ?? null,
          referencia: dto.referencia ?? null,
          estado: 'BORRADOR',
          requiere_aprobacion: requiereAprobacion,
          embarque_id: dto.embarque_id ?? null,
          tipo_pago_importacion: dto.tipo_pago_importacion ?? null,
          created_by: userId,
        },
      });

      // 4. Crear tes_movimiento_det por cada categoría
      if (dto.categorias && dto.categorias.length > 0) {
        await tx.tes_movimiento_det.createMany({
          data: dto.categorias.map((cat) => ({
            movimiento_id: mov.id,
            categoria_id: cat.categoria_id,
            monto: cat.monto,
            descripcion: cat.descripcion ?? null,
            cuenta_contable_id: cat.cuenta_contable_id ?? null,
          })),
        });
      }

      return mov;
    });

    this.logger.log(`Movimiento ${movimiento.id} creado en BORRADOR por usuario ${userId}`);
    return movimiento;
  }

  async confirmar(id: string, empresaId: string, userId: string) {
    // 1. Buscar movimiento en estado BORRADOR
    const movimiento = await this.prisma.tes_movimientos.findFirst({
      where: { id, empresa_id: empresaId },
    });
    if (!movimiento) {
      throw new NotFoundException(`Movimiento ${id} no encontrado`);
    }
    if (movimiento.estado !== 'BORRADOR') {
      throw new BadRequestException(`El movimiento no está en estado BORRADOR (estado actual: ${movimiento.estado})`);
    }

    // 2. Si requiere_aprobacion y no está aprobado → 403
    if (movimiento.requiere_aprobacion && !movimiento.aprobado_por) {
      throw new ForbiddenException('Este movimiento requiere aprobación previa antes de confirmarse');
    }

    // 3 & 4. Actualizar estado y saldo en transacción
    const resultado = await this.prisma.$transaction(async (tx) => {
      const mov = await tx.tes_movimientos.update({
        where: { id },
        data: {
          estado: 'CONFIRMADO',
          confirmado_por: userId,
          confirmado_en: new Date(),
        },
      });

      // Actualizar saldo: INGRESO += monto, EGRESO -= monto
      const delta = movimiento.tipo === 'INGRESO'
        ? Number(movimiento.monto)
        : -Number(movimiento.monto);

      await tx.tes_cuentas.update({
        where: { id: movimiento.cuenta_id as string },
        data: {
          saldo_actual: {
            increment: delta,
          },
        },
      });

      return mov;
    });

    // Integración contable — si la empresa no tiene el módulo activo, este método hace nada
    this.contabilidad.integrarMovimientoTesoreria(resultado.id).catch((e) =>
      this.logger.error(`Error al contabilizar movimiento ${id}: ${e.message}`),
    );

    this.logger.log(`Movimiento ${id} CONFIRMADO por usuario ${userId}`);
    return resultado;
  }

  async aprobar(id: string, empresaId: string, userId: string) {
    const movimiento = await this.prisma.tes_movimientos.findFirst({
      where: { id, empresa_id: empresaId },
    });
    if (!movimiento) {
      throw new NotFoundException(`Movimiento ${id} no encontrado`);
    }
    if (movimiento.estado !== 'BORRADOR') {
      throw new BadRequestException(`Solo se pueden aprobar movimientos en estado BORRADOR`);
    }
    if (!movimiento.requiere_aprobacion) {
      throw new BadRequestException(`Este movimiento no requiere aprobación`);
    }

    return this.prisma.tes_movimientos.update({
      where: { id },
      data: {
        aprobado_por: userId,
        aprobado_en: new Date(),
      },
    });
  }

  async anular(
    id: string,
    dto: AnularTesMovimientoDto,
    empresaId: string,
    userId: string,
  ) {
    const movimiento = await this.prisma.tes_movimientos.findFirst({
      where: { id, empresa_id: empresaId },
    });
    if (!movimiento) {
      throw new NotFoundException(`Movimiento ${id} no encontrado`);
    }
    // 0. Movimientos generados por otro módulo se anulan desde su documento de
    // origen (revierte el movimiento junto con el resto del documento). Anular
    // directo acá dejaría al origen desincronizado sin que nadie se entere.
    if (movimiento.origen_tipo) {
      const origenLabel = ORIGEN_TIPO_LABELS[movimiento.origen_tipo] || `su origen (${movimiento.origen_tipo})`;
      throw new BadRequestException(
        `Este movimiento fue generado automáticamente. Anulá ${origenLabel} en vez de anular el movimiento directamente acá.`,
      );
    }
    // 1. Solo movimientos CONFIRMADOS pueden anularse
    if (movimiento.estado !== 'CONFIRMADO') {
      throw new BadRequestException(`Solo se pueden anular movimientos en estado CONFIRMADO (estado actual: ${movimiento.estado})`);
    }

    // 2. Revertir saldo + marcar ANULADO en transacción
    const resultado = await this.prisma.$transaction(async (tx) => {
      const mov = await tx.tes_movimientos.update({
        where: { id },
        data: {
          estado: 'ANULADO',
          motivo_anulacion: dto.motivo_anulacion,
          anulado_por: userId,
          anulado_en: new Date(),
        },
      });

      // Operación inversa sobre saldo_actual
      const delta = movimiento.tipo === 'INGRESO'
        ? -Number(movimiento.monto)
        : Number(movimiento.monto);

      await tx.tes_cuentas.update({
        where: { id: movimiento.cuenta_id as string },
        data: {
          saldo_actual: {
            increment: delta,
          },
        },
      });

      return mov;
    });

    // Reversión contable — si no hay asiento previo, revertirDocumento hace nada
    this.contabilidad.revertirDocumento('tes_movimientos', id, userId).catch((e) =>
      this.logger.error(`Error al revertir asiento de movimiento ${id}: ${e.message}`),
    );

    this.logger.log(`Movimiento ${id} ANULADO por usuario ${userId}. Motivo: ${dto.motivo_anulacion}`);
    return resultado;
  }

  /**
   * Edita fecha y textos de un movimiento. Nada más.
   *
   * Importe, cuenta y tipo quedan fuera a propósito: la mayoría de los movimientos los
   * genera un documento (gasto, recibo, cheque) y cambiarlos acá dejaría al documento,
   * al asiento contable y al movimiento diciendo cosas distintas, sin que nadie se
   * entere hasta conciliar. Para eso está anular y rehacer desde el origen.
   *
   * La fecha sí se puede corregir, y es el caso real: el depósito se registra días
   * después y arrastra la fecha de carga, así que cae en el mes equivocado del libro
   * banco. Cambiarla no toca saldos — `saldo_actual` es un acumulado, no depende de la
   * fecha — sólo mueve el movimiento de período.
   */
  async actualizar(id: string, dto: UpdateTesMovimientoDto, empresaId: string, userId: string) {
    const movimiento = await this.prisma.tes_movimientos.findFirst({
      where: { id, empresa_id: empresaId },
      select: { id: true, estado: true, fecha: true },
    });
    if (!movimiento) throw new NotFoundException(`Movimiento ${id} no encontrado`);

    if (movimiento.estado === 'ANULADO') {
      throw new BadRequestException('No se puede editar un movimiento anulado');
    }

    // Si ya está conciliado, su fecha es la que casó con la línea del extracto:
    // moverla deja la conciliación diciendo cualquier cosa.
    const conciliado = await this.prisma.tes_extracto_lineas.findFirst({
      where: { movimiento_id: id, conciliado: true },
      select: { id: true },
    });
    if (conciliado) {
      throw new BadRequestException(
        'Este movimiento ya está conciliado con el extracto bancario. ' +
          'Deshacé la conciliación antes de editarlo.',
      );
    }

    const data: Record<string, unknown> = {};
    if (dto.fecha !== undefined) {
      if (dto.fecha > hoyLocalISO()) {
        throw new BadRequestException('La fecha del movimiento no puede ser futura');
      }
      data.fecha = new Date(dto.fecha);
    }
    if (dto.descripcion !== undefined) data.descripcion = dto.descripcion;
    if (dto.referencia !== undefined) data.referencia = dto.referencia;

    if (Object.keys(data).length === 0) return this.findOne(id, empresaId);

    // `updated_at` no se mantiene solo en este modelo (es @default(now()) sin @updatedAt).
    data.updated_at = new Date();

    await this.prisma.tes_movimientos.update({ where: { id }, data });
    this.logger?.log?.(
      `Movimiento ${id} editado por ${userId}: ${Object.keys(data).filter((k) => k !== 'updated_at').join(', ')}`,
    );
    return this.findOne(id, empresaId);
  }

  async eliminar(id: string, empresaId: string, userId: string) {
    const movimiento = await this.prisma.tes_movimientos.findFirst({
      where: { id, empresa_id: empresaId },
      include: { detalles: true },
    });
    if (!movimiento) {
      throw new NotFoundException(`Movimiento ${id} no encontrado`);
    }
    if (movimiento.estado !== 'BORRADOR') {
      throw new BadRequestException(
        `Solo se pueden eliminar movimientos en estado BORRADOR (estado actual: ${movimiento.estado})`,
      );
    }

    await this.prisma.$transaction(async (tx) => {
      await tx.tes_movimiento_det.deleteMany({ where: { movimiento_id: id } });
      await tx.tes_movimientos.delete({ where: { id } });
    });

    this.logger.log(`Movimiento ${id} ELIMINADO (BORRADOR) por usuario ${userId}`);
    return movimiento;
  }

  async findAll(
    empresaId: string,
    filters: {
      cuentaId?: string;
      tipo?: string;
      estado?: string;
      fechaDesde?: string;
      fechaHasta?: string;
      search?: string;
      page?: number;
      limit?: number;
    },
  ) {
    const page = filters.page ?? 1;
    const limit = filters.limit ?? 20;
    const skip = (page - 1) * limit;

    const where: any = { empresa_id: empresaId };

    if (filters.cuentaId) where.cuenta_id = filters.cuentaId;
    if (filters.tipo) where.tipo = filters.tipo;
    if (filters.estado) where.estado = filters.estado;
    if (filters.fechaDesde || filters.fechaHasta) {
      where.fecha = {};
      if (filters.fechaDesde) where.fecha.gte = new Date(filters.fechaDesde);
      if (filters.fechaHasta) where.fecha.lte = new Date(filters.fechaHasta);
    }

    // Búsqueda libre: el nro. de boleta/comprobante vive en la descripción o en la
    // referencia según cómo se cargó el movimiento, así que se buscan las dos. Si
    // lo tecleado es un importe ("350.000", "350000") también se compara el monto:
    // el usuario busca por lo que ve en pantalla.
    const texto = filters.search?.trim();
    if (texto) {
      const condiciones: any[] = [
        { descripcion: { contains: texto, mode: 'insensitive' } },
        { referencia: { contains: texto, mode: 'insensitive' } },
        { cuenta: { nombre: { contains: texto, mode: 'insensitive' } } },
      ];
      const monto = parseMontoBuscado(texto);
      if (monto !== null) condiciones.push({ monto });
      where.OR = condiciones;
    }

    const [data, total] = await this.prisma.$transaction([
      this.prisma.tes_movimientos.findMany({
        where,
        include: {
          cuenta: { select: { id: true, nombre: true, moneda: true, activo: true } },
          detalles: {
            include: {
              categoria: { select: { id: true, nombre: true, tipo: true } },
            },
          },
        },
        // `fecha` es DATE (sin hora) y el 98% de los movimientos comparte fecha
        // con otro, así que ordenar sólo por ella deja el orden dentro del día
        // en manos del motor: el depósito de un cheque aparecía arriba de su
        // propia reversa, hecha 17 segundos después. `created_at` da el orden
        // real, e `id` cierra el empate — hay 89 filas con el mismo created_at
        // (una carga masiva) y sin un orden total el `skip`/`take` de abajo
        // puede repetir una fila en una página y saltearla en la siguiente.
        orderBy: [{ fecha: 'desc' }, { created_at: 'desc' }, { id: 'desc' }],
        skip,
        take: limit,
      }),
      this.prisma.tes_movimientos.count({ where }),
    ]);

    // Cada movimiento generado por otro documento lleva quién lo generó, con
    // número y estado: la pantalla necesita decir qué anular y dónde, no solo
    // deshabilitar el botón.
    const origenes = await resolverOrigenes(data, (tipo, ids) => this.buscarDocumentosOrigen(empresaId, tipo, ids));

    return {
      data: data.map((mov) => ({ ...mov, origen: origenes.get(mov.id) ?? null })),
      meta: {
        total,
        page,
        limit,
        totalPages: Math.ceil(total / limit),
      },
    };
  }

  /** Datos del documento que generó cada movimiento, por tipo de origen. */
  private async buscarDocumentosOrigen(
    empresaId: string,
    tipo: string,
    ids: string[],
  ): Promise<Map<string, DatosDocumento>> {
    const mapa = new Map<string, DatosDocumento>();
    const agregar = (
      filas: Array<Record<string, unknown>>,
      campoNumero: string,
      campoFecha: string,
      anuladoDe: (f: Record<string, unknown>) => boolean,
    ) => {
      for (const f of filas) {
        mapa.set(String(f.id), {
          numero: f[campoNumero] ? String(f[campoNumero]) : null,
          fecha: (f[campoFecha] as Date | null) ?? null,
          estado: f.estado ? String(f.estado) : null,
          anulado: anuladoDe(f),
        });
      }
    };

    if (tipo.startsWith('CHEQUE')) {
      const filas = await this.prisma.tes_cheques.findMany({
        where: { empresa_id: empresaId, id: { in: ids } },
        select: { id: true, numero_cheque: true, fecha_emision: true, estado: true },
      });
      agregar(filas as never, 'numero_cheque', 'fecha_emision', (f) => String(f.estado) === 'ANULADO');
      return mapa;
    }
    if (tipo === 'gasto_cab') {
      const filas = await this.prisma.gasto_cab.findMany({
        where: { empresa_id: empresaId, id: { in: ids } },
        select: { id: true, numero_factura: true, fecha_emision: true, estado: true, anulado: true },
      });
      agregar(filas as never, 'numero_factura', 'fecha_emision', (f) => Boolean(f.anulado) || String(f.estado).toLowerCase().startsWith('anulad'));
      return mapa;
    }
    if (tipo === 'recibos_multi' || tipo === 'recibos_cobro') {
      const filas = await this.prisma.recibos_cobro.findMany({
        where: { empresa_id: empresaId, id: { in: ids } },
        select: { id: true, numero_recibo: true, fecha_emision: true, estado: true },
      });
      agregar(filas as never, 'numero_recibo', 'fecha_emision', (f) => String(f.estado).toLowerCase().startsWith('anulad'));
      return mapa;
    }
    if (tipo === 'ORDEN_PAGO_PROVEEDOR') {
      const filas = await this.prisma.orden_pago_proveedor_cab.findMany({
        where: { empresa_id: empresaId, id: { in: ids } },
        select: { id: true, numero_orden_pago: true, fecha_pago: true, estado: true, anulado: true },
      });
      agregar(filas as never, 'numero_orden_pago', 'fecha_pago', (f) => Boolean(f.anulado) || String(f.estado).toUpperCase().startsWith('ANULAD'));
      return mapa;
    }
    if (tipo === 'RENDICION_ADELANTO') {
      const filas = await this.prisma.rendicion_adelanto.findMany({
        where: { empresa_id: empresaId, id: { in: ids } },
        select: { id: true, numero_operacion: true, fecha: true, anulado: true },
      });
      agregar(filas as never, 'numero_operacion', 'fecha', (f) => Boolean(f.anulado));
      return mapa;
    }
    if (tipo === 'RENDICION_DEVOLUCION') {
      const filas = await this.prisma.rendicion_devolucion.findMany({
        where: { empresa_id: empresaId, id: { in: ids } },
        select: { id: true, fecha: true, anulado: true },
      });
      agregar(filas as never, 'id', 'fecha', (f) => Boolean(f.anulado));
      return mapa;
    }
    // Resto (transferencias, importación de extracto, RRHH): alcanza con saber
    // de dónde vino; el número no aporta nada que el usuario reconozca.
    return mapa;
  }

  async getExtracto(
    empresaId: string,
    cuentaId: string,
    fechaDesde?: string,
    fechaHasta?: string,
  ) {
    const cuenta = await this.prisma.tes_cuentas.findFirst({
      where: { id: cuentaId, empresa_id: empresaId },
    });
    if (!cuenta) throw new Error(`Cuenta ${cuentaId} no encontrada`);

    // Saldo de apertura: saldo_inicial + todos los movimientos confirmados ANTES de fechaDesde
    let saldoApertura = Number(cuenta.saldo_inicial);
    if (fechaDesde) {
      const movsAnteriores = await this.prisma.tes_movimientos.findMany({
        where: {
          cuenta_id: cuentaId,
          empresa_id: empresaId,
          estado: 'CONFIRMADO',
          fecha: { lt: new Date(fechaDesde) },
        },
        select: { tipo: true, monto: true },
      });
      for (const m of movsAnteriores) {
        saldoApertura += m.tipo === 'INGRESO' ? Number(m.monto) : -Number(m.monto);
      }
    }

    // Movimientos del período
    const where: any = {
      cuenta_id: cuentaId,
      empresa_id: empresaId,
      estado: 'CONFIRMADO',
    };
    if (fechaDesde || fechaHasta) {
      where.fecha = {};
      if (fechaDesde) where.fecha.gte = new Date(fechaDesde);
      if (fechaHasta) where.fecha.lte = new Date(fechaHasta);
    }

    const movimientos = await this.prisma.tes_movimientos.findMany({
      where,
      include: {
        detalles: { include: { categoria: { select: { id: true, nombre: true, tipo: true } } } },
      },
      // Mismo empate que en findAll, y acá pesa más: el saldo acumulado se
      // calcula recorriendo esta lista, así que un orden inestable daría un
      // saldo distinto en cada corrida.
      orderBy: [{ fecha: 'asc' }, { confirmado_en: 'asc' }, { created_at: 'asc' }, { id: 'asc' }],
    });

    // Calcular saldo acumulado línea a línea
    let saldoAcumulado = saldoApertura;
    const extracto = movimientos.map((m) => {
      const delta = m.tipo === 'INGRESO' ? Number(m.monto) : -Number(m.monto);
      saldoAcumulado += delta;
      return { ...m, saldo_acumulado: saldoAcumulado };
    });

    return {
      cuenta: {
        id: cuenta.id,
        nombre: cuenta.nombre,
        tipo: cuenta.tipo,
        moneda: cuenta.moneda,
      },
      saldo_apertura: saldoApertura,
      saldo_cierre: saldoAcumulado,
      movimientos: extracto,
    };
  }

  async findOne(id: string, empresaId: string) {
    const movimiento = await this.prisma.tes_movimientos.findFirst({
      where: { id, empresa_id: empresaId },
      include: {
        cuenta: true,
        detalles: {
          include: {
            categoria: true,
          },
        },
      },
    });
    if (!movimiento) {
      throw new NotFoundException(`Movimiento ${id} no encontrado`);
    }
    return movimiento;
  }
}

/** Fecha calendario de hoy en hora local del servidor, como "YYYY-MM-DD". */
function hoyLocalISO(): string {
  const d = new Date();
  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
