import {
  BadRequestException,
  Injectable,
  Logger,
  NotFoundException,
} from '@nestjs/common';
import { Prisma, rrhh_marcacion_fuente } from '@prisma/client';
import { randomUUID } from 'crypto';
import * as XLSX from 'xlsx';
import { PrismaService } from '../../prisma/prisma.service';
import {
  AjustarMarcacionLimpiaDto,
  QueryBatchesDto,
  QueryLogCorreccionesDto,
  QueryMarcacionesDto,
  QueryMarcacionesLimpiasDto,
} from '../dto/marcaciones.dto';
import { MarcacionesDedupService } from './marcaciones-dedup.service';
import {
  MarcacionesParserService,
  RawMarcacionParsed,
  RowError,
} from './marcaciones-parser.service';
import { RelojApiClientService } from './reloj-api-client.service';
import { HikvisionIsapiClientService } from './hikvision-isapi-client.service';

/**
 * Orquesta la ingesta de marcaciones (M16) y reúne las consultas de M21
 * (raw, limpias, batches, log).
 */

export type ImportarMarcacionesResultado = {
  batch_importacion_id: string;
  fuente: 'API' | 'PLANILLA';
  total_filas: number;
  marcaciones_recibidas: number;
  insertadas_raw: number;
  empleados_no_matcheados: number;
  errores_parse: RowError[];
  dedup: {
    grupos_procesados: number;
    marcaciones_limpias_creadas: number;
    marcaciones_limpias_actualizadas: number;
    marcaciones_duplicadas: number;
    log_correcciones_creados: number;
  };
};

@Injectable()
export class MarcacionesService {
  private readonly logger = new Logger(MarcacionesService.name);

  constructor(
    private readonly prisma: PrismaService,
    private readonly parser: MarcacionesParserService,
    private readonly dedup: MarcacionesDedupService,
    private readonly relojClient: RelojApiClientService,
    private readonly hikvisionClient: HikvisionIsapiClientService,
  ) {}

  // ═════════════════════════════════════════════════════════════════
  // INGESTA
  // ═════════════════════════════════════════════════════════════════

