<?php
/**
 * Empleado — Modelo de nómina de sueldos (CTNET RRHH)
 * ---------------------------------------------------------------------
 * Trabaja sobre la tabla `nomi_general` de la BD nominasP y orquesta el
 * cálculo completo del sueldo de un empleado para un período (Y-m):
 *
 *   - Sueldo base: calculado por asistencia real (NominaCalculator) o tomado
 *     de la referencia cacheada del período (`nomina_referencias_periodo`).
 *   - Bonos: derivados de las solicitudes de soporte RESUELTAS en las que el
 *     empleado figura como TÉCNICO con derecho a bono (`soporte_tecnico.envios_tecnicos`
 *     con estado 'Aceptada', o asignación directa en `solicitudes.tecnico_id`),
 *     repartidos en partes iguales entre los técnicos de cada solicitud
 *     (`pagos_tipo_solicitud` para el tarifario). Se cachean en
 *     `nomina_referencias_periodo` y se refrescan si cambian.
 *   - Pagos recibidos: suma de vales/adelantos en estado `asignado` del período.
 *   - Descuentos: arrastres de excedente (`vale_excedente`) vigentes del período.
 *   - Saldo neto: total ganado − pagos recibidos − descuentos.
 *
 * Responsabilidades:
 *   - listar()   → empleados según filtros (estatus, area, búsqueda).
 *   - getById()  → una fila de nomi_general.
 *   - calcularSaldo() → desglose completo del período (asegura la referencia).
 *   - calcularBalancePagosAcumulado() → pagos/excedentes acumulados históricos.
 *
 * Esquema esperado de `nomi_general`:
 *   id_empleado, cedula, nombres, apellidos, departamento, area, cargo,
 *   foto, imagen_id, estatus, sueldo
 *
 * Esquema esperado de `nomina_referencias_periodo`:
 *   id_empleado, cedula, periodo, sueldo_base_usd, bonos_base_usd,
 *   bonos_velocidad_usd, bonos_total_usd, sueldo_total_usd, tickets_bonos, origen
 *
 * @package CTNet\RRHH\Nominas\Sueldos
 */

declare(strict_types=1);

require_once __DIR__ . '/NominaCalculator.php';
require_once __DIR__ . '/Vale.php';

class Empleado
{
    /** @var mysqli */
    private $conn;

    /**
     * Conexión a soporte_tecnico (opcional). Es la fuente real de los bonos por
     * solicitudes resueltas: `envios_tecnicos` (quién aceptó), `solicitudes`
     * (categoría de cierre, fecha de resolución) y `pagos_tipo_solicitud`
     * (monto a pagar por categoría).
     *
     * @var mysqli|null
     */
    private $connSoporte;

    /** @var NominaCalculator */
    private $calculator;

    /** @var Vale */
    private $vale;

    public function __construct($conn, $connSoporte = null, $connContabilidad = null)
    {
        $this->conn = $conn;
        $this->connSoporte = ($connSoporte instanceof mysqli) ? $connSoporte : null;
        $this->calculator = new NominaCalculator($conn);
        $this->vale = new Vale($conn, ($connContabilidad instanceof mysqli) ? $connContabilidad : null);
    }

    /**
     * Permite suministrar/actualizar la conexión a soporte_tecnico después de
     * construir el modelo (retrocompatibilidad con `new Empleado($conn)`).
     *
     * @param mysqli|null $connSoporte
     * @return void
     */
    public function establecerConexionSoporte($connSoporte): void
    {
        $this->connSoporte = ($connSoporte instanceof mysqli) ? $connSoporte : null;
    }

    // =========================================================================
    // CONSULTAS BÁSICAS
    // =========================================================================

    /**
     * Lista empleados de `nomi_general` según filtros.
     *
     * Filtros soportados:
     *   - estatus : string (activo|inactivo|...)
     *   - area    : string (admin|tec|acli)
     *   - q       : string (nombre, apellido, cédula, cargo o departamento)
     *
     * @param array $filtros
     * @return array<int,array<string,mixed>>
     */
    public function listar(array $filtros = []): array
    {
        $where = [];
        $params = [];
        $tipos = '';

        $estatus = trim((string) ($filtros['estatus'] ?? ''));
        if ($estatus !== '') {
            $where[] = 'estatus = ?';
            $params[] = $estatus;
            $tipos .= 's';
        }

        $area = strtolower(trim((string) ($filtros['area'] ?? '')));
        if ($area !== '') {
            $where[] = 'area = ?';
            $params[] = $area;
            $tipos .= 's';
        }

        $q = trim((string) ($filtros['q'] ?? ''));
        if ($q !== '') {
            $where[] = '(nombres LIKE ? OR apellidos LIKE ? OR cedula LIKE ? OR cargo LIKE ? OR departamento LIKE ?)';
            $like = '%' . $q . '%';
            for ($i = 0; $i < 5; $i++) {
                $params[] = $like;
                $tipos .= 's';
            }
        }

        $sql = "SELECT id, id_empleado, cedula, nombres, apellidos, departamento, area, cargo,
                       foto, imagen_id, estatus, sueldo, fecha_ingreso
                FROM nomi_general";
        if ($where) {
            $sql .= ' WHERE ' . implode(' AND ', $where);
        }
        $sql .= ' ORDER BY apellidos ASC, nombres ASC';

        $stmt = $this->conn->prepare($sql);
        if (!$stmt) {
            return [];
        }
        if ($params) {
            $stmt->bind_param($tipos, ...$params);
        }
        if (!$stmt->execute()) {
            $stmt->close();
            return [];
        }

        $rows = $stmt->get_result()->fetch_all(MYSQLI_ASSOC);
        $stmt->close();
        return $rows;
    }

    /**
     * Obtiene una fila de `nomi_general` por id_empleado.
     *
     * @param string $idEmpleado
     * @return array<string,mixed>|null
     */
    public function getById($idEmpleado)
    {
        $stmt = $this->conn->prepare(
            "SELECT id, id_empleado, cedula, nombres, apellidos, departamento, area, cargo,
                    telefono, direccion, foto, imagen_id, estatus, sueldo, fecha_ingreso
             FROM nomi_general
             WHERE id_empleado = ? LIMIT 1"
        );
        if (!$stmt) {
            return null;
        }
        $stmt->bind_param('s', $idEmpleado);
        $stmt->execute();
        $row = $stmt->get_result()->fetch_assoc();
        $stmt->close();
        return $row ?: null;
    }

    // =========================================================================
    // CÁLCULO DE SALDO DEL PERÍODO
    // =========================================================================

