import {
  BadRequestException,
  ConflictException,
  ForbiddenException,
  Injectable,
  NotFoundException,
  Logger,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { AuditService } from 'src/audit/audit.service';
import { PosGateway } from 'src/pos-gateway/pos-gateway.gateway';
import { formatDateResponse } from 'src/utils/utilidades';
import { PrismaService } from '../prisma/prisma.service';
import { envs } from 'src/config/envs';
import { AbrirCajaDto } from './dto/abrir-caja.dto';
import { CerrarCajaDto } from './dto/cerrar-caja.dto';
import { MovimientoCajaDto } from './dto/movimiento-caja.dto';
import { tieneAccesoElevado } from 'src/utils/acceso-elevado';
import { alcanceSucursalUsuario, filtroSucursalOperativo } from 'src/utils/alcance-sucursal';
import { logoEmisor, nombreFantasiaEmisor } from 'src/utils/emisor-comprobante';

/**
 * La caja de una sesión viaja con su sucursal y los rubros de esa sucursal, para
 * que el POS filtre el catálogo por el rubro donde vende. Sin esto el POS no
 * tenía de dónde sacarlo: `GET /rubros` pide un permiso de Administración que un
 * cajero no tiene. Ver docs/plan-rubros-multi-sucursal.md.
 *
 * Se toma la sucursal de la CAJA y no `sesiones_caja.sucursal_id`: esa columna
 * se llenaba con lo que mandaba el front y hay sesiones con una sucursal que no
 * es la de su caja (ver abrirCaja).
 */
const CAJA_CON_RUBROS = {
  include: {
    empresas_sucursales: { include: { sucursal_rubros: { select: { rubro_id: true } } } },
  },
} as const;

@Injectable()
export class TesoreriaService {
  private readonly logger = new Logger(TesoreriaService.name);
  constructor(
    private prisma: PrismaService,
    private readonly auditService: AuditService,
    private readonly jwtService: JwtService,
    private readonly posGateway: PosGateway,
  ) {}

  // ==================== SESIONES DE CAJA ====================

  // ==================== HELPERS PRIVADOS ====================

  private async verificarTokenAutorizacion(token: string, operacionEsperada: string, empresa_id: string) {
    try {
      const payload = await this.jwtService.verifyAsync(token, { secret: process.env.JWT_SECRET });
      if (payload.tipo !== 'autorizacion_caja') throw new ForbiddenException('Token no es de autorización');
      if (payload.empresa_id !== empresa_id) throw new ForbiddenException('Token no corresponde a esta empresa');
      return payload;
    } catch (error) {
      if (error instanceof ForbiddenException) throw error;
      throw new ForbiddenException('Token de autorización inválido o expirado');
    }
  }

  private async obtenerPosConfig(caja_id: string) {
    return this.prisma.pos_config.findUnique({ where: { caja_id } });
  }

  async abrirCaja(dto: AbrirCajaDto, empresa_id: string, usuario_id: string) {
    // Verificar que la caja existe y pertenece a la empresa.
    // El filtro por empresa faltaba: se buscaba solo por `id`, así que con un
    // caja_id de otra empresa se abría igual una sesión sobre esa caja ajena.
    const caja = await this.prisma.cajas.findFirst({
      where: {
        id: dto.caja_id,
        empresas_sucursales: { empresa_id, active: { not: false } },
      },
      include: { empresas_sucursales: true },
    });
    if (!caja) throw new NotFoundException('Caja no encontrada');

    // Filtrar el listado no alcanza: hay que revalidar la asignación acá,
    // porque este endpoint recibe el caja_id directo del cliente. Se reusa
    // `cajasAccesibles` para que la apertura no pueda quedar más restrictiva
    // que el listado — si divergen, el usuario ve una caja que después no
    // puede abrir.
    const accesibles = await this.cajasAccesibles(empresa_id, usuario_id);
    if (!accesibles.some((c) => c.id === caja.id)) {
      throw new ForbiddenException('No tenés esta caja asignada');
    }

    // Verificar que no hay una sesión abierta para esta caja
    const sesionAbierta = await this.prisma.sesiones_caja.findFirst({
      where: { caja_id: dto.caja_id, estado: 'ABIERTA' },
    });
    if (sesionAbierta) {
      throw new ConflictException('Esta caja ya tiene una sesión abierta');
    }

    // Fase 1: Verificar autorización de apertura si la config lo requiere
    const posConfig = await this.obtenerPosConfig(dto.caja_id);
    let autorizadorAperturaId: string | null = null;

    if (posConfig?.requiere_autorizacion_apertura) {
      if (!dto.token_autorizacion) {
        throw new ForbiddenException('Esta caja requiere autorización de supervisor para abrir');
      }
      const payload = await this.verificarTokenAutorizacion(dto.token_autorizacion, 'apertura_caja', empresa_id);
      autorizadorAperturaId = payload.sub;
    }

    // Fase 1: Verificar fondo fijo si está configurado
    if (posConfig?.fondo_fijo && dto.monto_apertura !== Number(posConfig.fondo_fijo)) {
      this.logger.warn(
        `Monto apertura (${dto.monto_apertura}) difiere del fondo fijo configurado (${posConfig.fondo_fijo})`,
        'TesoreriaService',
      );
    }

    const sesion = await this.prisma.sesiones_caja.create({
      data: {
        empresa_id,
        // La sucursal sale de la CAJA, no de lo que mande el front: una caja no
        // cambia de sucursal, y el front manda la sucursal elegida en el header,
        // que puede ser otra. Con `dto.sucursal_id || caja.sucursal_id` se abrían
        // sesiones de una caja del taller marcadas en la sucursal de regalería, y
        // todo lo que colgaba de la sesión (reportes, rubro del POS) quedaba
        // atribuido a la sucursal equivocada.
        sucursal_id: caja.sucursal_id ?? dto.sucursal_id,
        caja_id: dto.caja_id,
        user_id: usuario_id,
        monto_apertura: dto.monto_apertura,
        fecha_apertura: new Date(),
        estado: 'ABIERTA',
        monto_ventas_efectivo: 0,
        monto_ventas_tarjeta: 0,
        monto_ventas_transferencia: 0,
        monto_entradas_manual: 0,
        monto_salidas_manual: 0,
        dispositivo_fingerprint: dto.dispositivo_fingerprint ?? null,
        autorizado_apertura_por: autorizadorAperturaId,
        estado_cierre: null,
        bloqueado: false,
        created_at: new Date(),
        updated_at: new Date(),
      },
      include: {
        cajas: CAJA_CON_RUBROS,
        empresas_sucursales: true,
        usuario: { select: { id: true, nombres: true, email: true } },
      },
    });

    // Fase 1: Registrar/actualizar dispositivo autorizado
    if (dto.dispositivo_fingerprint) {
      await this.prisma.dispositivos_autorizados.upsert({
        where: {
          empresa_id_fingerprint: {
            empresa_id,
            fingerprint: dto.dispositivo_fingerprint,
          },
        },
        create: {
          empresa_id,
          caja_id: dto.caja_id,
          fingerprint: dto.dispositivo_fingerprint,
          registrado_por: usuario_id,
          ultimo_acceso: new Date(),
        },
        update: {
          caja_id: dto.caja_id,
          ultimo_acceso: new Date(),
        },
      });
    }

    this.logger.log(
      `Caja abierta: ${dto.caja_id} | Empresa: ${empresa_id} | Usuario: ${usuario_id}`,
      'TesoreriaService',
    );
    await this.auditService.log({
      empresa_id,
      user_id: usuario_id,
      action: 'CREATE',
      entity_type: 'sesion_caja',
      entity_id: sesion.id,
      descripcion: `Caja abierta: ${dto.caja_id} | Monto apertura: ${dto.monto_apertura}${autorizadorAperturaId ? ' | Autorizado por supervisor' : ''}`,
      new_value: {
        caja_id: dto.caja_id,
        monto_apertura: dto.monto_apertura,
        estado: 'ABIERTA',
        autorizado_apertura_por: autorizadorAperturaId,
      },
    });
    return { message: 'Caja abierta exitosamente', data: sesion };
  }

  async cerrarCaja(sesion_id: string, dto: CerrarCajaDto, empresa_id: string, usuario_id: string) {
    const sesion = await this.prisma.sesiones_caja.findFirst({
      where: { id: sesion_id, empresa_id, estado: 'ABIERTA' },
    });
    if (!sesion) throw new NotFoundException('Sesión de caja no encontrada o ya cerrada');
    // Permitir cierre si la sesión fue bloqueada por iniciarCierre (estado_cierre='en_proceso')
    if (sesion.bloqueado && sesion.estado_cierre !== 'en_proceso') {
      throw new BadRequestException('La sesión está bloqueada por otro proceso');
    }

    // Fase 1: Verificar autorización de cierre si la config lo requiere
    const posConfig = sesion.caja_id ? await this.obtenerPosConfig(sesion.caja_id) : null;
    let autorizadorCierreId: string | null = null;

    if (posConfig?.requiere_autorizacion_cierre) {
      if (!dto.token_autorizacion) {
        throw new ForbiddenException('Esta caja requiere autorización de supervisor para cerrar');
      }
      const payload = await this.verificarTokenAutorizacion(dto.token_autorizacion, 'cierre_caja', empresa_id);
      autorizadorCierreId = payload.sub;
    }

    // Calcular totales del sistema
    const montoApertura = Number(sesion.monto_apertura) || 0;
    const ventasEfectivo = Number(sesion.monto_ventas_efectivo) || 0;
    const entradasManual = Number(sesion.monto_entradas_manual) || 0;
    const salidasManual = Number(sesion.monto_salidas_manual) || 0;

    const montoFinalSistema = montoApertura + ventasEfectivo + entradasManual - salidasManual;
    const diferencia = dto.monto_cierre - montoFinalSistema;

    // Fase 1: Verificar diferencia permitida
    const diferenciaPermitida = posConfig?.diferencia_arqueo_permitida
      ? Number(posConfig.diferencia_arqueo_permitida)
      : null;
    let estadoCierre: string = 'normal';

    if (diferenciaPermitida !== null && Math.abs(diferencia) > diferenciaPermitida) {
      // Si la diferencia excede lo permitido, necesita autorización adicional
      if (!dto.token_autorizacion && !autorizadorCierreId) {
        throw new ForbiddenException(
          `Diferencia de ${diferencia} excede el límite permitido de ${diferenciaPermitida}. Se requiere autorización de supervisor.`,
        );
      }
      estadoCierre = 'con_diferencia';
    }

    // Fase 1: Verificar que se proporcionó arqueo si es requerido
    if (posConfig?.requiere_arqueo_cierre && (!dto.arqueo_detalle || dto.arqueo_detalle.length === 0)) {
      throw new BadRequestException('Esta caja requiere detalle de arqueo para cerrar');
    }

    // Fase 2: Obtener resumen de monedas acumulado durante la sesión
    const resumenMonedas = (sesion.resumen_monedas as Record<string, any>) || null;

    // Generar reporte de cierre
    const reporteCierre = {
      monto_apertura: montoApertura,
      ventas_efectivo: ventasEfectivo,
      ventas_tarjeta: Number(sesion.monto_ventas_tarjeta) || 0,
      ventas_transferencia: Number(sesion.monto_ventas_transferencia) || 0,
      entradas_manual: entradasManual,
      salidas_manual: salidasManual,
      monto_final_sistema: montoFinalSistema,
      monto_cierre: dto.monto_cierre,
      diferencia,
      estado_cierre: estadoCierre,
      fecha_cierre: new Date().toISOString(),
      resumen_monedas: resumenMonedas,
    };

    const sesionCerrada = await this.prisma.sesiones_caja.update({
      where: { id: sesion_id },
      data: {
        fecha_cierre: new Date(),
        monto_cierre: dto.monto_cierre,
        monto_final_sistema: montoFinalSistema,
        diferencia,
        observaciones: dto.observaciones,
        estado: 'CERRADA',
        usuario_cierre_id: usuario_id,
        autorizado_cierre_por: autorizadorCierreId,
        estado_cierre: estadoCierre,
        reporte_cierre: reporteCierre as any,
        updated_at: new Date(),
      },
      include: {
        cajas: true,
        usuario: { select: { id: true, nombres: true, email: true } },
      },
    });

    // Crear registro de arqueo con detalle profesional
    const arqueo = await this.prisma.arqueo_cajas.create({
      data: {
        empresa_id,
        caja_id: sesion.caja_id,
        sesion_id,
        total_sistema: montoFinalSistema,
        total_contado: dto.monto_cierre,
        diferencia,
        observacion: dto.observaciones,
        usuario_id,
        fecha: new Date(),
        tipo_arqueo: 'cierre',
        estado: estadoCierre === 'con_diferencia' ? 'con_diferencia' : 'finalizado',
        autorizado_por: autorizadorCierreId,
        diferencia_permitida: diferenciaPermitida,
      },
    });

    // Fase 1: Guardar detalle de arqueo por denominación/medio de pago
    if (dto.arqueo_detalle && dto.arqueo_detalle.length > 0) {
      await this.prisma.arqueo_caja_detalle.createMany({
        data: dto.arqueo_detalle.map((item) => ({
          arqueo_id: arqueo.id,
          moneda_id: item.moneda_id ?? null,
          tipo: item.tipo,
          denominacion: item.denominacion ?? null,
          cantidad: item.cantidad,
          subtotal: item.subtotal,
          observacion: item.observacion ?? null,
        })),
      });
    }

    this.logger.log(
      `Caja cerrada: sesion ${sesion_id} | Empresa: ${empresa_id} | Diferencia: ${diferencia} | Estado: ${estadoCierre}`,
      'TesoreriaService',
    );
    await this.auditService.log({
      empresa_id,
      user_id: usuario_id,
      action: 'UPDATE',
      entity_type: 'sesion_caja',
      entity_id: sesion_id,
      descripcion: `Caja cerrada | Monto cierre: ${dto.monto_cierre} | Diferencia: ${diferencia} | Estado: ${estadoCierre}`,
      old_value: { estado: 'ABIERTA', monto_apertura: Number(sesion.monto_apertura) },
      new_value: {
        estado: 'CERRADA',
        monto_cierre: dto.monto_cierre,
        diferencia,
        estado_cierre: estadoCierre,
        autorizado_cierre_por: autorizadorCierreId,
      },
    });
    // Notificar via WebSocket
    this.posGateway.notificarCambioSesion(empresa_id, {
      sesion_id,
      caja_id: sesion.caja_id,
      evento: 'cerrada',
      usuario: { id: usuario_id, username: '' },
    });

    return { message: 'Caja cerrada exitosamente', data: sesionCerrada, reporte: reporteCierre };
  }

  // ==================== CIERRE ESTRICTO ====================

  async iniciarCierre(sesion_id: string, empresa_id: string, usuario_id: string) {
    const sesion = await this.prisma.sesiones_caja.findFirst({
      where: { id: sesion_id, empresa_id, estado: 'ABIERTA' },
      include: { cajas: true },
    });
    if (!sesion) throw new NotFoundException('Sesión de caja no encontrada o ya cerrada');
    if (sesion.bloqueado) throw new BadRequestException('La sesión ya está en proceso de cierre');

    const updated = await this.prisma.sesiones_caja.update({
      where: { id: sesion_id },
      data: {
        bloqueado: true,
        estado_cierre: 'en_proceso',
        updated_at: new Date(),
      },
      include: {
        cajas: true,
        usuario: { select: { id: true, nombres: true, email: true } },
      },
    });

    this.logger.log(
      `Cierre iniciado: sesion ${sesion_id} | Caja: ${sesion.cajas?.descripcion} | Empresa: ${empresa_id}`,
      'TesoreriaService',
    );

    await this.auditService.log({
      empresa_id,
      user_id: usuario_id,
      action: 'UPDATE',
      entity_type: 'sesion_caja',
      entity_id: sesion_id,
      descripcion: `Cierre iniciado (caja bloqueada): ${sesion.cajas?.descripcion}`,
      new_value: { bloqueado: true, estado_cierre: 'en_proceso' },
    });

    // Notificar via WebSocket
    this.posGateway.notificarCambioSesion(empresa_id, {
      sesion_id,
      caja_id: sesion.caja_id,
      evento: 'bloqueada',
      usuario: { id: usuario_id, username: '' },
    });

    return { message: 'Cierre iniciado, caja bloqueada para operaciones', data: updated };
  }

  async cancelarCierre(sesion_id: string, empresa_id: string, usuario_id: string, token_autorizacion?: string) {
    const sesion = await this.prisma.sesiones_caja.findFirst({
      where: { id: sesion_id, empresa_id, estado: 'ABIERTA' },
      include: { cajas: true },
    });
    if (!sesion) throw new NotFoundException('Sesión de caja no encontrada');
    if (!sesion.bloqueado) throw new BadRequestException('La sesión no está bloqueada');

    // Si la caja requiere autorización de cierre, cancelar cierre también la requiere
    const posConfig = sesion.caja_id ? await this.obtenerPosConfig(sesion.caja_id) : null;
    if (posConfig?.requiere_autorizacion_cierre) {
      if (!token_autorizacion) {
        throw new ForbiddenException('Se requiere autorización de supervisor para cancelar el cierre');
      }
      await this.verificarTokenAutorizacion(token_autorizacion, 'cierre_caja', empresa_id);
    }

    const updated = await this.prisma.sesiones_caja.update({
      where: { id: sesion_id },
      data: {
        bloqueado: false,
        estado_cierre: null,
        updated_at: new Date(),
      },
      include: {
        cajas: true,
        usuario: { select: { id: true, nombres: true, email: true } },
      },
    });

    this.logger.log(
      `Cierre cancelado: sesion ${sesion_id} | Caja: ${sesion.cajas?.descripcion} | Empresa: ${empresa_id}`,
      'TesoreriaService',
    );

    await this.auditService.log({
      empresa_id,
      user_id: usuario_id,
      action: 'UPDATE',
      entity_type: 'sesion_caja',
      entity_id: sesion_id,
      descripcion: `Cierre cancelado (caja desbloqueada): ${sesion.cajas?.descripcion}`,
      new_value: { bloqueado: false, estado_cierre: null },
    });

    // Notificar via WebSocket
    this.posGateway.notificarCambioSesion(empresa_id, {
      sesion_id,
      caja_id: sesion.caja_id,
      evento: 'desbloqueada',
      usuario: { id: usuario_id, username: '' },
    });

    return { message: 'Cierre cancelado, caja desbloqueada', data: updated };
  }

  /**
   * Devuelve los caja_id a los que el usuario tiene acceso según
   * `asignacion_sucursal_caja_usuario`.
   *   - [] → no tiene ninguna caja asignada (debería ver vacío).
   *   - null → no tiene asignaciones registradas: sin restricción (admin / legacy).
   *   - Una asignación con caja_id=null expande a todas las cajas de la sucursal.
   */
  private async getCajasAsignadasUsuario(empresa_id: string, usuario_id: string): Promise<string[] | null> {
    // `null` significa "no filtrar". Un actor elevado (superadmin, holding o
    // reseller) ve todas las cajas y sesiones de la empresa sin depender de que
    // se le asigne cada una.
    if (await tieneAccesoElevado(this.prisma, usuario_id, empresa_id)) return null;

    const asignaciones = await this.prisma.asignacion_sucursal_caja_usuario.findMany({
      where: {
        usuario_id,
        sucursal: { empresa_id },
      },
      include: { sucursal: { include: { cajas: { select: { id: true } } } } },
    });
    if (asignaciones.length === 0) return null;
    const ids = new Set<string>();
    for (const a of asignaciones) {
      if (a.caja_id) {
        ids.add(a.caja_id);
      } else if (a.sucursal?.cajas) {
        for (const c of a.sucursal.cajas) ids.add(c.id);
      }
    }

    // Sumar la caja donde el propio usuario tiene una sesión ABIERTA aunque no
    // la tenga asignada: si no, su caja abierta no aparece en Finanzas y no hay
    // manera de cerrarla desde la UI. La sesión ya es suya, no amplía el acceso.
    const propias = await this.prisma.sesiones_caja.findMany({
      where: { empresa_id, user_id: usuario_id, estado: 'ABIERTA' },
      select: { caja_id: true },
    });
    for (const s of propias) if (s.caja_id) ids.add(s.caja_id);

    return [...ids];
  }

  /**
   * Sesión de caja abierta para el contexto dado.
   *
   * `soloPropia` restringe el resultado a la sesión que abrió el propio usuario.
   * Lo usa el cliente móvil: un cobrador externo no tiene caja propia, y el
   * fallback de abajo — que devuelve una sesión abierta de cualquier caja a la
   * que tenga acceso — le entregaba la caja de otro cajero. La app tomaba ese id
   * y lo mandaba en el cobro, imputándolo a una caja ajena.
   *
   * En la web el fallback se mantiene: ahí una caja de mostrador la abre un
   * usuario y la operan varios, que es un flujo legítimo.
   */
  async getSesionActiva(
    empresa_id: string,
    caja_id?: string,
    usuario_id?: string,
    opciones?: { soloPropia?: boolean },
  ) {
    const where: any = { empresa_id, estado: 'ABIERTA' };
    if (caja_id) where.caja_id = caja_id;

    if (opciones?.soloPropia) {
      if (!usuario_id) return null;
      where.user_id = usuario_id;
    }

    // Si tenemos usuario, intentamos primero su propia sesión abierta.
    if (usuario_id && !caja_id) {
      const propia = await this.prisma.sesiones_caja.findFirst({
        where: { ...where, user_id: usuario_id },
        include: {
          cajas: CAJA_CON_RUBROS,
          empresas_sucursales: true,
          usuario: { select: { id: true, nombres: true, email: true } },
          movimiento_cajas: { orderBy: { fecha_mov: 'desc' }, take: 10 },
        },
        orderBy: { fecha_apertura: 'desc' },
      });
      if (propia) {
        const montoApertura = Number(propia.monto_apertura) || 0;
        const ventasEfectivo = Number(propia.monto_ventas_efectivo) || 0;
        const entradasManual = Number(propia.monto_entradas_manual) || 0;
        const salidasManual = Number(propia.monto_salidas_manual) || 0;
        return {
          ...propia,
          saldo_actual: montoApertura + ventasEfectivo + entradasManual - salidasManual,
        };
      }
      // Sin sesión propia y pidiendo solo la propia: no hay nada que devolver.
      // Devolver acá la de otro es lo que desviaba los cobros de la app.
      if (opciones?.soloPropia) return null;

      // Restringir a sus cajas asignadas, si las tiene.
      const cajasAsignadas = await this.getCajasAsignadasUsuario(empresa_id, usuario_id);
      if (cajasAsignadas !== null) {
        if (cajasAsignadas.length === 0) return null;
        where.caja_id = { in: cajasAsignadas };
      }
    }

    const sesion = await this.prisma.sesiones_caja.findFirst({
      where,
      // Sin orden explícito, con varias cajas abiertas Postgres devolvía
      // cualquiera de ellas y el resultado cambiaba entre llamadas.
      orderBy: { fecha_apertura: 'desc' },
      include: {
        cajas: CAJA_CON_RUBROS,
        empresas_sucursales: true,
        usuario: { select: { id: true, nombres: true, email: true } },
        movimiento_cajas: {
          orderBy: { fecha_mov: 'desc' },
          take: 10,
        },
      },
    });

    if (!sesion) return null;

    // Calcular totales actuales
    const montoApertura = Number(sesion.monto_apertura) || 0;
    const ventasEfectivo = Number(sesion.monto_ventas_efectivo) || 0;
    const entradasManual = Number(sesion.monto_entradas_manual) || 0;
    const salidasManual = Number(sesion.monto_salidas_manual) || 0;
    const saldoActual = montoApertura + ventasEfectivo + entradasManual - salidasManual;

    return { ...sesion, saldo_actual: saldoActual };
  }

  async getSesionesAbiertas(empresa_id: string, usuario_id?: string) {
    const where: any = { empresa_id, estado: 'ABIERTA' };
    if (usuario_id) {
      const cajasAsignadas = await this.getCajasAsignadasUsuario(empresa_id, usuario_id);
      if (cajasAsignadas !== null) {
        if (cajasAsignadas.length === 0) return [];
        where.caja_id = { in: cajasAsignadas };
      }
    }
    const sesiones = await this.prisma.sesiones_caja.findMany({
      where,
      include: {
        cajas: true,
        empresas_sucursales: true,
        usuario: { select: { id: true, nombres: true, email: true } },
        movimiento_cajas: {
          orderBy: { fecha_mov: 'desc' },
          take: 20,
          include: {
            tipo_movimiento_cajas: true,
          },
        },
      },
      orderBy: { fecha_apertura: 'desc' },
    });

    // Calcular saldo actual para cada sesión
    return sesiones.map((sesion) => {
      const montoApertura = Number(sesion.monto_apertura) || 0;
      const ventasEfectivo = Number(sesion.monto_ventas_efectivo) || 0;
      const entradasManual = Number(sesion.monto_entradas_manual) || 0;
      const salidasManual = Number(sesion.monto_salidas_manual) || 0;
      const saldoActual = montoApertura + ventasEfectivo + entradasManual - salidasManual;
      return { ...sesion, saldo_actual: saldoActual };
    });
  }

  async getSesionesHistorial(empresa_id: string, page = 1, limit = 20, caja_id?: string) {
    const skip = (page - 1) * limit;
    const where: any = { empresa_id };
    if (caja_id) where.caja_id = caja_id;

    const [items, total] = await Promise.all([
      this.prisma.sesiones_caja.findMany({
        where,
        include: {
          cajas: true,
          empresas_sucursales: true,
          usuario: { select: { id: true, nombres: true, email: true } },
        },
        orderBy: { fecha_apertura: 'desc' },
        skip,
        take: limit,
      }),
      this.prisma.sesiones_caja.count({ where }),
    ]);

    return {
      items,
      total,
      page,
      limit,
      totalPages: Math.ceil(total / limit),
    };
  }

  async getSesionDetalle(sesion_id: string, empresa_id: string) {
    const sesion = await this.prisma.sesiones_caja.findFirst({
      where: { id: sesion_id, empresa_id },
      include: {
        cajas: true,
        empresas_sucursales: true,
        usuario: { select: { id: true, nombres: true, email: true } },
        movimiento_cajas: {
          orderBy: { fecha_mov: 'desc' },
          include: {
            tipo_movimiento_cajas: true,
          },
        },
        arqueo_cajas: {
          orderBy: { fecha: 'desc' },
        },
      },
    });

    if (!sesion) throw new NotFoundException('Sesión no encontrada');
    return sesion;
  }

  // ==================== MOVIMIENTOS DE CAJA ====================

  async registrarMovimiento(dto: MovimientoCajaDto, empresa_id: string, usuario_id: string) {
    // Buscar sesión activa del usuario/empresa
    const sesion = await this.prisma.sesiones_caja.findFirst({
      where: { empresa_id, estado: 'ABIERTA' },
    });
    if (!sesion) {
      throw new BadRequestException('No hay una caja abierta. Debe abrir caja primero.');
    }

    // Buscar tipo de movimiento
    const tipoMov = await this.prisma.tipo_movimiento_cajas.findFirst({
      where: { descripcion: { contains: dto.tipo === 'ENTRADA' ? 'Entrada' : 'Salida', mode: 'insensitive' } },
    });

    const movimiento = await this.prisma.movimiento_cajas.create({
      data: {
        sesion_id: sesion.id,
        empresa_id,
        tipo_movimiento_caja_id: tipoMov?.id,
        monto: dto.monto,
        descripcion: dto.concepto,
        referencia: dto.referencia,
        usuario_id,
        fecha_mov: new Date().toISOString(),
      },
    });

    // Actualizar totales en la sesión con incremento atómico (evita que dos
    // movimientos casi simultáneos se pisen entre sí, ver mismo fix en
    // facturas.service.ts).
    const updateField = dto.tipo === 'ENTRADA' ? 'monto_entradas_manual' : 'monto_salidas_manual';

    await this.prisma.sesiones_caja.update({
      where: { id: sesion.id },
      data: {
        [updateField]: { increment: dto.monto },
        updated_at: new Date(),
      },
    });

    this.logger.log(`Movimiento caja (${dto.tipo}): ${dto.monto} | Empresa: ${empresa_id}`, 'TesoreriaService');
    await this.auditService.log({
      empresa_id,
      user_id: usuario_id,
      action: dto.tipo === 'ENTRADA' ? 'CREATE' : 'UPDATE',
      entity_type: 'movimiento_caja',
      entity_id: movimiento.id,
      descripcion: `${dto.tipo === 'ENTRADA' ? 'Entrada' : 'Salida'} de caja: ${dto.monto} | Concepto: ${dto.concepto ?? ''}`,
      new_value: { tipo: dto.tipo, monto: dto.monto, concepto: dto.concepto },
    });
    return { message: `${dto.tipo === 'ENTRADA' ? 'Entrada' : 'Salida'} registrada`, data: movimiento };
  }

  async getMovimientos(
    empresa_id: string,
    opts: {
      sesion_id?: string;
      caja_id?: string;
      page?: number;
      limit?: number;
      desde?: string;
      hasta?: string;
      search?: string;
      medio_pago_id?: string;
      /** Quién consulta: alcance por sucursal (y rubro del selector) vía la caja de la sesión. */
      usuarioId?: string;
    } = {},
  ) {
    const { sesion_id, caja_id, page = 1, limit = 20, desde, hasta, search, medio_pago_id, usuarioId } = opts;
    const skip = (page - 1) * limit;
    const where: any = { empresa_id };

    if (sesion_id) where.sesion_id = sesion_id;
    if (caja_id) {
      where.sesiones_caja = { caja_id };
    }
    // Sin esto el widget "Movimientos de caja" del dashboard mostraba los de
    // toda la empresa, aunque el resto del dashboard sí respetara el alcance.
    const alcance = await alcanceSucursalUsuario(this.prisma, usuarioId, empresa_id);
    const filtroSucursal = filtroSucursalOperativo(alcance);
    if (filtroSucursal !== undefined) {
      where.sesiones_caja = { ...(where.sesiones_caja ?? {}), cajas: { sucursal_id: filtroSucursal } };
    }
    if (desde || hasta) {
      where.fecha_mov = {};
      if (desde) where.fecha_mov.gte = this.inicioDiaLocal(desde);
      if (hasta) where.fecha_mov.lte = this.finDiaLocal(hasta);
    }
    if (search) {
      where.OR = [
        { descripcion: { contains: search, mode: 'insensitive' } },
        { referencia: { contains: search, mode: 'insensitive' } },
      ];
    }

    // Si hay filtro por medio_pago, derivamos los IDs de movimiento que matchean ANTES de paginar
    if (medio_pago_id) {
      const matchIds = await this.derivarMovimientoIdsPorMedio(empresa_id, medio_pago_id, where);
      where.id = { in: matchIds.length > 0 ? matchIds : ['__none__'] };
    }

    const [items, total] = await Promise.all([
      this.prisma.movimiento_cajas.findMany({
        where,
        include: {
          sesiones_caja: {
            include: {
              cajas: true,
              usuario: { select: { id: true, nombres: true, email: true } },
            },
          },
          tipo_movimiento_cajas: true,
          usuario: { select: { id: true, nombres: true, email: true } },
        },
        orderBy: { fecha_mov: 'desc' },
        skip,
        take: limit,
      }),
      this.prisma.movimiento_cajas.count({ where }),
    ]);

    // Enriquecer cada movimiento con medio_pago derivado
    const enriched = await this.enriquecerMovimientosConMedio(empresa_id, items);

    const itemsFormatted = enriched.map((mov) => ({
      ...mov,
      fecha_mov: formatDateResponse(mov.fecha_mov),
    }));

    return {
      items: itemsFormatted,
      total,
      page,
      limit,
      totalPages: Math.ceil(total / limit),
      // Totales de TODO lo filtrado, no de la página. La pantalla los sumaba del
      // lado del cliente sobre los 20 registros visibles, así que "Total salidas"
      // decía 75.000 en una hoja y 130.000 en la otra, y el día nunca cerraba.
      totales: await this.totalesMovimientos(where),
    };
  }

  /**
   * Los filtros de fecha llegan como "YYYY-MM-DD" (input type=date) y hay que
   * resolverlos al día calendario de Paraguay, porque `fecha_mov` es timestamptz
   * con la hora real del movimiento.
   *
   * `new Date("2026-09-16")` da medianoche UTC, que acá son las 21:00 del día
   * anterior; el código viejo además le aplicaba `setHours(23,59,59)` sobre esa
   * fecha, y en -03 eso retrocedía al día anterior. Resultado: filtrar
   * 16/09–16/09 devolvía 0 de 120 movimientos, porque el tope quedaba en
   * 2026-09-16T02:59Z. Construyéndolas con el constructor local caen donde
   * corresponde (el server corre en America/Asuncion).
   */
  private inicioDiaLocal(valor: string): Date {
    const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(valor.trim());
    if (!m) return new Date(valor);
    return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]), 0, 0, 0, 0);
  }

  private finDiaLocal(valor: string): Date {
    const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(valor.trim());
    if (!m) {
      // Ya trae hora: se respeta tal cual, sólo se estira al final del segundo.
      const d = new Date(valor);
      d.setMilliseconds(999);
      return d;
    }
    return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]), 23, 59, 59, 999);
  }

  /**
   * Clasificación entrada/salida. Los tipos son datos (`tipo_movimiento_cajas`),
   * no un enum, así que se decide por el texto: Entrada, Venta y Apertura suman;
   * Salida, Retiro, Depósito y Cierre restan. Un movimiento sin tipo cuenta como
   * salida. Misma regla que usa la pantalla, para que los números no difieran.
   */
  private esTipoEntrada(descripcion?: string | null): boolean {
    const t = (descripcion || '').toLowerCase();
    return t.includes('entrada') || t.includes('venta') || t.includes('apertura');
  }

  /** Suma entradas y salidas sobre el filtro completo, en una sola agregación. */
  private async totalesMovimientos(where: any) {
    const porTipo = await this.prisma.movimiento_cajas.groupBy({
      by: ['tipo_movimiento_caja_id'],
      where,
      _sum: { monto: true },
      _count: { _all: true },
    });

    const ids = porTipo
      .map((g) => g.tipo_movimiento_caja_id)
      .filter((id): id is string => !!id);
    const tipos = ids.length
      ? await this.prisma.tipo_movimiento_cajas.findMany({
          where: { id: { in: ids } },
          select: { id: true, descripcion: true },
        })
      : [];
    const descripcionPorId = new Map(tipos.map((t) => [t.id, t.descripcion]));

    let entradas = 0;
    let salidas = 0;
    let cantidad = 0;
    for (const g of porTipo) {
      const monto = Number(g._sum.monto ?? 0);
      cantidad += g._count._all;
      if (this.esTipoEntrada(descripcionPorId.get(g.tipo_movimiento_caja_id ?? ''))) {
        entradas += monto;
      } else {
        salidas += monto;
      }
    }

    return { entradas, salidas, neto: entradas - salidas, cantidad };
  }

  /**
   * Deriva medio de pago de cada movimiento:
   * - referencia = factura_cab.id → match en factura_forma_pagos por monto coincidente
   * - referencia = recibo.id → "Efectivo" (cobros solo registran el efectivo en caja)
   * - resto (apertura/cierre/manual) → null
   */
  private async enriquecerMovimientosConMedio(empresa_id: string, movs: any[]): Promise<any[]> {
    if (!movs.length) return movs;
    const referencias = Array.from(
      new Set(movs.map((m) => m.referencia).filter((r): r is string => !!r && /^[0-9a-f-]{36}$/i.test(r))),
    );
    if (!referencias.length) {
      return movs.map((m) => ({ ...m, medio_pago_descripcion: null, medio_pago_id: null }));
    }

    // Facturas que coinciden con esas referencias
    const facturas = await this.prisma.factura_cab.findMany({
      where: { id: { in: referencias }, empresa_id },
      select: {
        id: true,
        factura_forma_pagos: {
          select: {
            medio_pago_id: true,
            dmontipag: true,
            medio_pago: { select: { id: true, descripcion: true } },
          },
        },
      },
    });
    const facturaMap = new Map(facturas.map((f) => [f.id, f.factura_forma_pagos]));

    // Recibos: cargar detalles para derivar medio por match de monto
    const recibos = await this.prisma.recibos_cobro.findMany({
      where: { id: { in: referencias }, empresa_id },
      select: {
        id: true,
        recibo_cobro_detalle: {
          select: {
            medio_pago_id: true,
            monto_pagado: true,
            medio_pago: { select: { id: true, descripcion: true } },
          },
        },
      },
    });
    const reciboMap = new Map(recibos.map((r) => [r.id, r.recibo_cobro_detalle]));

    return movs.map((m) => {
      const ref = m.referencia;
      let medio_pago_descripcion: string | null = null;
      let medio_pago_id: string | null = null;

      if (ref && facturaMap.has(ref)) {
        const formas = facturaMap.get(ref) || [];
        if (formas.length === 1) {
          medio_pago_descripcion = formas[0].medio_pago?.descripcion || null;
          medio_pago_id = formas[0].medio_pago_id || null;
        } else if (formas.length > 1) {
          // Match por monto (un movimiento por medio en facturas)
          const monto = Math.abs(Number(m.monto || 0));
          const hit = formas.find((f) => Math.abs(Number(f.dmontipag || 0) - monto) < 0.01);
          if (hit) {
            medio_pago_descripcion = hit.medio_pago?.descripcion || null;
            medio_pago_id = hit.medio_pago_id || null;
          } else {
            medio_pago_descripcion = 'Mixto';
          }
        }
      } else if (ref && reciboMap.has(ref)) {
        const formas = (reciboMap.get(ref) || []).map((d) => ({
          medio_pago_id: d.medio_pago_id,
          monto: Number(d.monto_pagado || 0),
          descripcion: d.medio_pago?.descripcion || null,
        }));
        if (formas.length === 1) {
          medio_pago_descripcion = formas[0].descripcion;
          medio_pago_id = formas[0].medio_pago_id;
        } else if (formas.length > 1) {
          const monto = Math.abs(Number(m.monto || 0));
          const hit = formas.find((f) => Math.abs(f.monto - monto) < 0.01);
          if (hit) {
            medio_pago_descripcion = hit.descripcion;
            medio_pago_id = hit.medio_pago_id;
          } else {
            medio_pago_descripcion = 'Mixto';
          }
        }
      }

      return { ...m, medio_pago_descripcion, medio_pago_id };
    });
  }

  /**
   * Para el filtro: devuelve los movimiento.id que matchean un medio_pago_id dado.
   * Limita el universo por el `where` ya construido (empresa, sesion, fechas).
   */
  private async derivarMovimientoIdsPorMedio(
    empresa_id: string,
    medio_pago_id: string,
    baseWhere: any,
  ): Promise<string[]> {
    const candidatos = await this.prisma.movimiento_cajas.findMany({
      where: baseWhere,
      select: { id: true, referencia: true, monto: true },
      take: 5000,
    });
    if (!candidatos.length) return [];
    const enriquecidos = await this.enriquecerMovimientosConMedio(empresa_id, candidatos as any);
    return enriquecidos.filter((m) => m.medio_pago_id === medio_pago_id).map((m) => m.id);
  }

  // ==================== ARQUEOS ====================

  async getArqueos(empresa_id: string, page = 1, limit = 20, caja_id?: string) {
    const skip = (page - 1) * limit;
    const where: any = { empresa_id };
    if (caja_id) where.caja_id = caja_id;

    const [items, total] = await Promise.all([
      this.prisma.arqueo_cajas.findMany({
        where,
        include: {
          cajas: true,
          sesiones_caja: true,
        },
        orderBy: { fecha: 'desc' },
        skip,
        take: limit,
      }),
      this.prisma.arqueo_cajas.count({ where }),
    ]);

    // Transformar fechas a strings ISO para asegurar serialización correcta
    const itemsFormatted = items.map((arq) => ({ ...arq, fecha: formatDateResponse(arq.fecha) }));

    return {
      items: itemsFormatted,
      total,
      page,
      limit,
      totalPages: Math.ceil(total / limit),
    };
  }

  // ==================== ARQUEO STANDALONE (Fase 1) ====================

  async crearArqueo(
    sesion_id: string,
    dto: {
      observacion?: string;
      arqueo_detalle?: {
        moneda_id?: string;
        tipo: string;
        denominacion?: number;
        cantidad: number;
        subtotal: number;
        observacion?: string;
      }[];
      token_autorizacion?: string;
    },
    empresa_id: string,
    usuario_id: string,
  ) {
    const sesion = await this.prisma.sesiones_caja.findFirst({
      where: { id: sesion_id, empresa_id, estado: 'ABIERTA' },
    });
    if (!sesion) throw new NotFoundException('Sesión de caja no encontrada o ya cerrada');

    // Calcular total sistema
    const montoApertura = Number(sesion.monto_apertura) || 0;
    const ventasEfectivo = Number(sesion.monto_ventas_efectivo) || 0;
    const entradasManual = Number(sesion.monto_entradas_manual) || 0;
    const salidasManual = Number(sesion.monto_salidas_manual) || 0;
    const totalSistema = montoApertura + ventasEfectivo + entradasManual - salidasManual;

    // Calcular total contado desde detalle
    const totalContado = dto.arqueo_detalle?.reduce((acc, item) => acc + item.subtotal, 0) ?? 0;
    const diferencia = totalContado - totalSistema;

    // Verificar diferencia permitida
    const posConfig = sesion.caja_id ? await this.obtenerPosConfig(sesion.caja_id) : null;
    const diferenciaPermitida = posConfig?.diferencia_arqueo_permitida
      ? Number(posConfig.diferencia_arqueo_permitida)
      : null;
    let autorizadorId: string | null = null;

    if (diferenciaPermitida !== null && Math.abs(diferencia) > diferenciaPermitida) {
      if (!dto.token_autorizacion) {
        throw new ForbiddenException(
          `Diferencia de ${diferencia} excede el límite permitido de ${diferenciaPermitida}. Se requiere autorización.`,
        );
      }
      const payload = await this.verificarTokenAutorizacion(dto.token_autorizacion, 'arqueo_diferencia', empresa_id);
      autorizadorId = payload.sub;
    }

    const arqueo = await this.prisma.arqueo_cajas.create({
      data: {
        empresa_id,
        caja_id: sesion.caja_id,
        sesion_id,
        total_sistema: totalSistema,
        total_contado: totalContado,
        diferencia,
        observacion: dto.observacion,
        usuario_id,
        fecha: new Date(),
        tipo_arqueo: 'parcial',
        estado: Math.abs(diferencia) > (diferenciaPermitida ?? Infinity) ? 'con_diferencia' : 'finalizado',
        autorizado_por: autorizadorId,
        diferencia_permitida: diferenciaPermitida,
      },
    });

    // Guardar detalle
    if (dto.arqueo_detalle && dto.arqueo_detalle.length > 0) {
      await this.prisma.arqueo_caja_detalle.createMany({
        data: dto.arqueo_detalle.map((item) => ({
          arqueo_id: arqueo.id,
          moneda_id: item.moneda_id ?? null,
          tipo: item.tipo,
          denominacion: item.denominacion ?? null,
          cantidad: item.cantidad,
          subtotal: item.subtotal,
          observacion: item.observacion ?? null,
        })),
      });
    }

    this.logger.log(`Arqueo parcial creado: ${arqueo.id} | Sesión: ${sesion_id}`, 'TesoreriaService');
    await this.auditService.log({
      empresa_id,
      user_id: usuario_id,
      action: 'CREATE',
      entity_type: 'arqueo_caja',
      entity_id: arqueo.id,
      descripcion: `Arqueo parcial | Total sistema: ${totalSistema} | Total contado: ${totalContado} | Diferencia: ${diferencia}`,
      new_value: { total_sistema: totalSistema, total_contado: totalContado, diferencia },
    });

    return { message: 'Arqueo registrado', data: arqueo };
  }

  async getArqueoDetalle(arqueo_id: string, empresa_id: string) {
    const arqueo = await this.prisma.arqueo_cajas.findFirst({
      where: { id: arqueo_id, empresa_id },
      include: {
        cajas: true,
        sesiones_caja: true,
        arqueo_caja_detalle: {
          include: { moneda: true },
          orderBy: { created_at: 'asc' },
        },
      },
    });
    if (!arqueo) throw new NotFoundException('Arqueo no encontrado');
    return arqueo;
  }

  // ==================== PDF ARQUEO / CIERRE ====================

  async generarPdfArqueoSesion(
    sesion_id: string,
    empresa_id: string,
    formato: 'a4' | 'ticket' | 'ticket-detallado' | 'a4-detallado' = 'a4',
    arqueo_id?: string,
  ): Promise<{ pdfBuffer: Buffer; fileName: string }> {
    const sesion = await this.prisma.sesiones_caja.findFirst({
      where: { id: sesion_id, empresa_id },
      include: {
        cajas: true,
        empresas_sucursales: true,
        usuario: { select: { id: true, nombres: true, email: true } },
        usuario_sesiones_caja_usuario_cierre_idTousuario: { select: { id: true, nombres: true, email: true } },
        movimiento_cajas: {
          orderBy: { fecha_mov: 'asc' },
          include: { tipo_movimiento_cajas: true, usuario: { select: { nombres: true } } },
        },
        arqueo_cajas: { orderBy: { fecha: 'desc' }, include: { cajas: true } },
      },
    });
    if (!sesion) throw new NotFoundException('Sesión no encontrada');

    const arqueoElegido = arqueo_id ? sesion.arqueo_cajas.find((a) => a.id === arqueo_id) : sesion.arqueo_cajas[0];

    const empresa = await this.prisma.empresas.findFirst({
      where: { id: empresa_id },
      select: {
        razon_social: true,
        nombre_fantasia: true,
        ruc: true,
        dv: true,
        celular: true,
        email: true,
        logo: true,
      },
    });
    // Sucursal de la caja (no `sesiones_caja.sucursal_id`, que puede venir mal del front).
    const sucursalArqueoId = sesion.cajas?.sucursal_id ?? sesion.sucursal_id ?? null;
    const sucursalArqueo = sucursalArqueoId
      ? await this.prisma.empresas_sucursales.findFirst({
          where: { id: sucursalArqueoId, empresa_id },
          select: { logo: true, nombre_fantasia: true },
        })
      : null;

    // Ventas agrupadas por medio de pago (factura_forma_pagos de la sesión)
    const ventasPorMedioRaw = await this.prisma.$queryRaw<{ medio_pago: string; cantidad: number; total: number }[]>`
      SELECT
        COALESCE(mp.descripcion, 'Sin medio') AS medio_pago,
        COUNT(*)::int AS cantidad,
        COALESCE(SUM(ffp.dmontipag), 0)::float AS total
      FROM factura_forma_pagos ffp
      LEFT JOIN medio_pago mp ON mp.id = ffp.medio_pago_id
      WHERE ffp.sesion_caja_id = ${sesion_id}::uuid
        AND ffp.empresa_id = ${empresa_id}::uuid
      GROUP BY mp.descripcion
      ORDER BY total DESC
    `;

    // Cobros agrupados por medio de pago (recibo_cobro_detalle vía sesion_caja_id del recibo)
    const cobrosPorMedioRaw = await this.prisma.$queryRaw<{ medio_pago: string; cantidad: number; total: number }[]>`
      SELECT
        COALESCE(mp.descripcion, 'Sin medio') AS medio_pago,
        COUNT(*)::int AS cantidad,
        COALESCE(SUM(rcd.monto_pagado), 0)::float AS total
      FROM recibo_cobro_detalle rcd
      INNER JOIN recibos_cobro rc ON rc.id = rcd.recibo_cobro_id
      LEFT JOIN medio_pago mp ON mp.id = rcd.medio_pago_id
      WHERE rc.sesion_caja_id = ${sesion_id}::uuid
        AND rc.empresa_id = ${empresa_id}::uuid
        AND rc.estado <> 'anulado'
      GROUP BY mp.descripcion
      ORDER BY total DESC
    `;

    // Combinar ventas + cobros por medio
    const ingresosPorMedioMap = new Map<string, { cantidad: number; total: number }>();
    for (const v of ventasPorMedioRaw) {
      const prev = ingresosPorMedioMap.get(v.medio_pago) || { cantidad: 0, total: 0 };
      prev.cantidad += Number(v.cantidad);
      prev.total += Number(v.total);
      ingresosPorMedioMap.set(v.medio_pago, prev);
    }
    for (const c of cobrosPorMedioRaw) {
      const prev = ingresosPorMedioMap.get(c.medio_pago) || { cantidad: 0, total: 0 };
      prev.cantidad += Number(c.cantidad);
      prev.total += Number(c.total);
      ingresosPorMedioMap.set(c.medio_pago, prev);
    }
    const ventasPorMedio = Array.from(ingresosPorMedioMap.entries())
      .map(([medio_pago, v]) => ({ medio_pago, cantidad: v.cantidad, total: v.total }))
      .sort((a, b) => b.total - a.total);

    // Enriquecer movimientos con medio_pago derivado
    const movimientosEnriquecidos = await this.enriquecerMovimientosConMedio(
      empresa_id,
      sesion.movimiento_cajas as any,
    );

    const saldoEsperado =
      Number(sesion.monto_final_sistema ?? 0) ||
      Number(sesion.monto_apertura || 0) +
        Number(sesion.monto_ventas_efectivo || 0) +
        Number(sesion.monto_entradas_manual || 0) -
        Number(sesion.monto_salidas_manual || 0);
    const montoContado = Number(arqueoElegido?.total_contado ?? sesion.monto_cierre ?? 0);
    const diferenciaRaw =
      arqueoElegido?.diferencia != null ? Number(arqueoElegido.diferencia) : montoContado - saldoEsperado;

    const payload = {
      formato,
      tipo: 'view',
      ancho_papel: 80,
      moneda: { codigo: 'PYG' },
      empresa: {
        razon_social: empresa?.razon_social || '',
        ruc: empresa?.ruc || '',
        dv: empresa?.dv || '',
        telefono: empresa?.celular || '',
        email: empresa?.email || '',
        // Logo y nombre comercial de la sucursal del documento (si no tiene, los de la empresa).
        nombre_fantasia: nombreFantasiaEmisor(sucursalArqueo, empresa),
        logo_url: logoEmisor(sucursalArqueo, empresa),
      },
      sucursal: sesion.empresas_sucursales
        ? {
            nombre: sesion.empresas_sucursales.descripcion || '',
            direccion: sesion.empresas_sucursales.direccion || '',
            telefono: '',
          }
        : null,
      caja: {
        id: sesion.cajas?.id,
        nombre: sesion.cajas?.descripcion || 'Caja',
        codigo: sesion.cajas?.descripcion || '',
      },
      sesion: {
        id: sesion.id,
        operador: sesion.usuario?.nombres || '-',
        estado: sesion.estado || '-',
        apertura_fecha: sesion.fecha_apertura?.toISOString() || null,
        cierre_fecha: sesion.fecha_cierre?.toISOString() || null,
        monto_apertura: Number(sesion.monto_apertura || 0),
      },
      arqueo: {
        tipo: arqueoElegido?.tipo_arqueo || (sesion.estado === 'CERRADA' ? 'cierre' : 'control'),
        fecha: (arqueoElegido?.fecha || sesion.fecha_cierre || new Date()).toISOString(),
        saldo_esperado: saldoEsperado,
        monto_contado: montoContado,
        diferencia: diferenciaRaw,
        observaciones: arqueoElegido?.observacion || sesion.observaciones || null,
      },
      resumen: {
        ventas_efectivo: Number(sesion.monto_ventas_efectivo || 0),
        entradas_manuales: Number(sesion.monto_entradas_manual || 0),
        salidas_manuales: Number(sesion.monto_salidas_manual || 0),
      },
      ventas_por_medio: ventasPorMedio.map((v) => ({
        medio_pago: v.medio_pago,
        cantidad: Number(v.cantidad),
        total: Number(v.total),
      })),
      movimientos: movimientosEnriquecidos.map((m: any) => {
        const desc = (m.tipo_movimiento_cajas?.descripcion || '').toLowerCase();
        const tipo = desc.includes('salida') || Number(m.monto) < 0 ? 'SALIDA' : 'ENTRADA';
        return {
          fecha: m.fecha_mov?.toISOString() || null,
          tipo,
          concepto: m.descripcion || m.tipo_movimiento_cajas?.descripcion || '-',
          referencia: m.referencia || null,
          medio_pago: m.medio_pago_descripcion || null,
          monto: Number(m.monto || 0),
          usuario: m.usuario?.nombres || null,
        };
      }),
      incluir_movimientos: true,
    } as any;

    // Formato detallado: agregar detalle de recibos cobrados con cliente + items
    if (formato === 'ticket-detallado' || formato === 'a4-detallado') {
      payload.cobros_detalle = await this.buildCobrosDetalleArqueo(sesion_id, empresa_id);
    }

    const url = `${envs.apiGeneradorPDF}/api/v2/generate-arqueo-caja`;
    const response = await fetch(url, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(payload),
    });
    if (!response.ok) {
      const errorBody = await response.text();
      throw new BadRequestException(`Error al generar PDF de arqueo: ${errorBody}`);
    }
    const arrayBuffer = await response.arrayBuffer();
    const fileName = `arqueo-${sesion.cajas?.descripcion || 'caja'}-${sesion_id.slice(0, 8)}.pdf`;
    return { pdfBuffer: Buffer.from(arrayBuffer), fileName };
  }

  async generarPdfArqueo(
    arqueo_id: string,
    empresa_id: string,
    formato: 'a4' | 'ticket' | 'ticket-detallado' | 'a4-detallado' = 'a4',
  ): Promise<{ pdfBuffer: Buffer; fileName: string }> {
    const arqueo = await this.prisma.arqueo_cajas.findFirst({
      where: { id: arqueo_id, empresa_id },
      select: { sesion_id: true },
    });
    if (!arqueo || !arqueo.sesion_id) throw new NotFoundException('Arqueo no encontrado');
    return this.generarPdfArqueoSesion(arqueo.sesion_id, empresa_id, formato, arqueo_id);
  }

  /**
   * Detalle expandido de recibos cobrados de la sesión: por cada recibo,
   * datos del cliente y los ítems de las facturas cobradas. Usado por el
   * formato A4 "detallado" del arqueo.
   */
  private async buildCobrosDetalleArqueo(sesion_id: string, empresa_id: string) {
    const recibos = await this.prisma.recibos_cobro.findMany({
      where: { sesion_caja_id: sesion_id, empresa_id, estado: { not: 'anulado' } },
      orderBy: { fecha_emision: 'asc' },
      include: {
        clientes: { include: { personas: true } },
        moneda: true,
        recibo_cobro_detalle: {
          include: {
            medio_pago: true,
            factura_cuotas: {
              include: {
                factura_cab: {
                  include: {
                    factura_det: { include: { productos: true }, orderBy: { item: 'asc' } },
                    factura_cuotas: true,
                  },
                },
              },
            },
          },
        },
        facturas_multi: {
          include: {
            factura: {
              include: {
                factura_det: { include: { productos: true }, orderBy: { item: 'asc' } },
                factura_cuotas: true,
              },
            },
          },
        },
        medios_pago_multi: { include: { moneda: true } },
      },
    });

    return recibos.map((r) => {
      const persona = r.clientes?.personas;
      const documento = persona?.ruc ? `${persona.ruc}-${persona.dv || ''}` : persona?.nro_documento || '-';

      // Recolectar facturas únicas: priorizar multi, sino legacy via cuotas
      const facturasMap = new Map<string, { factura_cab: any; monto_pagado: number }>();
      for (const fm of r.facturas_multi || []) {
        const f = fm.factura;
        if (!f) continue;
        const prev = facturasMap.get(f.id);
        const monto = Number(fm.monto_pagado || 0);
        if (prev) prev.monto_pagado += monto;
        else facturasMap.set(f.id, { factura_cab: f, monto_pagado: monto });
      }
      if (facturasMap.size === 0) {
        for (const det of r.recibo_cobro_detalle || []) {
          const f = det.factura_cuotas?.factura_cab;
          if (!f) continue;
          const monto = Number(det.monto_pagado || 0);
          const prev = facturasMap.get(f.id);
          if (prev) prev.monto_pagado += monto;
          else facturasMap.set(f.id, { factura_cab: f, monto_pagado: monto });
        }
      }

      const facturas = Array.from(facturasMap.values()).map(({ factura_cab: f, monto_pagado }) => {
        // Saldo real = suma de saldo_pendiente de cuotas no pagadas (fuente de verdad
        // que usa "Facturas Crédito Pendientes"); fallback a factura_cab.saldo_pendiente.
        const cuotas = (f.factura_cuotas || []) as any[];
        const saldoCuotas = cuotas.reduce((acc, c) => {
          const estado = (c.estado || '').toLowerCase();
          if (estado === 'pagada' || estado === 'pagado') return acc;
          return acc + Number(c.saldo_pendiente ?? c.dmoncuota ?? 0);
        }, 0);
        const saldoFactura = cuotas.length > 0 ? saldoCuotas : Number(f.saldo_pendiente || 0);
        return {
          id: f.id,
          numero: `${f.dest || ''}-${f.dpunexp || ''}-${f.dnumdoc || ''}`.replace(/^-+|-+$/g, ''),
          fecha: f.dfeemide?.toISOString() || null,
          total: Number(f.total_factura || 0),
          saldo_pendiente: saldoFactura,
          monto_pagado,
          items: (f.factura_det || []).map((it: any) => ({
            descripcion: it.ddesproser || it.productos?.descripcion || '-',
            cantidad: Number(it.dcantproser || 0),
            precio_unitario: Number(it.duniproser || 0),
            subtotal: Number(it.dtotopeitem || 0),
          })),
        };
      });

      const medios = (r.medios_pago_multi || []).length
        ? r.medios_pago_multi.map((m) => ({
            medio: m.medio,
            monto: Number(m.monto || 0),
            referencia: m.referencia || null,
          }))
        : (r.recibo_cobro_detalle || []).map((d) => ({
            medio: d.medio_pago?.descripcion || '-',
            monto: Number(d.monto_pagado || 0),
            referencia: d.numero_cheque || d.codigo_autorizacion || null,
          }));

      return {
        recibo_id: r.id,
        numero_recibo: r.numero_recibo,
        fecha: (r.fecha_registro || r.fecha_emision)?.toISOString() || null,
        monto_total: Number(r.monto_total || 0),
        cliente: {
          razon_social: persona?.razon_social || '-',
          documento,
          ruc: persona?.ruc || null,
          dv: persona?.dv || null,
          nro_documento: persona?.nro_documento || null,
          email: persona?.email || null,
          telefono: persona?.telefono || persona?.celular || null,
        },
        moneda: r.moneda?.codigo || 'PYG',
        medios,
        facturas,
      };
    });
  }

  // ==================== DOCUMENTOS POR SESIÓN (Fase 1) ====================

  async asignarDocumentosSesion(
    sesion_id: string,
    documentos: { tipo_documento_id: string; numeracion_id?: string }[],
    empresa_id: string,
  ) {
    const sesion = await this.prisma.sesiones_caja.findFirst({
      where: { id: sesion_id, empresa_id, estado: 'ABIERTA' },
    });
    if (!sesion) throw new NotFoundException('Sesión no encontrada o ya cerrada');

    // Desactivar documentos anteriores de esta sesión
    await this.prisma.sesion_caja_documentos.updateMany({
      where: { sesion_caja_id: sesion_id },
      data: { activo: false },
    });

    // Crear nuevas asignaciones
    const created = await this.prisma.sesion_caja_documentos.createMany({
      data: documentos.map((doc) => ({
        sesion_caja_id: sesion_id,
        tipo_documento_id: doc.tipo_documento_id,
        numeracion_id: doc.numeracion_id ?? null,
        activo: true,
      })),
    });

    return { message: `${created.count} documentos asignados a la sesión`, count: created.count };
  }

  async getDocumentosSesion(sesion_id: string, empresa_id: string) {
    const sesion = await this.prisma.sesiones_caja.findFirst({
      where: { id: sesion_id, empresa_id },
    });
    if (!sesion) throw new NotFoundException('Sesión no encontrada');

    return this.prisma.sesion_caja_documentos.findMany({
      where: { sesion_caja_id: sesion_id, activo: true },
      include: {
        tipo_documento: true,
        numeraciones_documento: true,
      },
    });
  }

  // ==================== DISPOSITIVOS (Fase 1) ====================

  async getDispositivos(empresa_id: string, caja_id?: string) {
    const where: any = { empresa_id };
    if (caja_id) where.caja_id = caja_id;

    return this.prisma.dispositivos_autorizados.findMany({
      where,
      include: {
        cajas: { select: { id: true, descripcion: true } },
        usuario: { select: { id: true, username: true, nombres: true } },
      },
      orderBy: { ultimo_acceso: 'desc' },
    });
  }

  async revocarDispositivo(dispositivo_id: string, empresa_id: string) {
    const dispositivo = await this.prisma.dispositivos_autorizados.findFirst({
      where: { id: dispositivo_id, empresa_id },
    });
    if (!dispositivo) throw new NotFoundException('Dispositivo no encontrado');

    await this.prisma.dispositivos_autorizados.update({
      where: { id: dispositivo_id },
      data: { estado: 'revocado' },
    });

    return { message: 'Dispositivo revocado' };
  }

  // ==================== CAJAS DISPONIBLES ====================

  /**
   * Cajas que el usuario puede operar en la empresa.
   *
   * Filtra por las asignaciones del usuario (una asignación sin `caja_id`
   * significa "todas las cajas de esa sucursal"), con bypass: si el filtro no
   * le deja NINGUNA caja, se devuelven todas las de la empresa.
   *
   * El bypass mira el resultado y no la cantidad de filas de asignación. Mirar
   * las filas dejaba sin ninguna caja —y por lo tanto sin POS— a un usuario que
   * tuviera asignaciones a otra sucursal o a una caja distinta, que es
   * justamente lo que el bypass tenía que evitar.
   *
   * La sucursal debe estar activa: al desactivar una sucursal sus cajas
   * seguían apareciendo en la apertura del POS, porque solo se miraba `estado`
   * de la caja y nunca `active` de la sucursal.
   */
  private async cajasAccesibles(empresa_id: string, usuario_id?: string) {
    const base = {
      empresas_sucursales: { empresa_id, active: { not: false } },
      estado: true,
    };
    const include = { empresas_sucursales: true, pos_config: true };

    // Un actor elevado (superadmin, holding o reseller) queda exento del filtro
    // por asignación: opera todas las cajas de la empresa sin que haya que
    // asignárselas una por una.
    const elevado = await tieneAccesoElevado(this.prisma, usuario_id, empresa_id);

    // La caja donde el propio usuario tiene una sesión ABIERTA se incluye
    // siempre, aunque no la tenga asignada y aunque su sucursal esté
    // desactivada. Si no, una sesión abierta antes de configurar la asignación
    // queda invisible y sin forma de cerrarse desde la UI: el saldo queda
    // encerrado. No amplía el acceso — la sesión ya es suya.
    const conSesionPropia = usuario_id
      ? await this.prisma.cajas.findMany({
          where: {
            empresas_sucursales: { empresa_id },
            sesiones_caja: { some: { user_id: usuario_id, estado: 'ABIERTA' } },
          },
          include,
        })
      : [];

    const conPropias = (cajas: typeof conSesionPropia) => {
      const porId = new Map(cajas.map((c) => [c.id, c]));
      for (const c of conSesionPropia) porId.set(c.id, c);
      return [...porId.values()];
    };

    const asignaciones =
      usuario_id && !elevado
        ? await this.prisma.asignacion_sucursal_caja_usuario.findMany({
            where: { usuario_id },
            select: { sucursal_id: true, caja_id: true },
          })
        : [];

    if (asignaciones.length) {
      const cajasAsignadas = asignaciones.map((a) => a.caja_id).filter((id): id is string => !!id);
      const sucursalesConAccesoTotal = asignaciones
        .filter((a) => !a.caja_id)
        .map((a) => a.sucursal_id)
        .filter((id): id is string => !!id);

      const asignadas = await this.prisma.cajas.findMany({
        where: {
          ...base,
          OR: [{ id: { in: cajasAsignadas } }, { sucursal_id: { in: sucursalesConAccesoTotal } }],
        },
        include,
      });
      if (asignadas.length) return conPropias(asignadas);
    }

    return conPropias(await this.prisma.cajas.findMany({ where: base, include }));
  }

  async getCajasDisponibles(empresa_id: string, usuario_id?: string) {
    const cajas = await this.cajasAccesibles(empresa_id, usuario_id);

    // Para cada caja, buscar si tiene sesión activa
    const cajasConSesion = await Promise.all(
      cajas.map(async (caja) => {
        const sesionActiva = await this.prisma.sesiones_caja.findFirst({
          where: { caja_id: caja.id, estado: 'ABIERTA' },
        });

        return {
          ...caja,
          tiene_sesion_abierta: !!sesionActiva,
          sesion_activa: sesionActiva,
        };
      }),
    );

    return cajasConSesion;
  }

  // ==================== POS CONFIG (Fase 1) ====================

  async getPosConfig(caja_id: string) {
    const config = await this.prisma.pos_config.findUnique({ where: { caja_id } });
    if (!config) {
      // Retornar defaults con campos de autorización
      return {
        id: null,
        caja_id,
        requiere_autorizacion_apertura: false,
        requiere_autorizacion_cierre: false,
        fondo_fijo: null,
        diferencia_maxima_permitida: null,
        tipo_pos: 'admin',
        fullscreen: true,
      };
    }
    return config;
  }
}