  generarPlantillaImportacionExcel(): Buffer {
    const wb = XLSX.utils.book_new();

    // ── Hoja 1: Formato A — fila por día (la que más se usa) ─────────
    const formatoPorDia = XLSX.utils.aoa_to_sheet([
      ['documento_funcionario', 'fecha', 'hora_entrada', 'hora_salida'],
      ['1234567', '2026-05-19', '08:00', '17:00'],
      ['2345678', '2026-05-19', '08:10', '17:05'],
      ['3456789', '19/05/2026', '07:55', '16:50'],
      ['4567890', '2026-05-19', '08:00', ''],
    ]);
    formatoPorDia['!cols'] = [{ wch: 24 }, { wch: 14 }, { wch: 14 }, { wch: 14 }];
    XLSX.utils.book_append_sheet(wb, formatoPorDia, 'Formato_Por_Dia');

    // ── Hoja 2: Formato B — marcación individual ────────────────────
    const formatoPorMarcacion = XLSX.utils.aoa_to_sheet([
      ['documento_funcionario', 'timestamp_marcacion'],
      ['1234567', '2026-05-19 08:00:00'],
      ['1234567', '2026-05-19 17:00:00'],
      ['2345678', '19/05/2026 08:10:00'],
      ['2345678', '19/05/2026 17:05:00'],
      ['3456789', '2026-05-19T07:55:00Z'],
    ]);
    formatoPorMarcacion['!cols'] = [{ wch: 24 }, { wch: 26 }];
    XLSX.utils.book_append_sheet(wb, formatoPorMarcacion, 'Formato_Por_Marcacion');

    // ── Hoja 3: Instrucciones en columnas estructuradas ─────────────
    const instrucciones = XLSX.utils.aoa_to_sheet([
      ['Plantilla de importación de marcaciones · Módulo Presentismo (M16)'],
      [
        'Elegí UNO de los dos formatos según cómo te entregan las marcaciones. ' +
          'Las filas con error no abortan el lote: se reportan en la respuesta y el resto se importa.',
      ],
      [],
      ['Formato', 'Campo', 'Tipo / formato aceptado', 'Ejemplos', 'Obligatorio', 'Notas y aliases'],
      [
        'Común',
        'documento_funcionario',
        'Sólo dígitos (CI). Longitud 5 a 9.',
        '1234567',
        'Sí',
        'Acepta puntos y guiones (se limpian al importar). Alias: cedula, cedula_identidad, ci, documento, numero_documento, dni.',
      ],
      [
        'A — POR_DIA',
        'fecha',
        'YYYY-MM-DD ó DD/MM/YYYY',
        '2026-05-19 ó 19/05/2026',
        'Sí',
        'Alias: dia, fecha_marcacion.',
      ],
      [
        'A — POR_DIA',
        'hora_entrada',
        'HH:MM ó HH:MM:SS',
        '08:00 ó 08:00:30',
        'Sí (al menos una de entrada/salida)',
        'Alias: entrada, hr_entrada, hora_in.',
      ],
      [
        'A — POR_DIA',
        'hora_salida',
        'HH:MM ó HH:MM:SS',
        '17:00',
        'Sí (al menos una de entrada/salida)',
        'Alias: salida, hr_salida, hora_out. Si está vacía, sólo se registra la entrada.',
      ],
      [
        'B — POR_MARCACION',
        'timestamp_marcacion',
        'ISO 8601, YYYY-MM-DD HH:MM(:SS) ó DD/MM/YYYY HH:MM(:SS)',
        '2026-05-19 08:00:00 · 19/05/2026 17:05 · 2026-05-19T07:55:00Z',
        'Sí',
        'Alias: timestamp, fecha_hora, datetime, fechahora, hora.',
      ],
      [],
      ['Reglas', 'Descripción'],
      ['Archivos soportados', '.xlsx, .xls y .csv. Para CSV se recomienda UTF-8 con separador "," o ";".'],
      [
        'Identificación de empleados',
        'El CI se busca por (empresa, cedula_identidad). Sólo se aceptan empleados ACTIVOS.',
      ],
      [
        'Empleado no encontrado',
        'La fila se marca como ERROR con descripción y NO entra a marcaciones limpias. El resto del lote sigue.',
      ],
      [
        'Deduplicación automática',
        'Tras la importación el sistema agrupa por (empleado, día), toma min=entrada y max=salida, descarta el resto.',
      ],
      [
        'Log de correcciones',
        'Las marcaciones descartadas quedan registradas en el log inmutable (rrhh_marcaciones_log_correcciones).',
      ],
      [
        'Idempotencia',
        'Reimportar el mismo archivo no duplica registros: actualiza la marcación limpia y vuelve a evaluar duplicados.',
      ],
    ]);

    // Merge del título y subtítulo
    instrucciones['!merges'] = [
      { s: { r: 0, c: 0 }, e: { r: 0, c: 5 } },
      { s: { r: 1, c: 0 }, e: { r: 1, c: 5 } },
    ];
    instrucciones['!cols'] = [
      { wch: 18 }, // Formato
      { wch: 22 }, // Campo
      { wch: 38 }, // Tipo / formato aceptado
      { wch: 32 }, // Ejemplos
      { wch: 24 }, // Obligatorio
      { wch: 64 }, // Notas y aliases
    ];
    instrucciones['!rows'] = [
      { hpt: 22 }, // título
      { hpt: 32 }, // subtítulo (envuelve)
    ];
    XLSX.utils.book_append_sheet(wb, instrucciones, 'Instrucciones');

    return XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' });
  }