    /**
     * Calcula el desglose completo del período para un empleado.
     *
     * Asegura que exista la referencia del período en
     * `nomina_referencias_periodo` (sueldo base por asistencia + bonos) y
     * devuelve todas las claves que consumen las vistas (sueldos.php / detalle.php).
     *
     * @param string $idEmpleado
     * @param string $periodo Y-m
     * @return array<string,mixed>
     */
    public function calcularSaldo($idEmpleado, $periodo)
    {
        $periodo = $this->normalizarPeriodo($periodo);
        $idEmpleado = (string) $idEmpleado;

        if ($periodo === '') {
            return $this->saldosVacios($idEmpleado, 'periodo_invalido');
        }

        $empleado = $this->getById($idEmpleado);
        if (!$empleado) {
            return $this->saldosVacios($idEmpleado, 'empleado_no_encontrado');
        }

        $referencia = $this->obtenerOCrearReferencia($empleado, $periodo);

        $asistencia = $this->calcularAsistencia($empleado, $periodo);

        $sueldoNominal = round((float) ($empleado['sueldo'] ?? 0), 2);
        $sueldoBaseRef = round((float) ($referencia['sueldo_base_usd'] ?? 0), 2);

        // El sueldo base por asistencia es la fuente de verdad: sigue
        // acumulándose durante el mes, por lo que un valor cacheado al inicio
        // del período (p. ej. con pocos días trabajados) NO debe prevalecer
        // sobre el cálculo en vivo. Cuando la asistencia aplica, se recalcula
        // y, si difiere del caché, se persiste para mantener consistentes los
        // totales (sueldo_total_usd) de la referencia del período.
        $sueldoUsd = $sueldoBaseRef;
        if ($asistencia['aplica']) {
            $sueldoLive = round((float) $asistencia['sueldo_calculado_usd'], 2);
            if (abs($sueldoLive - $sueldoBaseRef) > 0.00001) {
                $idEmpRef = (string) ($empleado['id_empleado'] ?? '');
                $this->actualizarSueldoBaseReferencia(
                    $idEmpRef,
                    $periodo,
                    $sueldoLive,
                    (float) ($referencia['bonos_base_usd'] ?? 0),
                    (float) ($referencia['bonos_velocidad_usd'] ?? 0),
                    (float) ($referencia['bonos_total_usd'] ?? 0),
                    (int) ($referencia['tickets_bonos'] ?? 0)
                );
                $refrescada = $this->getReferenciaPeriodo($idEmpRef, $periodo);
                if ($refrescada) {
                    $referencia = $refrescada;
                }
            }
            $sueldoUsd = $sueldoLive;
        }
        if ($sueldoUsd <= 0) {
            $sueldoUsd = $sueldoNominal;
        }

        $bonosBase = round((float) ($referencia['bonos_base_usd'] ?? 0), 2);
        $bonosVelocidad = round((float) ($referencia['bonos_velocidad_usd'] ?? 0), 2);
        $bonosTotal = round((float) ($referencia['bonos_total_usd'] ?? ($bonosBase + $bonosVelocidad)), 2);
        $tickets = (int) ($referencia['tickets_bonos'] ?? 0);

        // Desglose auditable de los trabajos que generan los bonos (solicitudes
        // resueltas aceptadas por el técnico en el período).
        $bonosDetalle = $this->obtenerBonosDetalle($idEmpleado, $periodo);
        $bonosTrabajos = $bonosDetalle['trabajos'] ?? [];

        $totalGanado = round((float) ($referencia['sueldo_total_usd'] ?? ($sueldoUsd + $bonosTotal)), 2);
        if ($totalGanado <= 0) {
            $totalGanado = round($sueldoUsd + $bonosTotal, 2);
        }

        $valesPeriodo = $this->vale->getValesPorEmpleado($idEmpleado, $periodo);
        $pagosRecibidos = 0.0;
        $descuentosArrastre = 0.0;
        $huellas = [];

        foreach ($valesPeriodo as $v) {
            $estado = strtolower(trim((string) ($v['estado'] ?? '')));
            $tipo = (string) ($v['tipo_registro'] ?? '');

            if ($estado === 'cancelado') {
                continue;
            }

            if ($tipo === 'vale_excedente') {
                if ($estado !== 'descontado') {
                    $montoUsd = (float) ($v['monto_usd'] ?? 0);
                    $pendiente = (float) ($v['total_pendiente'] ?? 0);
                    $descuentosArrastre += (abs($montoUsd - $pendiente) < 0.00001) ? $montoUsd : $pendiente;
                }
                continue;
            }

            if ($estado !== 'asignado' && $estado !== 'descontado') {
                continue;
            }
            $huella = md5(
                (string) ($v['fecha_vale'] ?? '') . '|' .
                number_format((float) ($v['monto_usd'] ?? 0), 2, '.', '') . '|' .
                strtolower(trim((string) ($v['descripcion'] ?? ''))) . '|' .
                strtolower(trim((string) ($v['banco'] ?? '')))
            );
            if (isset($huellas[$huella])) {
                continue;
            }
            $huellas[$huella] = true;
            $montoADescontar = (float) ($v['monto_a_descontar'] ?? 0);
            $montoUsd = (float) ($v['monto_usd'] ?? 0);
            $montoComputable = ($montoADescontar > 0) ? $montoADescontar : $montoUsd;
            $pagosRecibidos += $montoComputable;
        }

        $pagosRecibidos = round($pagosRecibidos, 2);
        $descuentosArrastre = round($descuentosArrastre, 2);
        $totalDescuentos = $descuentosArrastre;
        $saldoNeto = round($totalGanado - $pagosRecibidos - $totalDescuentos, 2);

        $guardias = $this->calcularGuardias($empleado, $periodo, $asistencia['valor_hora_usd'], $asistencia['horas_jornada']);

        if ($guardias['penalizacion_usd'] > 0) {
            $totalGanado = round($totalGanado - $guardias['penalizacion_usd'], 2);
            $saldoNeto = round($saldoNeto - $guardias['penalizacion_usd'], 2);
        }

        return [
            'id_empleado' => $idEmpleado,
            'cedula' => (string) ($empleado['cedula'] ?? ''),
            'nombres' => (string) ($empleado['nombres'] ?? ''),
            'apellidos' => (string) ($empleado['apellidos'] ?? ''),
            'area' => (string) ($empleado['area'] ?? ''),
            'departamento' => (string) ($empleado['departamento'] ?? ''),
            'cargo' => (string) ($empleado['cargo'] ?? ''),

            'sueldo_usd' => $sueldoUsd,
            'sueldo_nominal_usd' => $sueldoNominal,
            'bonos_ganancias_usd' => $bonosBase,
            'bonos_velocidad_usd' => $bonosVelocidad,
            'total_bonos_usd' => $bonosTotal,
            'tickets_bonos' => $tickets,
            'bonos_detalle' => $bonosTrabajos,
            'total_ganado_usd' => $totalGanado,

            'pagos_recibidos_usd' => $pagosRecibidos,
            'descuentos_arrastre_usd' => $descuentosArrastre,
            'total_descuentos_usd' => $totalDescuentos,
            'saldo_usd' => $saldoNeto,

            'asistencia_aplica' => $asistencia['aplica'],
            'asistencia_tiene_marcas' => $asistencia['tiene_marcas'],
            'asistencia_motivo' => $asistencia['motivo'],
            'asistencia_sueldo_nominal_usd' => $sueldoNominal,
            'asistencia_sueldo_calculado_usd' => $asistencia['sueldo_calculado_usd'],
            'asistencia_valor_hora_usd' => $asistencia['valor_hora_usd'],
            'asistencia_horas_planificadas' => $asistencia['horas_planificadas'],
            'asistencia_horas_lunes_viernes' => $asistencia['horas_lunes_viernes'],
            'asistencia_horas_fin_semana' => $asistencia['horas_fin_semana'],
            'asistencia_horas_extension' => $asistencia['horas_extension'],
            'asistencia_horas_pagables' => $asistencia['horas_pagables'],

            'guardias_asignadas' => $guardias['asignadas'],
            'guardias_cumplidas' => $guardias['cumplidas'],
            'guardias_no_cumplidas' => $guardias['no_cumplidas'],
            'penalizacion_guardia_horas' => $guardias['penalizacion_horas'],
            'penalizacion_guardia_usd' => $guardias['penalizacion_usd'],
            'guardias_detalle' => $guardias['detalle'],

            'dias_pagados_detalle' => $asistencia['dias_pagados_detalle'],

            'periodos_internos' => $this->calcularDesglosePeriodosInternos($idEmpleado, $periodo),

            'origen_referencia' => (string) ($referencia['origen'] ?? 'auto'),
        ];
    }

    /**
     * Devuelve el horario laboral aplicable al empleado según su área
     * funcional, tomado de `asistencia_horarios_area` (módulo de asistencia):
     * hora de entrada, hora de salida, tolerancias y días laborables.
     *
     * Toda la nómina (mensual, semanal, quincenal y diaria) se calcula con esta
     * configuración.
     *
     * @param string $idEmpleado
     * @return array<string,mixed>
     */
    public function obtenerHorarioAplicado($idEmpleado): array
    {
        $empleado = $this->getById($idEmpleado);
        if (!$empleado) {
            return [];
        }
        $horario = $this->calculator->obtenerHorarioEmpleado($empleado);
        $horario['horas_jornada'] = round(
            $this->calculator->horasJornadaDiaria($horario['hora_entrada'], $horario['hora_salida']),
            4
        );
        return $horario;
    }

    /**
     * Calcula el balance acumulado de pagos/excedentes de un empleado hasta
     * el período indicado (inclusive), histórico de períodos anteriores.
     *
     * @param string $idEmpleado
     * @param string $cedula
     * @param string $periodo
     * @return array<string,float>
     */
    public function calcularBalancePagosAcumulado($idEmpleado, $cedula = '', $periodo = null): array
    {
        $periodo = $this->normalizarPeriodo($periodo ?: date('Y-m'));

        $pagosAcumulados = 0.0;
        $excedentePendienteDescontar = 0.0;
        $excedenteArrastrado = 0.0;

        $stmt = $this->conn->prepare(
            "SELECT tipo_registro, estado, monto_usd, monto_a_descontar, total_pendiente, descripcion
             FROM empleado_vales
             WHERE id_empleado = ? AND periodo <= ? AND estado != 'cancelado'"
        );
        if (!$stmt) {
            $stmt = $this->conn->prepare(
                "SELECT tipo_registro, estado, monto_usd, monto_a_descontar, total_pendiente, descripcion
                 FROM empleado_vales
                 WHERE cedula = ? AND periodo <= ? AND estado != 'cancelado'"
            );
            if (!$stmt) {
                return [
                    'pagos_recibidos_usd' => 0.0,
                    'excedente_pendiente_descontar_usd' => 0.0,
                    'excedente_arrastrado_usd' => 0.0,
                ];
            }
            $stmt->bind_param('ss', $cedula, $periodo);
        } else {
            $stmt->bind_param('ss', $idEmpleado, $periodo);
        }

        if ($stmt->execute()) {
            $rows = $stmt->get_result()->fetch_all(MYSQLI_ASSOC);
            foreach ($rows as $r) {
                $tipo = (string) ($r['tipo_registro'] ?? '');
                $estado = strtolower(trim((string) ($r['estado'] ?? '')));

                if ($tipo === 'vale_excedente') {
                    $pendiente = (float) ($r['total_pendiente'] ?? 0);
                    $montoUsd = (float) ($r['monto_usd'] ?? 0);
                    $arrastrado = (abs($montoUsd - $pendiente) < 0.00001) ? $montoUsd : $pendiente;

                    if ($estado === 'descontado') {
                        $excedenteArrastrado += $arrastrado;
                    } else {
                        $excedentePendienteDescontar += $pendiente > 0 ? $pendiente : $arrastrado;
                    }
                    continue;
                }

                if ($estado === 'asignado') {
                    $pagosAcumulados += (float) ($r['monto_usd'] ?? 0);
                } elseif ($estado === 'descontado') {
                    $pagosAcumulados += (float) ($r['monto_a_descontar'] ?? $r['monto_usd'] ?? 0);
                }
            }
        }
        $stmt->close();

        return [
            'pagos_recibidos_usd' => round($pagosAcumulados, 2),
            'excedente_pendiente_descontar_usd' => round($excedentePendienteDescontar, 2),
            'excedente_arrastrado_usd' => round($excedenteArrastrado, 2),
        ];
    }

    // =========================================================================
    // DESGLOSE INTERNO DEL PERÍODO (semanas y quincenas)
    // =========================================================================

    /**
     * Calcula los valores semanales y quincenales contenidos en un período
     * mensual (Y-m), usando el mismo motor (NominaCalculator) sobre la
     * asistencia real de cada sub-rango.
     *
     * Devuelve la lista de "unidades internas" del período:
     *   - tipo: 'semanal' | 'quincenal'
     *   - rango: inicio / fin (Y-m-d)
     *   - valores: sueldo base, horas trabajadas/extra, neto, días.
     *   - totales_periodo: suma de los netos de las unidades (referencia).
     *
     * @param string $idEmpleado
     * @param string $periodo Y-m
     * @return array<string,mixed>
     */
    public function calcularDesglosePeriodosInternos($idEmpleado, $periodo): array
    {
        $periodo = $this->normalizarPeriodo($periodo);
        $idEmpleado = (string) $idEmpleado;

        $vacio = [
            'periodo' => $periodo,
            'unidades' => [],
            'semanas' => [],
            'quincenas' => [],
            'totales' => [
                'sueldo_base_usd' => 0.0,
                'bonos_usd' => 0.0,
                'horas_trabajadas' => 0.0,
                'horas_extra' => 0.0,
                'dias_trabajados' => 0,
                'dias_no_trabajados' => 0,
                'neto_usd' => 0.0,
            ],
        ];

        if ($periodo === '') {
            return $vacio;
        }

        $empleado = $this->getById($idEmpleado);
        if (!$empleado) {
            return $vacio;
        }

        $primeroMes = $periodo . '-01';
        $ultimoMes = date('Y-m-t', strtotime($primeroMes));

        // Contexto del mes padre: valor hora y días laborables del mes completo,
        // para que TODAS las sub-unidades (semanas/quincenas) usen el mismo
        // valor hora del período seleccionado (consistencia de la vista).
        $contextoMes = $this->contextoValorHoraMes($empleado, $primeroMes);

        $semanas = $this->construirUnidadesSemanales($empleado, $primeroMes, $ultimoMes, $contextoMes);
        $quincenas = $this->construirUnidadesQuincenales($empleado, $periodo, $contextoMes);

        $unidades = array_merge($semanas, $quincenas);

        $totales = [
            'sueldo_base_usd' => 0.0,
            'bonos_usd' => 0.0,
            'horas_trabajadas' => 0.0,
            'horas_extra' => 0.0,
            'dias_trabajados' => 0,
            'dias_no_trabajados' => 0,
            'neto_usd' => 0.0,
        ];
        // Los totales se calculan sobre las SEMANAS (cubren todo el mes sin
        // solapamiento); las quincenas son otra vista del mismo contenido.
        foreach ($semanas as $u) {
            $totales['sueldo_base_usd'] += (float) $u['sueldo_base_usd'];
            $totales['bonos_usd'] += (float) $u['bonos_usd'];
            $totales['horas_trabajadas'] += (float) $u['horas_trabajadas'];
            $totales['horas_extra'] += (float) $u['horas_extra'];
            $totales['dias_trabajados'] += (int) $u['dias_trabajados'];
            $totales['dias_no_trabajados'] += (int) $u['dias_no_trabajados'];
            $totales['neto_usd'] += (float) $u['neto_usd'];
        }
        foreach ($totales as $k => $v) {
            $totales[$k] = is_float($v) ? round($v, 2) : $v;
        }

        return [
            'periodo' => $periodo,
            'unidades' => $unidades,
            'semanas' => $semanas,
            'quincenas' => $quincenas,
            'totales' => $totales,
        ];
    }

    /**
     * Calcula el contexto del mes padre: valor hora y días laborables del mes
     * según el horario del área. Se usa como referencia común para todas las
     * semanas y quincenas, de modo que compartan el mismo valor hora.
     *
     * @return array{valor_hora_usd:float, dias_laborables_mes:int, sueldo_nominal_usd:float, horas_jornada:float}
     */
    private function contextoValorHoraMes(array $empleado, string $primeroMes): array
    {
        $sueldoNominal = round((float) ($empleado['sueldo'] ?? 0), 2);

        $contexto = [
            'valor_hora_usd' => 0.0,
            'dias_laborables_mes' => 0,
            'sueldo_nominal_usd' => $sueldoNominal,
            'horas_jornada' => 0.0,
        ];

        try {
            $calculo = $this->calculator->calcular(
                $empleado,
                NominaCalculator::PERIODO_MENSUAL,
                $primeroMes,
                [
                    'descuento_falta' => false,
                    'descuento_retardo' => false,
                    'descuento_salida_anticipada' => false,
                ]
            );
        } catch (Throwable $e) {
            return $contexto;
        }

        $res = $calculo['resultado'] ?? [];
        // Precisión completa (no se redondea) para no arrastrar error al calcular
        // los montos de cada sub-unidad. El redondeo a centavos se hace al final.
        $contexto['valor_hora_usd'] = (float) ($res['valor_hora'] ?? 0);
        $contexto['dias_laborables_mes'] = (int) ($res['dias_laborables_mes'] ?? 0);
        // Horas de jornada del área: se usan para reconocer el sueldo por horas
        // cuando no hay días laborables configurados.
        $contexto['horas_jornada'] = (float) ($res['horas_jornada_diaria'] ?? 0);

        return $contexto;
    }