  /** Importa marcaciones desde un archivo CSV/Excel. */
  async importarPlanilla(
    empresaId: string,
    buffer: Buffer,
    opts: { reloj_id?: string; userId?: string },
  ): Promise<ImportarMarcacionesResultado> {
    if (!buffer || buffer.length === 0) {
      throw new BadRequestException('El archivo está vacío');
    }
    await this.validarReloj(empresaId, opts.reloj_id, 'PLANILLA');

    const parsed = this.parser.parseBuffer(buffer);
    const batchId = randomUUID();

    const persistido = await this.persistirRaw(
      empresaId,
      'PLANILLA',
      batchId,
      parsed.marcaciones,
      opts.reloj_id ?? null,
      opts.userId ?? null,
    );

    const dedup = await this.dedup.deduplicarBatch(empresaId, batchId);

    return {
      batch_importacion_id: batchId,
      fuente: 'PLANILLA',
      total_filas: parsed.total_filas,
      marcaciones_recibidas: parsed.marcaciones.length,
      insertadas_raw: persistido.insertadas,
      empleados_no_matcheados: persistido.no_matcheados,
      errores_parse: parsed.errores,
      dedup: {
        grupos_procesados: dedup.grupos_procesados,
        marcaciones_limpias_creadas: dedup.marcaciones_limpias_creadas,
        marcaciones_limpias_actualizadas: dedup.marcaciones_limpias_actualizadas,
        marcaciones_duplicadas: dedup.marcaciones_duplicadas,
        log_correcciones_creados: dedup.log_correcciones_creados,
      },
    };
  }

  /** Consume el endpoint del reloj API e ingiere las marcaciones recibidas. */
  async sincronizarReloj(
    empresaId: string,
    relojId: string,
    apiKeyEnClaro: string | null,
    opts: { desde?: Date; hasta?: Date; userId?: string },
  ): Promise<ImportarMarcacionesResultado> {
    const reloj = await this.prisma.rrhh_relojes_marcadores.findFirst({
      where: { id: relojId, empresa_id: empresaId },
    });
    if (!reloj) throw new NotFoundException('Reloj no encontrado');
    if (reloj.tipo_conexion !== 'API') {
      throw new BadRequestException('Sólo aplica a relojes con tipo_conexion = API');
    }
    if (!reloj.activo) throw new BadRequestException('El reloj está inactivo');

    const result =
      reloj.protocolo_api === 'HIKVISION_ISAPI'
        ? await this.hikvisionClient.consultarAcsEvent(
            {
              url_base: reloj.url_base ?? '',
              api_key: apiKeyEnClaro,
              campo_documento: reloj.campo_documento,
              campo_timestamp: reloj.campo_timestamp,
            },
            this.resolverVentanaSync(reloj.intervalo_polling_min, opts),
          )
        : await this.relojClient.consultar(
            {
              url_base: reloj.url_base ?? '',
              tipo_auth: (reloj.tipo_auth ?? 'NONE') as any,
              api_key: apiKeyEnClaro,
              formato_respuesta: (reloj.formato_respuesta as any) ?? 'JSON',
              campo_documento: reloj.campo_documento,
              campo_timestamp: reloj.campo_timestamp,
            },
            { desde: opts.desde, hasta: opts.hasta },
          );

    if (!result.ok) {
      throw new BadRequestException(
        `Falló la consulta al reloj: ${result.detalle ?? `HTTP ${result.http_status}`}`,
      );
    }

    const batchId = randomUUID();
    const raws: RawMarcacionParsed[] = result.marcaciones.map((m, idx) => ({
      fila: idx + 1,
      documento_raw: this.limpiarDocumento(m.documento_raw),
      timestamp_marcacion: m.timestamp_marcacion,
    }));
    const persistido = await this.persistirRaw(
      empresaId,
      'API',
      batchId,
      raws,
      relojId,
      opts.userId ?? null,
    );

    await this.prisma.rrhh_relojes_marcadores.update({
      where: { id: relojId },
      data: { ultima_sincronizacion: new Date(), updated_at: new Date() },
    });

    const dedup = await this.dedup.deduplicarBatch(empresaId, batchId);

    return {
      batch_importacion_id: batchId,
      fuente: 'API',
      total_filas: result.marcaciones.length,
      marcaciones_recibidas: result.marcaciones.length,
      insertadas_raw: persistido.insertadas,
      empleados_no_matcheados: persistido.no_matcheados,
      errores_parse: [],
      dedup: {
        grupos_procesados: dedup.grupos_procesados,
        marcaciones_limpias_creadas: dedup.marcaciones_limpias_creadas,
        marcaciones_limpias_actualizadas: dedup.marcaciones_limpias_actualizadas,
        marcaciones_duplicadas: dedup.marcaciones_duplicadas,
        log_correcciones_creados: dedup.log_correcciones_creados,
      },
    };
  }

  /**
   * Ventana de sincronización para relojes Hikvision. Respeta el rango explícito si
   * viene; si no (polling automático con `{}`), usa `[now − max(intervalo×2, 15)min, now]`.
   * El solape es seguro porque la deduplicación es idempotente.
   */
  private resolverVentanaSync(
    intervaloMin: number | null,
    opts: { desde?: Date; hasta?: Date },
  ): { desde?: Date; hasta?: Date } {
    if (opts.desde || opts.hasta) return { desde: opts.desde, hasta: opts.hasta };
    const hasta = new Date();
    const lookbackMin = Math.max((intervaloMin ?? 0) * 2, 15);
    const desde = new Date(hasta.getTime() - lookbackMin * 60_000);
    return { desde, hasta };
  }

  // ═════════════════════════════════════════════════════════════════
  // CONSULTAS
  // ═════════════════════════════════════════════════════════════════

  async listarRaw(empresaId: string, query: QueryMarcacionesDto) {
    const page = query.page ?? 1;
    const limit = query.limit ?? 50;
    const skip = (page - 1) * limit;

    const where: Prisma.rrhh_marcaciones_rawWhereInput = { empresa_id: empresaId };
    if (query.empleado_id) where.empleado_id = query.empleado_id;
    if (query.reloj_id) where.reloj_id = query.reloj_id;
    if (query.batch_importacion_id) where.batch_importacion_id = query.batch_importacion_id;
    if (query.estado) where.estado_importacion = query.estado as any;
    if (query.fuente) where.fuente = query.fuente as any;
    if (query.fecha_desde || query.fecha_hasta) {
      where.timestamp_marcacion = {};
      if (query.fecha_desde) (where.timestamp_marcacion as any).gte = new Date(query.fecha_desde);
      if (query.fecha_hasta) {
        const fin = new Date(query.fecha_hasta);
        fin.setUTCHours(23, 59, 59, 999);
        (where.timestamp_marcacion as any).lte = fin;
      }
    }

    const [data, total] = await this.prisma.$transaction([
      this.prisma.rrhh_marcaciones_raw.findMany({
        where,
        skip,
        take: limit,
        orderBy: [{ timestamp_marcacion: 'desc' }],
        include: {
          empleado: {
            select: {
              id: true,
              numero_empleado: true,
              cedula_identidad: true,
              nombres: true,
              apellidos: true,
            },
          },
          reloj: { select: { id: true, nombre: true } },
        },
      }),
      this.prisma.rrhh_marcaciones_raw.count({ where }),
    ]);

    return { data, total, page, limit };
  }