    /**
     * Construye las unidades semanales (lunes-domingo) que intersecan el mes.
     *
     * @return array<int,array<string,mixed>>
     */
    private function construirUnidadesSemanales(array $empleado, string $primeroMes, string $ultimoMes, array $contextoMes): array
    {
        $unidades = [];

        $cursor = new DateTime($primeroMes);
        // Retroceder al lunes de la semana del primer día del mes.
        $cursor->modify('monday this week');

        $finMes = new DateTime($ultimoMes);

        while ($cursor <= $finMes) {
            $inicio = clone $cursor;
            $fin = clone $cursor;
            $fin->modify('+6 days');

            $unidades[] = $this->construirUnidad(
                $empleado,
                'semanal',
                $inicio->format('Y-m-d'),
                $fin->format('Y-m-d'),
                $contextoMes
            );

            $cursor->modify('+7 days');
        }

        return $unidades;
    }

    /**
     * Construye las unidades quincenales del mes (1-15 y 16-fin).
     *
     * @return array<int,array<string,mixed>>
     */
    private function construirUnidadesQuincenales(array $empleado, string $periodo, array $contextoMes): array
    {
        $unidades = [];

        $q1Inicio = $periodo . '-01';
        $q1Fin = $periodo . '-15';
        $q2Inicio = $periodo . '-16';
        $q2Fin = date('Y-m-t', strtotime($periodo . '-01'));

        $unidades[] = $this->construirUnidad($empleado, 'quincenal', $q1Inicio, $q1Fin, $contextoMes);
        $unidades[] = $this->construirUnidad($empleado, 'quincenal', $q2Inicio, $q2Fin, $contextoMes);

        return $unidades;
    }

    /**
     * Calcula una unidad (semana o quincena) del período. El valor hora es el
     * del mes padre (contexto) y el sueldo base se prorratea por los días
     * trabajados de la unidad sobre los días laborables del mes. Los bonos del
     * período se prorratean por días de la unidad sobre los días del mes.
     *
     * @return array<string,mixed>
     */
    private function construirUnidad(array $empleado, string $tipo, string $inicio, string $fin, array $contextoMes): array
    {
        $idEmpleado = (string) ($empleado['id_empleado'] ?? '');
        $sueldoNominal = round((float) ($empleado['sueldo'] ?? 0), 2);
        $valorHora = (float) ($contextoMes['valor_hora_usd'] ?? 0);
        $diasLaborablesMes = (int) ($contextoMes['dias_laborables_mes'] ?? 0);

        $base = [
            'tipo' => $tipo,
            'inicio' => $inicio,
            'fin' => $fin,
            'dias_rango' => $this->contarDiasInclusive($inicio, $fin),
            'dias_trabajados' => 0,
            'dias_no_trabajados' => 0,
            'horas_trabajadas' => 0.0,
            'horas_extra' => 0.0,
            'valor_hora_usd' => round($valorHora, NominaCalculator::DECIMALES_VALOR_UNITARIO),
            'sueldo_base_usd' => 0.0,
            'bonos_usd' => 0.0,
            'neto_usd' => 0.0,
        ];

        // Días y horas reales dentro del rango exacto solicitado, reutilizando
        // el detalle diario del motor para el rango que lo cubra.
        $horas = $this->horasYRango($idEmpleado, $inicio, $fin);

        // NORMA DE PRECISIÓN: se calcula con valores a precisión completa y se
        // redondea a centavos SÓLO el monto resultante.
        $sueldoBaseUnidad = 0.0;
        if ($diasLaborablesMes > 0 && $sueldoNominal > 0) {
            // Sueldo base proporcional a los días trabajados de la unidad
            // sobre los días laborables del mes padre.
            $sueldoPorDia = $sueldoNominal / $diasLaborablesMes;
            $sueldoBaseUnidad = $this->calculator->redondearMoneda($horas['dias_trabajados'] * $sueldoPorDia);
        } elseif ($valorHora > 0) {
            // Sin días laborables configurados: se usa el reconocido por horas.
            $sueldoBaseUnidad = $this->calculator->redondearMoneda($horas['horas_trabajadas'] * $valorHora);
        }

        // Bonos del período, prorrateados por días de la unidad sobre los días del mes.
        $bonosUnidad = $this->prorratearBonosPeriodo($idEmpleado, substr($inicio, 0, 7), $inicio, $fin);

        $netoUnidad = $this->calculator->redondearMoneda($sueldoBaseUnidad + $bonosUnidad);

        return array_merge($base, [
            'dias_rango' => $horas['dias_rango'],
            'dias_trabajados' => $horas['dias_trabajados'],
            'dias_no_trabajados' => $horas['dias_no_trabajados'],
            'horas_trabajadas' => $horas['horas_trabajadas'],
            'horas_extra' => $horas['horas_extra'],
            'valor_hora_usd' => round($valorHora, NominaCalculator::DECIMALES_VALOR_UNITARIO),
            'sueldo_base_usd' => $sueldoBaseUnidad,
            'bonos_usd' => $bonosUnidad,
            'neto_usd' => $netoUnidad,
        ]);
    }

    /**
     * Cuenta días y horas reales trabajadas dentro de un rango exacto,
     * consultando el motor de cálculo (NominaCalculator) para ese rango.
     *
     * @return array<string,mixed>
     */
    private function horasYRango(string $idEmpleado, string $inicio, string $fin): array
    {
        $base = [
            'dias_rango' => $this->contarDiasInclusive($inicio, $fin),
            'dias_trabajados' => 0,
            'dias_no_trabajados' => 0,
            'horas_trabajadas' => 0.0,
            'horas_extra' => 0.0,
        ];

        $empleado = $this->getById($idEmpleado);
        if (!$empleado) {
            return $base;
        }

        $diasTrab = 0;
        $diasNoTrab = 0;
        $horasTrab = 0.0;
        $horasExtra = 0.0;
        $diasRango = $this->contarDiasInclusive($inicio, $fin);

        // El rango puede cruzar dos meses (p. ej. una semana que empieza el
        // 2026-08-31 y termina el 2026-09-06). Se combinan los detalles diarios
        // de cada mes que el rango toca para no dejar días en cero por anclar a
        // un único mes (el de inicio).
        $ini = new DateTime($inicio);
        $finDt = new DateTime($fin);
        $mesesDelRango = array_unique([
            $ini->format('Y-m'),
            $finDt->format('Y-m'),
        ]);

        $detalleDias = [];
        foreach ($mesesDelRango as $mes) {
            try {
                $calculoMes = $this->calculator->calcular(
                    $empleado,
                    NominaCalculator::PERIODO_MENSUAL,
                    $mes . '-01',
                    [
                        'descuento_falta' => false,
                        'descuento_retardo' => false,
                        'descuento_salida_anticipada' => false,
                    ]
                );
            } catch (Throwable $e) {
                continue;
            }
            foreach (($calculoMes['detalle_dias'] ?? []) as $d) {
                $detalleDias[] = $d;
            }
        }

        if (empty($detalleDias)) {
            return array_merge($base, ['dias_rango' => $diasRango]);
        }

        foreach ($detalleDias as $d) {
            $fecha = (string) ($d['fecha'] ?? '');
            if ($fecha === '') {
                continue;
            }
            $f = new DateTime($fecha);
            if ($f < $ini || $f > $finDt) {
                continue;
            }
            if (!empty($d['laborable'])) {
                if (($d['estado'] ?? '') === 'trabajado' || ($d['estado'] ?? '') === 'retardo') {
                    $diasTrab++;
                } elseif (($d['estado'] ?? '') === 'falta') {
                    $diasNoTrab++;
                }
            }
            $horasTrab += (float) ($d['horas_trabajadas'] ?? 0);
            $horasExtra += (float) ($d['horas_extra'] ?? 0);
        }

        return [
            'dias_rango' => $diasRango,
            'dias_trabajados' => $diasTrab,
            'dias_no_trabajados' => $diasNoTrab,
            'horas_trabajadas' => round($horasTrab, 2),
            'horas_extra' => round($horasExtra, 2),
        ];
    }