  async listarLimpias(empresaId: string, query: QueryMarcacionesLimpiasDto) {
    const page = query.page ?? 1;
    const limit = query.limit ?? 50;
    const skip = (page - 1) * limit;

    const where: Prisma.rrhh_marcaciones_limpiasWhereInput = { empresa_id: empresaId };
    if (query.empleado_id) where.empleado_id = query.empleado_id;
    if (typeof query.procesado_novedades === 'boolean') {
      where.procesado_novedades = query.procesado_novedades;
    }
    if (query.fecha_desde || query.fecha_hasta) {
      where.fecha = {};
      if (query.fecha_desde) (where.fecha as any).gte = new Date(query.fecha_desde);
      if (query.fecha_hasta) (where.fecha as any).lte = new Date(query.fecha_hasta);
    }

    const [data, total] = await this.prisma.$transaction([
      this.prisma.rrhh_marcaciones_limpias.findMany({
        where,
        skip,
        take: limit,
        orderBy: [{ fecha: 'desc' }, { empleado_id: 'asc' }],
        include: {
          empleado: {
            select: {
              id: true,
              numero_empleado: true,
              cedula_identidad: true,
              nombres: true,
              apellidos: true,
            },
          },
          turno: { select: { id: true, nombre: true, tipo: true } },
        },
      }),
      this.prisma.rrhh_marcaciones_limpias.count({ where }),
    ]);

    return { data, total, page, limit };
  }

  async ajustarLimpia(
    id: string,
    empresaId: string,
    dto: AjustarMarcacionLimpiaDto,
    userId: string,
  ) {
    if (
      dto.hora_entrada === undefined &&
      dto.hora_salida === undefined &&
      dto.turno_id === undefined
    ) {
      throw new BadRequestException(
        'Debe indicar al menos un campo a corregir (hora_entrada, hora_salida, turno_id)',
      );
    }

    const marcacion = await this.prisma.rrhh_marcaciones_limpias.findFirst({
      where: { id, empresa_id: empresaId },
      include: { turno: { select: { id: true } } },
    });
    if (!marcacion) throw new NotFoundException('Marcación limpia no encontrada');

    let turnoFinal = marcacion.turno_id;
    if (dto.turno_id !== undefined) {
      if (dto.turno_id === null) {
        turnoFinal = null;
      } else {
        const turno = await this.prisma.rrhh_turnos.findFirst({
          where: { id: dto.turno_id, empresa_id: empresaId },
          select: { id: true },
        });
        if (!turno) throw new BadRequestException('turno_id inválido para esta empresa');
        turnoFinal = turno.id;
      }
    }

    const horaEntradaFinal =
      dto.hora_entrada !== undefined
        ? this.parseHoraAjuste(dto.hora_entrada)
        : marcacion.hora_entrada;
    const horaSalidaFinal =
      dto.hora_salida !== undefined
        ? this.parseHoraAjuste(dto.hora_salida)
        : marcacion.hora_salida;

    if (horaEntradaFinal && horaSalidaFinal && horaEntradaFinal >= horaSalidaFinal) {
      throw new BadRequestException('hora_salida debe ser posterior a hora_entrada');
    }

    const cambios: Array<{
      campo_modificado: 'HORA_ENTRADA' | 'HORA_SALIDA' | 'TURNO';
      valor_anterior: string | null;
      valor_nuevo: string;
    }> = [];

    const fmtHora = (value: Date | null) => {
      if (!value) return null;
      const hh = String(value.getUTCHours()).padStart(2, '0');
      const mm = String(value.getUTCMinutes()).padStart(2, '0');
      return `${hh}:${mm}`;
    };

    if (dto.hora_entrada !== undefined && fmtHora(marcacion.hora_entrada) !== fmtHora(horaEntradaFinal)) {
      cambios.push({
        campo_modificado: 'HORA_ENTRADA',
        valor_anterior: fmtHora(marcacion.hora_entrada),
        valor_nuevo: fmtHora(horaEntradaFinal)!,
      });
    }
    if (dto.hora_salida !== undefined && fmtHora(marcacion.hora_salida) !== fmtHora(horaSalidaFinal)) {
      cambios.push({
        campo_modificado: 'HORA_SALIDA',
        valor_anterior: fmtHora(marcacion.hora_salida),
        valor_nuevo: fmtHora(horaSalidaFinal)!,
      });
    }
    if (dto.turno_id !== undefined && marcacion.turno_id !== turnoFinal) {
      cambios.push({
        campo_modificado: 'TURNO',
        valor_anterior: marcacion.turno_id ?? null,
        valor_nuevo: turnoFinal ?? 'NULL',
      });
    }

    if (cambios.length === 0) {
      throw new BadRequestException('No se detectaron cambios respecto al valor actual');
    }

    const updated = await this.prisma.$transaction(async (tx) => {
      const row = await tx.rrhh_marcaciones_limpias.update({
        where: { id },
        data: {
          hora_entrada: dto.hora_entrada !== undefined ? horaEntradaFinal : undefined,
          hora_salida: dto.hora_salida !== undefined ? horaSalidaFinal : undefined,
          turno_id: dto.turno_id !== undefined ? turnoFinal : undefined,
          procesado_novedades: false,
          updated_at: new Date(),
        },
        include: {
          empleado: {
            select: {
              id: true,
              numero_empleado: true,
              cedula_identidad: true,
              nombres: true,
              apellidos: true,
            },
          },
          turno: { select: { id: true, nombre: true, tipo: true } },
        },
      });

      await tx.rrhh_marcaciones_ajustes_manuales.createMany({
        data: cambios.map((c) => ({
          empresa_id: empresaId,
          marcacion_limpia_id: id,
          campo_modificado: c.campo_modificado,
          valor_anterior: c.valor_anterior,
          valor_nuevo: c.valor_nuevo,
          motivo: dto.motivo,
          ajustado_por: userId,
        })),
      });

      return row;
    });

    return {
      data: updated,
      ajustes_creados: cambios.length,
    };
  }

  async listarBatches(empresaId: string, query: QueryBatchesDto) {
    const page = query.page ?? 1;
    const limit = query.limit ?? 20;
    const skip = (page - 1) * limit;

    const where: Prisma.rrhh_marcaciones_rawWhereInput = { empresa_id: empresaId };
    if (query.fuente) where.fuente = query.fuente as any;

    // Postgres-only: agrupamos en raw SQL para devolver el resumen del batch.
    const rows = await this.prisma.$queryRaw<
      Array<{
        batch_importacion_id: string;
        fuente: string;
        importado_en: Date;
        importado_por: string | null;
        reloj_id: string | null;
        total: bigint;
        procesados: bigint;
        duplicados: bigint;
        errores: bigint;
        no_matcheados: bigint;
      }>
    >`
      SELECT
        batch_importacion_id,
        MIN(fuente::text)        AS fuente,
        MIN(importado_en)        AS importado_en,
        MIN(importado_por::text) AS importado_por,
        MIN(reloj_id::text)      AS reloj_id,
        COUNT(*)::bigint                                                    AS total,
        COUNT(*) FILTER (WHERE estado_importacion = 'PROCESADO')::bigint    AS procesados,
        COUNT(*) FILTER (WHERE estado_importacion = 'DUPLICADO')::bigint    AS duplicados,
        COUNT(*) FILTER (WHERE estado_importacion = 'ERROR')::bigint        AS errores,
        COUNT(*) FILTER (WHERE empleado_id IS NULL)::bigint                 AS no_matcheados
      FROM rrhh_marcaciones_raw
      WHERE empresa_id = ${empresaId}::uuid
        ${query.fuente ? Prisma.sql`AND fuente = ${query.fuente}::"rrhh_marcacion_fuente"` : Prisma.empty}
      GROUP BY batch_importacion_id
      ORDER BY importado_en DESC
      LIMIT ${limit} OFFSET ${skip}
    `;

    const totalRow = await this.prisma.$queryRaw<Array<{ total: bigint }>>`
      SELECT COUNT(DISTINCT batch_importacion_id)::bigint AS total
      FROM rrhh_marcaciones_raw
      WHERE empresa_id = ${empresaId}::uuid
        ${query.fuente ? Prisma.sql`AND fuente = ${query.fuente}::"rrhh_marcacion_fuente"` : Prisma.empty}
    `;

    return {
      data: rows.map((r) => ({
        batch_importacion_id: r.batch_importacion_id,
        fuente: r.fuente,
        importado_en: r.importado_en,
        importado_por: r.importado_por,
        reloj_id: r.reloj_id,
        total: Number(r.total),
        procesados: Number(r.procesados),
        duplicados: Number(r.duplicados),
        errores: Number(r.errores),
        no_matcheados: Number(r.no_matcheados),
      })),
      total: Number(totalRow[0]?.total ?? 0),
      page,
      limit,
    };
  }