    /**
     * Prorratea los bonos cacheados del período (referencia) según los días
     * de la unidad sobre los días totales del mes.
     */
    private function prorratearBonosPeriodo(string $idEmpleado, string $periodo, string $inicio, string $fin): float
    {
        if ($periodo === '') {
            return 0.0;
        }
        $referencia = $this->getReferenciaPeriodo($idEmpleado, $periodo);
        if (!$referencia) {
            return 0.0;
        }

        $bonosTotal = round((float) ($referencia['bonos_total_usd'] ?? 0), 2);
        if ($bonosTotal <= 0) {
            return 0.0;
        }

        $diasMes = (int) date('t', strtotime($periodo . '-01'));
        $diasUnidad = $this->contarDiasInclusive($inicio, $fin);
        if ($diasMes <= 0) {
            return 0.0;
        }

        return round($bonosTotal * ($diasUnidad / $diasMes), 2);
    }

    /**
     * Cuenta los días entre dos fechas (inclusive).
     */
    private function contarDiasInclusive(string $inicio, string $fin): int
    {
        try {
            $a = new DateTime($inicio);
            $b = new DateTime($fin);
            return (int) $a->diff($b)->days + 1;
        } catch (Throwable $e) {
            return 0;
        }
    }

    // =========================================================================
    // REFERENCIA DEL PERÍODO (sueldo base + bonos cacheados)
    // =========================================================================

    /**
     * Obtiene la referencia del período; si no existe, la crea calculando el
     * sueldo base por asistencia y los bonos (0 salvo que exista una fuente
     * de billetera de tickets).
     *
     * @return array<string,mixed>
     */
    private function obtenerOCrearReferencia(array $empleado, string $periodo): array
    {
        $idEmpleado = (string) ($empleado['id_empleado'] ?? '');
        $referencia = $this->getReferenciaPeriodo($idEmpleado, $periodo);
        if ($referencia) {
            // Los bonos se derivan de las solicitudes resueltas y pueden cambiar
            // a medida que se cierran tickets del período. Si la referencia
            // cacheada difiere del cálculo actual, se refresca.
            $bonosFuente = $this->obtenerBonosTickets($idEmpleado, $periodo);
            if ($bonosFuente !== null) {
                $bonosBase = (float) ($bonosFuente['bonos_base_usd'] ?? 0);
                $bonosVelocidad = (float) ($bonosFuente['bonos_velocidad_usd'] ?? 0);
                $tickets = (int) ($bonosFuente['tickets_bonos'] ?? 0);
                $bonosTotal = round($bonosBase + $bonosVelocidad, 2);

                $bonosCache = round((float) ($referencia['bonos_total_usd'] ?? 0), 2);
                $ticketsCache = (int) ($referencia['tickets_bonos'] ?? 0);

                if (abs($bonosTotal - $bonosCache) > 0.00001 || $tickets !== $ticketsCache) {
                    $sueldoBase = round((float) ($referencia['sueldo_base_usd'] ?? 0), 2);
                    $this->actualizarBonosReferencia(
                        $idEmpleado,
                        $periodo,
                        $sueldoBase,
                        $bonosBase,
                        $bonosVelocidad,
                        $bonosTotal,
                        $tickets
                    );
                    $refrescada = $this->getReferenciaPeriodo($idEmpleado, $periodo);
                    if ($refrescada) {
                        return $refrescada;
                    }
                }
            }
            return $referencia;
        }

        $asistencia = $this->calcularAsistencia($empleado, $periodo);
        $sueldoNominal = round((float) ($empleado['sueldo'] ?? 0), 2);

        $sueldoBase = $asistencia['aplica'] ? round($asistencia['sueldo_calculado_usd'], 2) : $sueldoNominal;
        if ($sueldoBase <= 0) {
            $sueldoBase = $sueldoNominal;
        }

        $bonosBase = 0.0;
        $bonosVelocidad = 0.0;
        $tickets = 0;

        $bonosFuente = $this->obtenerBonosTickets($idEmpleado, $periodo);
        if ($bonosFuente !== null) {
            $bonosBase = (float) ($bonosFuente['bonos_base_usd'] ?? 0);
            $bonosVelocidad = (float) ($bonosFuente['bonos_velocidad_usd'] ?? 0);
            $tickets = (int) ($bonosFuente['tickets_bonos'] ?? 0);
        }

        $bonosTotal = round($bonosBase + $bonosVelocidad, 2);
        $sueldoTotal = round($sueldoBase + $bonosTotal, 2);

        $data = [
            'id_empleado' => $idEmpleado,
            'cedula' => (string) ($empleado['cedula'] ?? ''),
            'periodo' => $periodo,
            'sueldo_base_usd' => $sueldoBase,
            'bonos_base_usd' => $bonosBase,
            'bonos_velocidad_usd' => $bonosVelocidad,
            'bonos_total_usd' => $bonosTotal,
            'sueldo_total_usd' => $sueldoTotal,
            'tickets_bonos' => $tickets,
            'origen' => 'auto',
        ];

        $this->insertarReferencia($data);

        $persistida = $this->getReferenciaPeriodo($idEmpleado, $periodo);
        return $persistida ?: $data;
    }

    /**
     * Actualiza los bonos de la referencia cacheada del período cuando el
     * cálculo derivado de solicitudes resueltas cambia (p. ej. se cierran
     * nuevos tickets del mes). Mantiene el sueldo base y recalcula el total.
     *
     * @return void
     */
    private function actualizarBonosReferencia(
        string $idEmpleado,
        string $periodo,
        float $sueldoBase,
        float $bonosBase,
        float $bonosVelocidad,
        float $bonosTotal,
        int $tickets
    ): void {
        $sueldoTotal = round($sueldoBase + $bonosTotal, 2);

        $sql = "UPDATE nomina_referencias_periodo
                   SET bonos_base_usd = ?,
                       bonos_velocidad_usd = ?,
                       bonos_total_usd = ?,
                       sueldo_total_usd = ?,
                       tickets_bonos = ?,
                       origen = ?
                 WHERE id_empleado = ? AND periodo = ?";
        $stmt = $this->conn->prepare($sql);
        if (!$stmt) {
            return;
        }
        $origen = 'auto';
        // tipos: d d d d i s s s
        $tipos = 'ddddisss';
        $stmt->bind_param(
            $tipos,
            $bonosBase,
            $bonosVelocidad,
            $bonosTotal,
            $sueldoTotal,
            $tickets,
            $origen,
            $idEmpleado,
            $periodo
        );
        $stmt->execute();
        $stmt->close();
    }

    /**
     * Actualiza el sueldo base de la referencia cacheada del período cuando la
     * asistencia real acumulada difiere del valor guardado (p. ej. la
     * referencia se creó al inicio del mes con pocos días trabajados).
     * Recalcula `sueldo_total_usd` con los bonos vigentes del caché.
     *
     * @return void
     */
    private function actualizarSueldoBaseReferencia(
        string $idEmpleado,
        string $periodo,
        float $sueldoBase,
        float $bonosBase,
        float $bonosVelocidad,
        float $bonosTotal,
        int $tickets
    ): void {
        $sueldoTotal = round($sueldoBase + $bonosTotal, 2);

        $sql = "UPDATE nomina_referencias_periodo
                   SET sueldo_base_usd = ?,
                       bonos_base_usd = ?,
                       bonos_velocidad_usd = ?,
                       bonos_total_usd = ?,
                       sueldo_total_usd = ?,
                       tickets_bonos = ?,
                       origen = ?
                 WHERE id_empleado = ? AND periodo = ?";
        $stmt = $this->conn->prepare($sql);
        if (!$stmt) {
            return;
        }
        $origen = 'auto';
        // tipos: d d d d d i s s s
        $tipos = 'ddddd' . 'isss';
        $stmt->bind_param(
            $tipos,
            $sueldoBase,
            $bonosBase,
            $bonosVelocidad,
            $bonosTotal,
            $sueldoTotal,
            $tickets,
            $origen,
            $idEmpleado,
            $periodo
        );
        $stmt->execute();
        $stmt->close();
    }