  async obtenerBatch(empresaId: string, batchId: string) {
    const filas = await this.prisma.rrhh_marcaciones_raw.findMany({
      where: { empresa_id: empresaId, batch_importacion_id: batchId },
      orderBy: [{ timestamp_marcacion: 'asc' }],
      include: {
        empleado: {
          select: {
            id: true,
            numero_empleado: true,
            cedula_identidad: true,
            nombres: true,
            apellidos: true,
          },
        },
        reloj: { select: { id: true, nombre: true } },
      },
    });
    if (filas.length === 0) {
      throw new NotFoundException('Batch no encontrado');
    }
    const resumen = {
      total: filas.length,
      procesados: filas.filter((f) => f.estado_importacion === 'PROCESADO').length,
      duplicados: filas.filter((f) => f.estado_importacion === 'DUPLICADO').length,
      errores: filas.filter((f) => f.estado_importacion === 'ERROR').length,
      no_matcheados: filas.filter((f) => !f.empleado_id).length,
    };
    return {
      batch_importacion_id: batchId,
      importado_en: filas[0].importado_en,
      fuente: filas[0].fuente,
      reloj_id: filas[0].reloj_id,
      resumen,
      filas,
    };
  }

  async listarLogCorrecciones(empresaId: string, query: QueryLogCorreccionesDto) {
    const page = query.page ?? 1;
    const limit = query.limit ?? 50;
    const skip = (page - 1) * limit;

    const where: Prisma.rrhh_marcaciones_log_correccionesWhereInput = { empresa_id: empresaId };
    if (query.empleado_id) where.empleado_id = query.empleado_id;
    if (query.batch_importacion_id) where.batch_importacion_id = query.batch_importacion_id;
    if (query.fecha_desde || query.fecha_hasta) {
      where.fecha = {};
      if (query.fecha_desde) (where.fecha as any).gte = new Date(query.fecha_desde);
      if (query.fecha_hasta) (where.fecha as any).lte = new Date(query.fecha_hasta);
    }

    const [data, total] = await this.prisma.$transaction([
      this.prisma.rrhh_marcaciones_log_correcciones.findMany({
        where,
        skip,
        take: limit,
        orderBy: [{ fecha: 'desc' }, { procesado_en: 'desc' }],
        include: {
          empleado: {
            select: {
              id: true,
              numero_empleado: true,
              cedula_identidad: true,
              nombres: true,
              apellidos: true,
            },
          },
        },
      }),
      this.prisma.rrhh_marcaciones_log_correcciones.count({ where }),
    ]);

    return { data, total, page, limit };
  }

  async obtenerLogCorreccion(empresaId: string, id: string) {
    const log = await this.prisma.rrhh_marcaciones_log_correcciones.findFirst({
      where: { id, empresa_id: empresaId },
      include: {
        empleado: {
          select: {
            id: true,
            numero_empleado: true,
            cedula_identidad: true,
            nombres: true,
            apellidos: true,
          },
        },
      },
    });
    if (!log) throw new NotFoundException('Corrección no encontrada');
    return log;
  }

  // ═════════════════════════════════════════════════════════════════
  // Helpers internos
  // ═════════════════════════════════════════════════════════════════