    /**
     * Busca la referencia cacheada del período.
     */
    private function getReferenciaPeriodo(string $idEmpleado, string $periodo)
    {
        $stmt = $this->conn->prepare(
            "SELECT * FROM nomina_referencias_periodo
             WHERE id_empleado = ? AND periodo = ?
             ORDER BY id DESC LIMIT 1"
        );
        if (!$stmt) {
            return null;
        }
        $stmt->bind_param('ss', $idEmpleado, $periodo);
        $stmt->execute();
        $row = $stmt->get_result()->fetch_assoc();
        $stmt->close();
        return $row ?: null;
    }

    /**
     * Inserta una nueva referencia del período (esquema fijo conocido).
     */
    private function insertarReferencia(array $data): bool
    {
        $sql = "INSERT INTO nomina_referencias_periodo
                    (id_empleado, cedula, periodo, sueldo_base_usd, bonos_base_usd,
                     bonos_velocidad_usd, bonos_total_usd, sueldo_total_usd, tickets_bonos, origen)
                VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
        $stmt = $this->conn->prepare($sql);
        if (!$stmt) {
            return false;
        }
        // tipos: s s s d d d d d i s
        $tipos = 'sssdddddis';
        $stmt->bind_param(
            $tipos,
            $data['id_empleado'],
            $data['cedula'],
            $data['periodo'],
            $data['sueldo_base_usd'],
            $data['bonos_base_usd'],
            $data['bonos_velocidad_usd'],
            $data['bonos_total_usd'],
            $data['sueldo_total_usd'],
            $data['tickets_bonos'],
            $data['origen']
        );
        $ok = $stmt->execute();
        $stmt->close();
        return $ok;
    }

    /**
     * Fuente de bonos por tickets del período.
     *
     * El pago se deriva de las solicitudes de soporte RESUELTAS:
     *   - Un técnico tiene derecho a bono si aparece en `envios_tecnicos` con
     *     `estado_aceptacion = 'Aceptada'` o si la solicitud está asignada
     *     directamente a él (`solicitudes.tecnico_id`). Los estados 'Pendiente'
     *     y 'No Disponible' no generan bono.
     *   - Solo participa personal TÉCNICO (sufijo `-tec`); admin/acli quedan
     *     fuera del reparto.
     *   - El monto por trabajo usa el tarifario por categoría del catálogo
     *     (`pagos_tipo_solicitud.monto`, unido por `categoria_cierre`). El monto
     *     de `solicitudes.honorarios_monto` corresponde a lo facturado al cliente.
     *   - El monto se reparte en partes iguales entre los técnicos con derecho
     *     a bono de la misma solicitud (si solo uno, cobra el 100%).
     *
     * @return array<string,mixed>|null  null si soporte_tecnico no está disponible.
     */
    private function obtenerBonosTickets(string $idEmpleado, string $periodo)
    {
        $detalle = $this->obtenerBonosDetalle($idEmpleado, $periodo);
        if ($detalle === null) {
            return null;
        }

        return [
            'bonos_base_usd' => $detalle['bonos_total_usd'],
            'bonos_velocidad_usd' => 0.0,
            'tickets_bonos' => $detalle['tickets'],
        ];
    }

    /**
     * Calcula el detalle de bonos por solicitudes resueltas del empleado en el
     * período (Y-m). Devuelve null si no hay conexión a soporte_tecnico.
     *
     * REGLAS DE REPARTO (confirmadas con el negocio):
     *   - Solo cobra personal TÉCNICO. Se consideran técnicos los
     *     identificadores con sufijo `-tec` (p. ej. CTNET-162639-tec). Los
     *     sufijos `-admin` / `-acli` (y cualquier otro) NO entran al reparto ni
     *     cobran.
     *   - Un técnico cobra una solicitud resuelta si cumple al menos una:
     *       a) Aceptación explícita: `envios_tecnicos.estado_aceptacion =
     *          'Aceptada'` para esa solicitud.
     *       c) Asignación directa: `solicitudes.tecnico_id` apunta a él como
     *          técnico concreto (distinto de 'multi'/'todos'/'soporte_remoto').
     *     Los estados 'Pendiente' y 'No Disponible' NO dan derecho a cobro.
     *   - El monto del trabajo se reparte en PARTES IGUALES entre los técnicos
     *     que cobran esa solicitud. Si sólo un técnico aceptó (y el resto marcó
     *     'No Disponible'), ese técnico cobra el 100%.
     *   - El monto por trabajo usa el tarifario por categoría del catálogo
     *     (`pagos_tipo_solicitud.monto`, unido por `categoria_cierre`). El monto
     *     de `solicitudes.honorarios_monto` corresponde a lo facturado al cliente.
     *
     * Claves:
     *   - bonos_total_usd : monto total a pagar por los trabajos que cobra.
     *   - tickets         : cantidad de solicitudes resueltas contabilizadas.
     *   - trabajos        : desglose por solicitud (para auditoría en la vista).
     *
     * @return array<string,mixed>|null
     */
    public function obtenerBonosDetalle(string $idEmpleado, string $periodo): ?array
    {
        $idEmpleado = trim($idEmpleado);
        if ($this->connSoporte === null || $idEmpleado === '') {
            return null;
        }
        if (!preg_match('/^\d{4}-\d{2}$/', $periodo)) {
            return null;
        }

        // Variantes del identificador del técnico: en `envios_tecnicos.tecnico_id`
        // se guarda el id_empleado (p. ej. CTNET-162639-tec), pero admitimos
        // también la cédula para cubrir registros históricos.
        $variantes = [$idEmpleado];

        $stmtEmp = $this->conn->prepare("SELECT cedula FROM nomi_general WHERE id_empleado = ? LIMIT 1");
        if ($stmtEmp) {
            $stmtEmp->bind_param('s', $idEmpleado);
            if ($stmtEmp->execute()) {
                $rowEmp = $stmtEmp->get_result()->fetch_assoc();
                $cedula = trim((string) ($rowEmp['cedula'] ?? ''));
                if ($cedula !== '') {
                    $variantes[] = $cedula;
                    $soloDigitos = preg_replace('/\D+/', '', $cedula);
                    if ($soloDigitos !== '' && $soloDigitos !== $cedula) {
                        $variantes[] = $soloDigitos;
                    }
                }
            }
            $stmtEmp->close();
        }
        $variantes = array_values(array_unique(array_filter($variantes)));

        $placeholders = implode(',', array_fill(0, count($variantes), '?'));

        // Conjunto canónico de "técnicos que cobran una solicitud":
        //   - Aceptación explícita en envios_tecnicos (estado = 'Aceptada').
        //   - Asignación directa en solicitudes.tecnico_id (técnico concreto).
        // En ambos casos SOLO técnicos (sufijo '-tec'). Este conjunto se usa
        // tanto para decidir qué solicitudes cobra el empleado (FROM exterior)
        // como para calcular el divisor del reparto (subconsulta), de modo que
        // numerador y denominador siempre son coherentes.
        $fuenteTecnicosQueCobran = "
                SELECT s2.id AS solicitud_id, s2.tecnico_id AS tecnico_id
                FROM solicitudes s2
                WHERE s2.tecnico_id IS NOT NULL
                  AND s2.tecnico_id <> ''
                  AND s2.tecnico_id NOT IN ('multi', 'todos', 'soporte_remoto')
                  AND LOWER(s2.tecnico_id) LIKE '%-tec'
                UNION
                SELECT e2.solicitud_id AS solicitud_id, e2.tecnico_id AS tecnico_id
                FROM envios_tecnicos e2
                WHERE e2.tecnico_id IS NOT NULL
                  AND e2.tecnico_id <> ''
                  AND e2.estado_aceptacion = 'Aceptada'
                  AND LOWER(e2.tecnico_id) LIKE '%-tec'
        ";

        $sql = "
            SELECT
                b.solicitud_id,
                b.tecnico_id,
                (SELECT COUNT(DISTINCT b2.tecnico_id)
                   FROM (
                        $fuenteTecnicosQueCobran
                   ) b2
                  WHERE b2.solicitud_id = b.solicitud_id) AS tecnicos_aceptados,
                s.nro_control,
                s.cliente,
                s.tipo,
                s.categoria_cierre,
                COALESCE(s.fecha_resolucion, s.fecha_actualizacion, s.fecha_cierre) AS fecha_resolucion,
                s.sla_met,
                COALESCE(NULLIF(s.honorarios_monto, 0), p.monto, 0) AS monto,
                p.monto AS monto_catalogo,
                COALESCE(NULLIF(p.categoria_label, ''), p.categoria_cierre, s.categoria_cierre) AS categoria_label
            FROM (
                SELECT b1.solicitud_id AS solicitud_id, b1.tecnico_id AS tecnico_id
                FROM (
                    $fuenteTecnicosQueCobran
                ) b1
                WHERE b1.tecnico_id IN ($placeholders)
            ) b
            INNER JOIN solicitudes s
                    ON s.id = b.solicitud_id
            -- Tarifario por categoría: valor único (MAX) para no duplicar filas
            -- si existieran varias tarifas activas para la misma categoría.
            LEFT JOIN (
                SELECT categoria_cierre,
                       MAX(monto) AS monto,
                       SUBSTRING_INDEX(GROUP_CONCAT(categoria_label ORDER BY monto DESC), ',', 1) AS categoria_label
                FROM pagos_tipo_solicitud
                WHERE activo = 1
                GROUP BY categoria_cierre
            ) p ON p.categoria_cierre = s.categoria_cierre
            WHERE s.estado = 'Resuelto'
              AND s.categoria_cierre IS NOT NULL
              AND s.categoria_cierre <> ''
              AND DATE_FORMAT(COALESCE(s.fecha_resolucion, s.fecha_actualizacion, s.fecha_cierre), '%Y-%m') = ?
            ORDER BY fecha_resolucion ASC
        ";

        $stmt = $this->connSoporte->prepare($sql);
        if (!$stmt) {
            return null;
        }

        // El único placeholder de la consulta es el IN del FROM exterior
        // (las variantes del empleado); el resto de la fuente es fija y el
        // parámetro final es el período.
        $tipos = str_repeat('s', count($variantes)) . 's';
        $params = array_merge($variantes, [$periodo]);
        $stmt->bind_param($tipos, ...$params);

        $trabajos = [];
        $total = 0.0;

        if ($stmt->execute()) {
            $res = $stmt->get_result();
            while ($row = $res->fetch_assoc()) {
                $tecnicosAceptados = max(1, (int) ($row['tecnicos_aceptados'] ?? 1));
                $montoSolicitud = round((float) ($row['monto'] ?? 0), 2);
                $montoCatalogo = round((float) ($row['monto_catalogo'] ?? 0), 2);
                // Reparto equitativo entre los técnicos que cobran el trabajo.
                $montoTecnico = round($montoSolicitud / $tecnicosAceptados, 2);

                $total += $montoTecnico;
                $trabajos[] = [
                    'solicitud_id' => (int) ($row['solicitud_id'] ?? 0),
                    'nro_control' => (string) ($row['nro_control'] ?? ''),
                    'cliente' => (string) ($row['cliente'] ?? ''),
                    'tipo' => (string) ($row['tipo'] ?? ''),
                    'categoria_cierre' => (string) ($row['categoria_cierre'] ?? ''),
                    'categoria_label' => (string) ($row['categoria_label'] ?? ''),
                    'fecha_resolucion' => (string) ($row['fecha_resolucion'] ?? ''),
                    'sla_met' => $row['sla_met'] !== null ? (int) $row['sla_met'] : null,
                    'monto_solicitud' => $montoSolicitud,
                    'monto_catalogo' => $montoCatalogo,
                    'tecnicos_aceptados' => $tecnicosAceptados,
                    'monto_tecnico' => $montoTecnico,
                ];
            }
        }
        $stmt->close();

        return [
            'bonos_total_usd' => round($total, 2),
            'tickets' => count($trabajos),
            'trabajos' => $trabajos,
        ];
    }

    // =========================================================================
    // ASISTENCIA
    // =========================================================================

    /**
     * Calcula el desglose de sueldo por asistencia para el período.
     * Si no hay datos válidos, marca la asistencia como no aplicable.
     *
     * @return array<string,mixed>
     */
    private function calcularAsistencia(array $empleado, string $periodo): array
    {
        $default = [
            'aplica' => false,
            'tiene_marcas' => false,
            'motivo' => 'sin_marcas_periodo',
            'sueldo_calculado_usd' => 0.0,
            'valor_hora_usd' => 0.0,
            'horas_jornada' => 0.0,
            'horas_planificadas' => 0.0,
            'horas_lunes_viernes' => 0.0,
            'horas_fin_semana' => 0.0,
            'horas_extension' => 0.0,
            'horas_pagables' => 0.0,
            'dias_pagados_detalle' => [],
        ];

        $sueldoNominal = (float) ($empleado['sueldo'] ?? 0);
        if ($sueldoNominal <= 0) {
            $default['motivo'] = 'sueldo_nominal_no_valido';
            return $default;
        }

        $ancla = $periodo . '-01';

        try {
            $calculo = $this->calculator->calcular(
                $empleado,
                NominaCalculator::PERIODO_MENSUAL,
                $ancla,
                [
                    'descuento_falta' => false,
                    'descuento_retardo' => false,
                    'descuento_salida_anticipada' => false,
                ]
            );
        } catch (Throwable $e) {
            $default['motivo'] = 'empleado_no_encontrado';
            return $default;
        }

        $res = $calculo['resultado'] ?? [];
        $detalleDias = $calculo['detalle_dias'] ?? [];

        $tieneMarcas = false;
        foreach ($detalleDias as $d) {
            if (!empty($d['marca_entrada'])) {
                $tieneMarcas = true;
                break;
            }
        }

        $horasJornada = (float) ($res['horas_jornada_diaria'] ?? 0);
        $horasPlanificadas = (float) ($res['horas_planificadas_mes'] ?? 0);
        $valorHora = (float) ($res['valor_hora'] ?? 0);

        if ($horasJornada <= 0) {
            $default['motivo'] = 'horario_no_configurado';
            return $default;
        }
        if ($horasPlanificadas <= 0 || $valorHora <= 0) {
            $default['motivo'] = 'horas_planificadas_invalidas';
            $default['valor_hora_usd'] = $valorHora;
            $default['horas_jornada'] = $horasJornada;
            return $default;
        }
        if (!$tieneMarcas) {
            $default['motivo'] = 'sin_marcas_periodo';
            $default['valor_hora_usd'] = round($valorHora, NominaCalculator::DECIMALES_VALOR_UNITARIO);
            $default['horas_jornada'] = $horasJornada;
            $default['horas_planificadas'] = $horasPlanificadas;
            return $default;
        }

        $horasLv = 0.0;
        $horasFs = 0.0;
        $diasPagadosDetalle = [];
        $sueldoCalculado = 0.0;

        foreach ($detalleDias as $d) {
            $diaSemana = (int) ($d['dia_semana'] ?? 0);
            $horasTrab = (float) ($d['horas_trabajadas'] ?? 0);
            $horasExtra = (float) ($d['horas_extra'] ?? 0);
            $esFinSemana = ($diaSemana === 6 || $diaSemana === 7);

            $horasPagadasDia = $horasTrab + ($esFinSemana ? $horasExtra : 0);
            if ($esFinSemana) {
                $horasFs += $horasPagadasDia;
            } else {
                $horasLv += $horasTrab;
            }

            if ($horasPagadasDia > 0.0001) {
                $montoPagadoDia = round($horasPagadasDia * $valorHora, 2);
                $sueldoCalculado += $montoPagadoDia;
                $diasPagadosDetalle[] = [
                    'fecha' => (string) ($d['fecha'] ?? ''),
                    'horas_lunes_viernes' => $esFinSemana ? 0.0 : round($horasTrab, 2),
                    'horas_fin_semana' => $esFinSemana ? round($horasTrab, 2) : 0.0,
                    'horas_extension' => round($esFinSemana ? 0.0 : $horasExtra, 2),
                    'horas_pagadas' => round($horasPagadasDia, 2),
                    'horas_penalizadas' => 0.0,
                    'monto_pagado_usd' => $montoPagadoDia,
                    'monto_penalizacion_usd' => 0.0,
                    'monto_neto_usd' => $montoPagadoDia,
                ];
            }
        }

        $horasExtension = (float) ($res['horas_extra_aprobadas'] ?? 0);
        $horasPagables = round($horasLv + $horasFs + $horasExtension, 4);

        return [
            'aplica' => true,
            'tiene_marcas' => true,
            'motivo' => 'ok',
            'sueldo_calculado_usd' => round($sueldoCalculado, 2),
            'valor_hora_usd' => round($valorHora, NominaCalculator::DECIMALES_VALOR_UNITARIO),
            'horas_jornada' => $horasJornada,
            'horas_planificadas' => $horasPlanificadas,
            'horas_lunes_viernes' => round($horasLv, 4),
            'horas_fin_semana' => round($horasFs, 4),
            'horas_extension' => round($horasExtension, 4),
            'horas_pagables' => $horasPagables,
            'dias_pagados_detalle' => $diasPagadosDetalle,
        ];
    }