  private async persistirRaw(
    empresaId: string,
    fuente: 'API' | 'PLANILLA',
    batchId: string,
    raws: RawMarcacionParsed[],
    relojId: string | null,
    userId: string | null,
  ): Promise<{ insertadas: number; no_matcheados: number }> {
    if (raws.length === 0) return { insertadas: 0, no_matcheados: 0 };

    // Mapeo de documentos → empleado_id (filtrado por empresa).
    const documentos = Array.from(new Set(raws.map((r) => r.documento_raw)));
    const empleados = await this.prisma.rrhh_empleados.findMany({
      where: { empresa_id: empresaId, cedula_identidad: { in: documentos } },
      select: { id: true, cedula_identidad: true, estado: true },
    });
    const mapDocEmp = new Map<string, { id: string; estado: string }>();
    for (const e of empleados) mapDocEmp.set(e.cedula_identidad, { id: e.id, estado: e.estado });

    let noMatcheados = 0;
    const rows: Prisma.rrhh_marcaciones_rawUncheckedCreateInput[] = raws.map((r) => {
      const emp = mapDocEmp.get(r.documento_raw);
      let estado: 'PENDIENTE' | 'ERROR' = 'PENDIENTE';
      let errorDescr: string | null = null;
      if (!emp) {
        noMatcheados++;
        estado = 'ERROR';
        errorDescr = `Documento ${r.documento_raw} no encontrado entre los empleados de la empresa`;
      } else if (emp.estado !== 'ACTIVO') {
        // Lo aceptamos pero lo dejamos en ERROR para que el motor de novedades lo ignore.
        estado = 'ERROR';
        errorDescr = `Empleado con CI ${r.documento_raw} no está ACTIVO (estado actual: ${emp.estado})`;
      }
      return {
        empresa_id: empresaId,
        reloj_id: relojId,
        fuente: fuente as rrhh_marcacion_fuente,
        documento_raw: r.documento_raw,
        empleado_id: emp && emp.estado === 'ACTIVO' ? emp.id : null,
        timestamp_marcacion: r.timestamp_marcacion,
        tipo_marcacion: r.tipo_marcacion ?? null,
        estado_importacion: estado,
        error_descripcion: errorDescr,
        importado_por: userId,
        batch_importacion_id: batchId,
      };
    });

    await this.prisma.rrhh_marcaciones_raw.createMany({ data: rows });

    return { insertadas: rows.length, no_matcheados: noMatcheados };
  }

  private limpiarDocumento(s: string): string {
    return String(s ?? '').trim().replace(/[.\-\s]/g, '');
  }

  private parseHoraAjuste(value: string | undefined): Date | null {
    if (value === undefined) return null;
    const [hh, mm, ss = '0'] = String(value).split(':');
    const h = Number(hh);
    const m = Number(mm);
    const s = Number(ss);
    if (
      Number.isNaN(h) ||
      Number.isNaN(m) ||
      Number.isNaN(s) ||
      h < 0 ||
      h > 23 ||
      m < 0 ||
      m > 59 ||
      s < 0 ||
      s > 59
    ) {
      throw new BadRequestException('Hora inválida. Use formato HH:MM');
    }
    const d = new Date('1970-01-01T00:00:00Z');
    d.setUTCHours(h, m, s, 0);
    return d;
  }

  private async validarReloj(
    empresaId: string,
    relojId: string | undefined,
    fuenteEsperada: 'API' | 'PLANILLA',
  ) {
    if (!relojId) return;
    const reloj = await this.prisma.rrhh_relojes_marcadores.findFirst({
      where: { id: relojId, empresa_id: empresaId },
      select: { id: true, tipo_conexion: true, activo: true },
    });
    if (!reloj) throw new BadRequestException('Reloj inválido para esta empresa');
    if (fuenteEsperada === 'PLANILLA' && reloj.tipo_conexion !== 'PLANILLA') {
      throw new BadRequestException(
        'El reloj seleccionado no es de tipo PLANILLA; usá su endpoint de sincronización en su lugar',
      );
    }
  }
}