    // =========================================================================
    // GUARDIAS
    // =========================================================================

    /**
     * Calcula las guardias asignadas del período y la penalización por las
     * no cumplidas (guardias_no_cumplidas × horas_jornada × valor_hora).
     *
     * Esquema esperado de `guardias_asignadas`: cedula, fecha, tipo, estado.
     * Estados considerados "no cumplida": ausente|incumplida|no_cumplida|falto|perdida|rechazada.
     *
     * @return array<string,mixed>
     */
    private function calcularGuardias(array $empleado, string $periodo, float $valorHora, float $horasJornada): array
    {
        $resultado = [
            'asignadas' => 0,
            'cumplidas' => 0,
            'no_cumplidas' => 0,
            'penalizacion_horas' => 0.0,
            'penalizacion_usd' => 0.0,
            'detalle' => [],
        ];

        if (!$this->tablaExiste('guardias_asignadas')) {
            return $resultado;
        }

        $cedula = (string) ($empleado['cedula'] ?? '');
        if ($cedula === '') {
            return $resultado;
        }

        $inicio = $periodo . '-01';
        $fin = date('Y-m-t', strtotime($inicio));

        $stmt = $this->conn->prepare(
            "SELECT fecha, tipo, estado FROM guardias_asignadas
             WHERE cedula = ? AND fecha BETWEEN ? AND ?
             ORDER BY fecha ASC"
        );
        if (!$stmt) {
            return $resultado;
        }
        $stmt->bind_param('sss', $cedula, $inicio, $fin);
        if (!$stmt->execute()) {
            $stmt->close();
            return $resultado;
        }
        $rows = $stmt->get_result()->fetch_all(MYSQLI_ASSOC);
        $stmt->close();

        $estadosNoCumplida = ['ausente', 'incumplida', 'no_cumplida', 'no cumplida', 'falto', 'perdida', 'rechazada'];
        $estadosCumplida = ['cumplida', 'realizada', 'completada', 'asistio', 'asistió', 'ok'];

        $noCumplidas = 0;
        foreach ($rows as $r) {
            $estadoRaw = strtolower(trim((string) ($r['estado'] ?? '')));
            $penaliza = in_array($estadoRaw, $estadosNoCumplida, true);
            $cumplida = in_array($estadoRaw, $estadosCumplida, true);

            if ($penaliza) {
                $noCumplidas++;
            }

            $resultado['detalle'][] = [
                'fecha_guardia' => (string) ($r['fecha'] ?? ''),
                'tipo_guardia' => (string) ($r['tipo'] ?? ''),
                'estado' => (string) ($r['estado'] ?? ''),
                'penaliza' => $penaliza,
            ];

            if ($cumplida) {
                $resultado['cumplidas']++;
            }
        }

        $resultado['asignadas'] = count($rows);
        $resultado['no_cumplidas'] = $noCumplidas;

        if ($noCumplidas > 0 && $valorHora > 0 && $horasJornada > 0) {
            $penalizacionHoras = round($noCumplidas * $horasJornada, 4);
            $resultado['penalizacion_horas'] = $penalizacionHoras;
            $resultado['penalizacion_usd'] = round($penalizacionHoras * $valorHora, 2);
        }

        return $resultado;
    }

    /**
     * Normaliza un período a formato Y-m. Devuelve '' si es inválido.
     */
    private function normalizarPeriodo($periodo): string
    {
        $periodo = trim((string) $periodo);
        if (!preg_match('/^\d{4}-\d{2}$/', $periodo)) {
            return '';
        }
        $mes = (int) substr($periodo, 5, 2);
        if ($mes < 1 || $mes > 12) {
            return '';
        }
        return $periodo;
    }

    /**
     * Devuelve el array de saldo "vacío" para casos de error.
     *
     * @return array<string,mixed>
     */
    private function saldosVacios(string $idEmpleado, string $motivo): array
    {
        return [
            'id_empleado' => $idEmpleado,
            'sueldo_usd' => 0.0,
            'sueldo_nominal_usd' => 0.0,
            'bonos_ganancias_usd' => 0.0,
            'bonos_velocidad_usd' => 0.0,
            'total_bonos_usd' => 0.0,
            'tickets_bonos' => 0,
            'bonos_detalle' => [],
            'total_ganado_usd' => 0.0,
            'pagos_recibidos_usd' => 0.0,
            'descuentos_arrastre_usd' => 0.0,
            'total_descuentos_usd' => 0.0,
            'saldo_usd' => 0.0,
            'asistencia_aplica' => false,
            'asistencia_tiene_marcas' => false,
            'asistencia_motivo' => $motivo,
            'asistencia_sueldo_nominal_usd' => 0.0,
            'asistencia_sueldo_calculado_usd' => 0.0,
            'asistencia_valor_hora_usd' => 0.0,
            'asistencia_horas_planificadas' => 0.0,
            'asistencia_horas_lunes_viernes' => 0.0,
            'asistencia_horas_fin_semana' => 0.0,
            'asistencia_horas_extension' => 0.0,
            'asistencia_horas_pagables' => 0.0,
            'guardias_asignadas' => 0,
            'guardias_cumplidas' => 0,
            'guardias_no_cumplidas' => 0,
            'penalizacion_guardia_horas' => 0.0,
            'penalizacion_guardia_usd' => 0.0,
            'guardias_detalle' => [],
            'dias_pagados_detalle' => [],
            'periodos_internos' => [
                'periodo' => '',
                'unidades' => [],
                'semanas' => [],
                'quincenas' => [],
                'totales' => [
                    'sueldo_base_usd' => 0.0,
                    'bonos_usd' => 0.0,
                    'horas_trabajadas' => 0.0,
                    'horas_extra' => 0.0,
                    'dias_trabajados' => 0,
                    'dias_no_trabajados' => 0,
                    'neto_usd' => 0.0,
                ],
            ],
            'origen_referencia' => 'auto',
        ];
    }

    /**
     * Devuelve las columnas reales de una tabla (cacheado por tabla).
     *
     * @return array<int,string>
     */
    private function obtenerColumnas(string $tabla): array
    {
        static $cache = [];
        if (isset($cache[$tabla])) {
            return $cache[$tabla];
        }
        $columnas = [];
        $res = $this->conn->query("SHOW COLUMNS FROM `{$tabla}`");
        if ($res) {
            while ($row = $res->fetch_assoc()) {
                $columnas[] = (string) $row['Field'];
            }
        }
        $cache[$tabla] = $columnas;
        return $columnas;
    }

    /**
     * Indica si existe una tabla en la BD de nóminas (cacheado).
     */
    private function tablaExiste(string $tabla): bool
    {
        static $cache = [];
        if (isset($cache[$tabla])) {
            return $cache[$tabla];
        }
        $stmt = $this->conn->prepare(
            "SELECT COUNT(*) AS c FROM information_schema.tables
             WHERE table_schema = DATABASE() AND table_name = ?"
        );
        $existe = false;
        if ($stmt) {
            $stmt->bind_param('s', $tabla);
            if ($stmt->execute()) {
                $row = $stmt->get_result()->fetch_assoc();
                $existe = ((int) ($row['c'] ?? 0)) > 0;
            }
            $stmt->close();
        }
        $cache[$tabla] = $existe;
        return $existe;
    }
}
