<?php
/**
 * ASISMA CRM - Cotizaciones Controller
 */
class CotizacionesController extends Controller {

    private $cotizacionModel;
    private $leadModel;

    public function __construct() {
        $this->cotizacionModel = new Cotizacion();
        $this->leadModel = new Lead();
    }

    /**
     * Listado general de cotizaciones
     */
    public function index() {
        $page = (int)$this->input('page', 1);
        if ($page < 1) $page = 1;

        $limit = 20;
        
        $filters = [
            'estado' => $this->input('estado', ''),
            'q' => $this->input('q', ''),
            'franquiciado_id' => $this->input('franquiciado_id', '')
        ];

        $cotizaciones = $this->cotizacionModel->getQuotesFiltered($page, $limit, $filters);
        $totalQuotes = $this->cotizacionModel->getQuotesFilteredCount($filters);
        $totalPages = (int)ceil($totalQuotes / $limit);

        $franquiciadoModel = new Franquiciado();
        $franquiciados = $franquiciadoModel->getFranquiciadosDetalle();

        $this->render('cotizaciones/index', [
            'title' => 'Cotizaciones',
            'cotizaciones' => $cotizaciones,
            'totalQuotes' => $totalQuotes,
            'page' => $page,
            'totalPages' => $totalPages,
            'filters' => $filters,
            'franquiciados' => $franquiciados
        ]);
    }

    /**
     * Crear Nueva Cotización
     */
    public function crear() {
        $leadId = (int)$this->input('lead_id', 0);
        if ($leadId <= 0) {
            $this->redirect('leads');
        }

        $lead = $this->leadModel->getLeadDetails($leadId);
        if (!$lead) {
            $this->redirect('leads');
        }

        // Restringir por Tenant
        if (!SessionHelper::isAdmin() && $lead['franquiciado_id'] != SessionHelper::getFranquiciadoId()) {
            $this->redirect('leads');
        }

        $db = Database::getInstance();

        if ($_SERVER['REQUEST_METHOD'] === 'POST') {
            // ── Datos de facturación del CLIENTE: se exigen AL GENERAR la
            // cotización (regla 22-07), no recién al cobrar. Si el modal ya
            // los mandó (fact_*), se guardan; si aún faltan, se responde
            // faltan_datos para levantarlo antes de crear nada.
            $empresaFact = !empty($lead['empresa_id'])
                ? $db->fetchOne("SELECT * FROM empresas WHERE id = :id", ['id' => $lead['empresa_id']])
                : null;
            if ($empresaFact) {
                $empresaFact = $this->guardarDatosFacturacionSiVienen($empresaFact);
                $faltanFact = $this->camposFacturacionFaltantes($empresaFact, $lead);
                if (!empty($faltanFact)) {
                    $this->json($this->respuestaFaltanDatos($empresaFact, $faltanFact));
                }
            }

            // Recibir datos POST
            $productosPost = isset($_POST['productos']) ? $_POST['productos'] : array();
            $servicioPost  = isset($_POST['servicio']) && is_array($_POST['servicio']) ? $_POST['servicio'] : null;
            $descuentoValor = (float)$this->input('descuento_valor', 0);
            $descuentoTipo = $this->input('descuento_tipo', 'fijo');
            $notas = $this->input('notas', '');

            // Vigencia OBLIGATORIA por calendario: fecha hasta la cual está
            // vigente la cotización (vence al FINAL de ese día).
            $vigenteHasta = trim($this->input('vigente_hasta', ''));
            if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $vigenteHasta)) {
                $this->json(['success' => false, 'message' => 'Debe seleccionar en el calendario hasta cuándo estará vigente la cotización.']);
            }

            if (empty($productosPost) && !$servicioPost) {
                $this->json(['success' => false, 'message' => 'Debe agregar al menos un producto o un servicio a la cotización.']);
            }

            // Validar e hidratar productos con base de datos (banda de precios
            // min-max en la MONEDA del producto; los items en UF se convierten
            // a CLP con el valor del día para totalizar)
            $productosCotizados = [];
            $subtotal = 0.00;
            $valorUf  = null;

            foreach ($productosPost as $p) {
                $prodId       = (int)(isset($p['id'])       ? $p['id']       : 0);
                $cant         = (int)(isset($p['cantidad']) ? $p['cantidad'] : 1);
                $precioManual = (float)(isset($p['precio'])  ? $p['precio']   : 0);

                if ($prodId <= 0 || $cant <= 0) continue;

                $prodDb = $db->fetchOne(
                    "SELECT nombre, tipo, precio_minimo, precio_maximo, moneda, es_suscripcion, periodo_suscripcion
                     FROM productos WHERE id = :id", ['id' => $prodId]);
                if (!$prodDb) continue;

                // Un producto tipo SERVICIO jamás entra como fila de producto:
                // se cotiza con su plan (panel de servicio)
                if (isset($prodDb['tipo']) && $prodDb['tipo'] === 'servicio') {
                    $this->json(['success' => false, 'message' => '"' . $prodDb['nombre'] . '" es un SERVICIO: se cotiza eligiendo uno de sus planes, no como fila de producto.']);
                }

                // Alcance: el franquiciado solo cotiza productos autorizados
                if (!SessionHelper::isAdmin() && !AlcanceHelper::productoPermitido(SessionHelper::getFranquiciadoId(), $prodId)) {
                    $this->json(['success' => false, 'message' => 'El producto "' . $prodDb['nombre'] . '" no está autorizado para tu franquicia. Contacta a la matriz.'], 403);
                }

                $esUf     = $prodDb['moneda'] === 'UF';
                $simbolo  = $esUf ? 'UF ' : '$';
                $minimo   = (float)$prodDb['precio_minimo'];
                $maximo   = ($prodDb['precio_maximo'] !== null && $prodDb['precio_maximo'] > 0) ? (float)$prodDb['precio_maximo'] : null;

                // Banda de precios: fuera del rango se rechaza CON ALERTA,
                // para TODOS los roles (la banda la define la matriz en el
                // catalogo; si necesita otro precio, cambia la banda ahi)
                if ($minimo > 0 && $precioManual < $minimo) {
                    $this->json(['success' => false, 'message' => 'El precio de "' . $prodDb['nombre'] . '" (' . $simbolo . number_format($precioManual, $esUf ? 2 : 0, ',', '.') . ') está BAJO el mínimo permitido de ' . $simbolo . number_format($minimo, $esUf ? 2 : 0, ',', '.') . '.']);
                }
                if ($maximo !== null && $precioManual > $maximo) {
                    $this->json(['success' => false, 'message' => 'El precio de "' . $prodDb['nombre'] . '" (' . $simbolo . number_format($precioManual, $esUf ? 2 : 0, ',', '.') . ') SUPERA el máximo permitido de ' . $simbolo . number_format($maximo, $esUf ? 2 : 0, ',', '.') . '.']);
                }

                // Conversión a CLP para totalizar (el valor UF del día queda
                // guardado en la cotización)
                if ($esUf && $valorUf === null) $valorUf = UfHelper::valorUf();
                $precioClp = $esUf ? round($precioManual * $valorUf, 0) : $precioManual;

                $totalItem = $cant * $precioClp;
                $subtotal += $totalItem;

                $productosCotizados[] = [
                    'clase'     => 'producto',
                    'id'        => $prodId,
                    'nombre'    => $prodDb['nombre'],
                    'cantidad'  => $cant,
                    'moneda'    => $prodDb['moneda'],
                    'precio'    => $precioManual,          // en la moneda del producto
                    'precio_clp'=> $precioClp,             // convertido para totalizar
                    'total'     => $totalItem,
                    'es_suscripcion'      => (int)$prodDb['es_suscripcion'],
                    'periodo_suscripcion' => $prodDb['periodo_suscripcion']
                ];
            }

            // ── SERVICIO con plan (cotización estilo asisma_ops) ────────────
            // Máximo UN servicio y UN plan por cotización (decisión 21-07-2026).
            // Los precios base SIEMPRE se hidratan de la BD; el vendedor solo
            // los ajusta respetando los mínimos del plan. Características y
            // bonos son texto editable (como el modal del OPS) y se guardan
            // como snapshot: cambios futuros del catálogo no alteran lo enviado.
            $servicioDatos = null;
            $cuotas = 1;
            if ($servicioPost) {
                $servicioId = (int)(isset($servicioPost['servicio_id']) ? $servicioPost['servicio_id'] : 0);
                $planId     = (int)(isset($servicioPost['plan_id']) ? $servicioPost['plan_id'] : 0);
                if ($servicioId <= 0 || $planId <= 0) {
                    $this->json(['success' => false, 'message' => 'Debe seleccionar el servicio y uno de sus planes.']);
                }

                $servicioModel = new Servicio();
                $servicioDb = $servicioModel->getDetalleCompleto($servicioId, true);
                if (!$servicioDb || !$servicioDb['activo']) {
                    $this->json(['success' => false, 'message' => 'El servicio seleccionado no existe o está inactivo.']);
                }
                if (!SessionHelper::isAdmin() && !AlcanceHelper::servicioPermitido(SessionHelper::getFranquiciadoId(), $servicioId)) {
                    $this->json(['success' => false, 'message' => 'El servicio "' . $servicioDb['nombre'] . '" no está autorizado para tu franquicia. Contacta a la matriz.'], 403);
                }

                $planDb = null;
                foreach ($servicioDb['planes'] as $p) {
                    if ((int)$p['id'] === $planId) $planDb = $p;
                }
                if (!$planDb) {
                    $this->json(['success' => false, 'message' => 'El plan seleccionado no pertenece a este servicio o está inactivo.']);
                }

                $esUfPlan = $planDb['moneda'] === 'UF';
                $simboloPlan = $esUfPlan ? 'UF ' : '$';

                // Precios editados por el vendedor (default: los del plan)
                $precioInicial = isset($servicioPost['precio_inicial']) && $servicioPost['precio_inicial'] !== ''
                    ? (float)str_replace(',', '.', $servicioPost['precio_inicial'])
                    : ($planDb['precio_inicial'] !== null ? (float)$planDb['precio_inicial'] : null);
                $precioMensual = isset($servicioPost['precio_mensual']) && $servicioPost['precio_mensual'] !== ''
                    ? (float)str_replace(',', '.', $servicioPost['precio_mensual'])
                    : ($planDb['precio_mensual'] !== null ? (float)$planDb['precio_mensual'] : null);

                // Un plan sin precio inicial no puede recibir uno inventado (y viceversa)
                if ($planDb['precio_inicial'] === null) $precioInicial = null;
                if ($planDb['precio_mensual'] === null) $precioMensual = null;

                // Banda de precios del plan (rechazo con alerta, igual que la banda de productos)
                if ($planDb['precio_inicial_minimo'] !== null && $precioInicial !== null && $precioInicial < (float)$planDb['precio_inicial_minimo']) {
                    $this->json(['success' => false, 'message' => 'El pago inicial del plan "' . $planDb['nombre'] . '" (' . $simboloPlan . $precioInicial . ') está BAJO el mínimo permitido de ' . $simboloPlan . (float)$planDb['precio_inicial_minimo'] . '.']);
                }
                if (!empty($planDb['precio_inicial_maximo']) && $precioInicial !== null && $precioInicial > (float)$planDb['precio_inicial_maximo']) {
                    $this->json(['success' => false, 'message' => 'El pago inicial del plan "' . $planDb['nombre'] . '" (' . $simboloPlan . $precioInicial . ') SUPERA el máximo permitido de ' . $simboloPlan . (float)$planDb['precio_inicial_maximo'] . '.']);
                }
                if ($planDb['precio_mensual_minimo'] !== null && $precioMensual !== null && $precioMensual < (float)$planDb['precio_mensual_minimo']) {
                    $this->json(['success' => false, 'message' => 'La mensualidad del plan "' . $planDb['nombre'] . '" (' . $simboloPlan . $precioMensual . ') está BAJO el mínimo permitido de ' . $simboloPlan . (float)$planDb['precio_mensual_minimo'] . '.']);
                }
                if (!empty($planDb['precio_mensual_maximo']) && $precioMensual !== null && $precioMensual > (float)$planDb['precio_mensual_maximo']) {
                    $this->json(['success' => false, 'message' => 'La mensualidad del plan "' . $planDb['nombre'] . '" (' . $simboloPlan . $precioMensual . ') SUPERA el máximo permitido de ' . $simboloPlan . (float)$planDb['precio_mensual_maximo'] . '.']);
                }

                $cuotas = (int)(isset($servicioPost['cuotas']) ? $servicioPost['cuotas'] : 1);
                if ($cuotas < 1) $cuotas = 1;
                if ($cuotas > 12) $cuotas = 12;

                // Descuento del pago inicial (semántica OPS): fijo en la moneda
                // del plan o porcentaje. Usa las columnas descuento_* existentes.
                $descuentoValor = (float)(isset($servicioPost['descuento_valor']) ? str_replace(',', '.', $servicioPost['descuento_valor']) : 0);
                $descuentoTipo  = isset($servicioPost['descuento_tipo']) && $servicioPost['descuento_tipo'] === 'porcentaje' ? 'porcentaje' : 'fijo';
                if ($descuentoValor < 0) $descuentoValor = 0;

                // Características y bonos elegidos (texto libre, patrón OPS)
                $caracteristicas = array();
                if (isset($servicioPost['caracteristicas']) && is_array($servicioPost['caracteristicas'])) {
                    foreach ($servicioPost['caracteristicas'] as $c) {
                        $titulo = trim(isset($c['titulo']) ? $c['titulo'] : '');
                        if ($titulo === '') continue;
                        $caracteristicas[] = array('titulo' => $titulo, 'desc' => trim(isset($c['desc']) ? $c['desc'] : ''));
                    }
                }
                $bonos = array();
                if (isset($servicioPost['bonos']) && is_array($servicioPost['bonos'])) {
                    foreach ($servicioPost['bonos'] as $b) {
                        $titulo = trim(isset($b['titulo']) ? $b['titulo'] : '');
                        if ($titulo === '') continue;
                        $bonos[] = array('titulo' => $titulo, 'desc' => trim(isset($b['desc']) ? $b['desc'] : ''));
                    }
                }

                if ($esUfPlan && $valorUf === null) $valorUf = UfHelper::valorUf();
                $precioInicialClp = $precioInicial !== null ? ($esUfPlan ? round($precioInicial * $valorUf, 0) : round($precioInicial, 0)) : 0;
                $precioMensualClp = $precioMensual !== null ? ($esUfPlan ? round($precioMensual * $valorUf, 0) : round($precioMensual, 0)) : 0;

                // El pago inicial del plan entra al subtotal; la mensualidad es
                // recurrente e informativa (igual que el OPS)
                $subtotal += $precioInicialClp;

                $servicioDatos = array(
                    'clase'              => 'servicio_plan',
                    'servicio_id'        => $servicioId,
                    'producto_id'        => isset($servicioDb['producto_id']) ? (int)$servicioDb['producto_id'] : null,
                    'servicio_nombre'    => $servicioDb['nombre'],
                    'plan_id'            => $planId,
                    'plan_codigo'        => $planDb['codigo'],
                    'plan_nombre'        => $planDb['nombre'],
                    'moneda'             => $planDb['moneda'],
                    'precio_inicial'     => $precioInicial,
                    'precio_mensual'     => $precioMensual,
                    'precio_inicial_clp' => $precioInicialClp,
                    'precio_mensual_clp' => $precioMensualClp,
                    'caracteristicas'    => $caracteristicas,
                    'bonos'              => $bonos,
                );
                $productosCotizados[] = $servicioDatos;
            }

            if (empty($productosCotizados)) {
                $this->json(['success' => false, 'message' => 'Los productos agregados no son válidos.']);
            }

            // Calcular Descuento. Con servicio, el descuento aplica al TOTAL
            // inicial (pago inicial del plan + productos adicionales), no solo
            // al pago del plan (regla 22-07). Fijo en la moneda del plan o %.
            $descMonto = 0.00;
            if ($descuentoValor > 0) {
                if ($servicioDatos) {
                    $baseDescuento = $subtotal;
                    if ($descuentoTipo === 'porcentaje') {
                        $descMonto = $baseDescuento * $descuentoValor / 100;
                    } else {
                        $descMonto = $servicioDatos['moneda'] === 'UF' ? round($descuentoValor * $valorUf, 0) : $descuentoValor;
                    }
                    if ($descMonto > $baseDescuento) $descMonto = $baseDescuento;
                } elseif ($descuentoTipo === 'porcentaje') {
                    $descMonto = $subtotal * $descuentoValor / 100;
                } else {
                    $descMonto = $descuentoValor;
                }
            }

            // Calcular Impuesto
            $impuestoPct = 19.00; // IVA de Chile
            $montoConDescuento = $subtotal - $descMonto;
            $impuestoMonto = $montoConDescuento * ($impuestoPct / 100);
            
            // Total Final
            $totalFinal = $montoConDescuento + $impuestoMonto;

            // Generar hash único de 32 caracteres para el link
            $hash = bin2hex(openssl_random_pseudo_bytes(16)); // PHP 5.5: no existe random_bytes()

            // Vencimiento al final del día elegido, con el RELOJ DE MYSQL (PHP
            // y MySQL corren con ~4h de diferencia; vencimientos siempre por NOW())
            $filaVence = $db->fetchOne(
                "SELECT CONCAT(:f1, ' 23:59:59') as vence,
                        (CONCAT(:f2, ' 23:59:59') > NOW()) as valida,
                        GREATEST(1, CEIL(TIMESTAMPDIFF(MINUTE, NOW(), CONCAT(:f3, ' 23:59:59')) / 60)) as horas",
                ['f1' => $vigenteHasta, 'f2' => $vigenteHasta, 'f3' => $vigenteHasta]);
            if (empty($filaVence['valida'])) {
                $this->json(['success' => false, 'message' => 'La fecha de vigencia debe ser hoy o una fecha futura.']);
            }
            $vencimientoAt = $filaVence['vence'];
            // Se conserva validez_horas (derivada) por compatibilidad: la BD y
            // el documento CE (validez_dias) la siguen usando
            $validezHoras = (int)$filaVence['horas'];

            $tipoCotizacion = 'productos';
            if ($servicioDatos) {
                $tipoCotizacion = count($productosCotizados) > 1 ? 'mixta' : 'servicios';
            }

            $db->beginTransaction();
            try {
                // Crear Cotización
                $cotizId = $this->cotizacionModel->create([
                    'lead_id' => $leadId,
                    'franquiciado_id' => $lead['franquiciado_id'],
                    'usuario_id' => SessionHelper::getUsuarioId(),
                    'hash' => $hash,
                    'productos_json' => json_encode($productosCotizados, JSON_UNESCAPED_UNICODE),
                    'subtotal' => $subtotal,
                    'descuento_valor' => $descuentoValor,
                    'descuento_tipo' => $descuentoTipo,
                    'impuesto_pct' => $impuestoPct,
                    'total' => $totalFinal,
                    'moneda' => 'CLP',
                    'tipo' => $tipoCotizacion,
                    'servicio_id' => $servicioDatos ? $servicioDatos['servicio_id'] : null,
                    'cuotas' => $cuotas,
                    'valor_uf' => $valorUf,
                    'notas' => $notas,
                    'validez_horas' => $validezHoras,
                    'vencimiento_at' => $vencimientoAt,
                    'estado' => 'borrador'
                ]);

                // Estado automático: el lead avanza a 'cotizado' (solo hacia adelante,
                // no retrocede a un lead que ya está negociando o más allá)
                EmbudoHelper::avanzarAutomatico($leadId, ESTADO_COTIZADO, 'se generó una cotización.');

                // Registrar actividad en el lead
                $db->insert('actividades_crm', [
                    'lead_id' => $leadId,
                    'usuario_id' => SessionHelper::getUsuarioId(),
                    'tipo' => 'sistema',
                    'direccion' => 'interna',
                    'asunto' => 'Cotización generada',
                    'detalles' => "Se ha generado la cotización COT-" . str_pad($cotizId, 6, '0', STR_PAD_LEFT) . " por un total de $" . number_format($totalFinal, 0, ',', '.') . " CLP."
                ]);

                AuditHelper::log('create_quotation', 'cotizaciones', $cotizId, null, ['total' => $totalFinal, 'hash' => $hash]);

                $db->commit();

            } catch (Exception $e) {
                $db->rollBack();
                error_log("Error al crear cotización: " . $e->getMessage());
                $this->json(['success' => false, 'message' => 'Error de base de datos al registrar cotización.']);
            }

            // Documento COTIZACION en el facturador (Control Empresas) del
            // franquiciado. FUERA de la transacción: es una llamada HTTP.
            $mensaje = 'Cotización creada correctamente.';
            try {
                $resDoc = IntegracionesHelper::generarDocumentoEnFranquiciado($cotizId, 'COTIZACION');
                IntegracionesHelper::registrarResultado($leadId, 'Documento en facturador (CE)', $resDoc);
                if ($resDoc['success'] && !empty($resDoc['numero'])) {
                    $mensaje .= ' Cotización N° ' . $resDoc['numero'] . ' generada en Control Empresas.';
                }
            } catch (Exception $e) {
                error_log('Error generando documento CE: ' . $e->getMessage());
            }

            // Envío automático para cotizaciones con SERVICIO (flujo OPS):
            // PDF + WhatsApp + correo apenas se crea. Fuera de la transacción.
            $canales = null;
            if ($tipoCotizacion !== 'productos') {
                try {
                    $canales = $this->enviarCotizacion($cotizId, array('correo', 'whatsapp'));
                    if (!empty($canales['resumen'])) $mensaje .= ' ' . $canales['resumen'];
                } catch (Exception $e) {
                    error_log('Error enviando cotización de servicio: ' . $e->getMessage());
                    $mensaje .= ' AVISO: no se pudo enviar automáticamente; reenvíe desde la cotización.';
                }
            }

            $this->json([
                'success' => true,
                'message' => $mensaje,
                'tipo' => $tipoCotizacion,
                'cotizacion_id' => $cotizId,
                'redirect' => APP_URL . '/cotizaciones/ver?id=' . $cotizId
            ]);
        }

        // GET: productos para el selector. Matriz: toda la vertical del lead;
        // franquiciado: SOLO los productos autorizados. Precio único para todos
        // (decisión de alcance: el catálogo y sus precios viven en el CRM).
        $camposProducto = "p.id, p.nombre, p.precio_sugerido, p.precio_minimo, p.precio_maximo,
                           p.moneda, p.es_suscripcion, p.periodo_suscripcion, p.tipo,
                           (SELECT s.id FROM servicios s WHERE s.producto_id = p.id AND s.activo = 1 LIMIT 1) as servicio_id";
        if (SessionHelper::isAdmin()) {
            $productos = $db->fetchAll(
                "SELECT {$camposProducto} FROM productos p
                 WHERE p.vertical_id = :vid AND p.activo = 1
                 ORDER BY p.nombre ASC",
                ['vid' => $lead['vertical_interes']]);
        } else {
            // Solo los productos que el franquiciado puede VENDER
            // (marca completa autorizada o producto suelto)
            $productos = $db->fetchAll(
                "SELECT {$camposProducto} FROM productos p
                 WHERE (p.marca_id IN (SELECT marca_id FROM franquiciado_marcas WHERE franquiciado_id = :fid)
                     OR p.id IN (SELECT producto_id FROM franquiciado_productos WHERE franquiciado_id = :fid3))
                   AND p.activo = 1
                 ORDER BY p.nombre ASC",
                ['fid' => (int)SessionHelper::getFranquiciadoId(), 'fid3' => (int)SessionHelper::getFranquiciadoId()]
            );
        }

        // Servicios cotizables (con planes, características y bonos anidados):
        // matriz → los de la vertical del lead; franquiciado → solo sus marcas
        $servicioModel = new Servicio();
        $servicios = SessionHelper::isAdmin()
            ? $servicioModel->getServiciosParaCotizar($lead['vertical_interes'], null)
            : $servicioModel->getServiciosParaCotizar(null, SessionHelper::getFranquiciadoId());

        $this->render('cotizaciones/crear', [
            'title' => 'Nueva Cotización para: ' . htmlspecialchars($lead['contacto_nombre']),
            'lead' => $lead,
            'productos' => $productos,
            'servicios' => $servicios,
            'valorUfHoy' => UfHelper::valorUf()
        ]);
    }

    /**
     * Vista previa en vivo del documento premium de SERVICIO (AJAX POST desde
     * el formulario de creación, sin guardar nada). Devuelve el HTML CRUDO de
     * la misma plantilla que usa la cotización pública/PDF (publica_servicio),
     * armado con un $cotiz "de mentira" a partir de lo que el vendedor va
     * editando, para que la previsualización sea idéntica al documento final.
     */
    public function previsualizarServicio() {
        if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
            http_response_code(405);
            echo '<div style="font-family:sans-serif;padding:3rem;text-align:center;color:#94a3b8">Método no soportado.</div>';
            exit;
        }

        $servicioId = (int)$this->input('servicio_id', 0);
        $planId     = (int)$this->input('plan_id', 0);
        if ($servicioId <= 0 || $planId <= 0) {
            echo '<div style="font-family:sans-serif;padding:3rem;text-align:center;color:#94a3b8">Elige un plan para ver la vista previa.</div>';
            exit;
        }

        $servicioModel = new Servicio();
        $servicioDb = $servicioModel->getDetalleCompleto($servicioId, true);
        if (!$servicioDb || !$servicioDb['activo']) {
            echo '<div style="font-family:sans-serif;padding:3rem;text-align:center;color:#94a3b8">Servicio no disponible.</div>';
            exit;
        }
        if (!SessionHelper::isAdmin() && !AlcanceHelper::servicioPermitido(SessionHelper::getFranquiciadoId(), $servicioId)) {
            http_response_code(403);
            echo '<div style="font-family:sans-serif;padding:3rem;text-align:center;color:#94a3b8">Sin permiso sobre este servicio.</div>';
            exit;
        }

        $planDb = null;
        foreach ($servicioDb['planes'] as $p) {
            if ((int)$p['id'] === $planId) $planDb = $p;
        }
        if (!$planDb) {
            echo '<div style="font-family:sans-serif;padding:3rem;text-align:center;color:#94a3b8">El plan seleccionado no pertenece a este servicio.</div>';
            exit;
        }

        $esUfPlan = $planDb['moneda'] === 'UF';

        $precioInicial = $this->input('precio_inicial', '') !== ''
            ? (float)str_replace(',', '.', $this->input('precio_inicial'))
            : ($planDb['precio_inicial'] !== null ? (float)$planDb['precio_inicial'] : null);
        $precioMensual = $this->input('precio_mensual', '') !== ''
            ? (float)str_replace(',', '.', $this->input('precio_mensual'))
            : ($planDb['precio_mensual'] !== null ? (float)$planDb['precio_mensual'] : null);
        if ($planDb['precio_inicial'] === null) $precioInicial = null;
        if ($planDb['precio_mensual'] === null) $precioMensual = null;

        $cuotas = (int)$this->input('cuotas', 1);
        if ($cuotas < 1) $cuotas = 1;
        if ($cuotas > 12) $cuotas = 12;

        $descuentoValor = (float)str_replace(',', '.', $this->input('descuento_valor', 0));
        $descuentoTipo  = $this->input('descuento_tipo', 'fijo') === 'porcentaje' ? 'porcentaje' : 'fijo';
        if ($descuentoValor < 0) $descuentoValor = 0;

        // Texto libre: se lee $_POST directo (Controller::input() lo escaparía
        // con htmlspecialchars y la plantilla lo vuelve a escapar al pintar).
        $caracteristicas = array();
        if (isset($_POST['caracteristicas']) && is_array($_POST['caracteristicas'])) {
            foreach ($_POST['caracteristicas'] as $c) {
                $titulo = trim(isset($c['titulo']) ? $c['titulo'] : '');
                if ($titulo === '') continue;
                $caracteristicas[] = array('titulo' => $titulo, 'desc' => trim(isset($c['desc']) ? $c['desc'] : ''));
            }
        }
        $bonos = array();
        if (isset($_POST['bonos']) && is_array($_POST['bonos'])) {
            foreach ($_POST['bonos'] as $b) {
                $titulo = trim(isset($b['titulo']) ? $b['titulo'] : '');
                if ($titulo === '') continue;
                $bonos[] = array('titulo' => $titulo, 'desc' => trim(isset($b['desc']) ? $b['desc'] : ''));
            }
        }
        $notas = trim(isset($_POST['notas']) ? $_POST['notas'] : '');

        $valorUf   = $esUfPlan ? UfHelper::valorUf() : 0.0;
        $factorClp = $esUfPlan ? $valorUf : 1;
        $precioInicialClp = $precioInicial !== null ? round($precioInicial * $factorClp, 0) : 0;
        $precioMensualClp = $precioMensual !== null ? round($precioMensual * $factorClp, 0) : 0;

        // Subtotal/impuesto con la misma fórmula que crear(), acotada al
        // servicio (la vista previa no incluye filas de productos sueltos)
        $subtotal  = $precioInicialClp;
        $descMonto = 0;
        if ($descuentoValor > 0) {
            if ($descuentoTipo === 'porcentaje') {
                $descMonto = $subtotal * $descuentoValor / 100;
            } else {
                $descMonto = $esUfPlan ? round($descuentoValor * $factorClp, 0) : $descuentoValor;
            }
            if ($descMonto > $subtotal) $descMonto = $subtotal;
        }
        $montoConDescuento = $subtotal - $descMonto;
        $totalFinal = $montoConDescuento + ($montoConDescuento * 0.19);

        $leadId = (int)$this->input('lead_id', 0);
        $lead = $leadId ? $this->leadModel->getLeadDetails($leadId) : null;
        if ($lead && !SessionHelper::isAdmin() && $lead['franquiciado_id'] != SessionHelper::getFranquiciadoId()) {
            $lead = null;
        }

        $itemPlan = array(
            'clase'              => 'servicio_plan',
            'servicio_id'        => $servicioId,
            'producto_id'        => isset($servicioDb['producto_id']) ? (int)$servicioDb['producto_id'] : null,
            'servicio_nombre'    => $servicioDb['nombre'],
            'plan_id'            => $planId,
            'plan_codigo'        => $planDb['codigo'],
            'plan_nombre'        => $planDb['nombre'],
            'moneda'             => $planDb['moneda'],
            'precio_inicial'     => $precioInicial,
            'precio_mensual'     => $precioMensual,
            'precio_inicial_clp' => $precioInicialClp,
            'precio_mensual_clp' => $precioMensualClp,
            'caracteristicas'    => $caracteristicas,
            'bonos'              => $bonos,
        );

        $db = Database::getInstance();
        // Vista previa: si aún no se elige la fecha del calendario, se asume
        // +48h solo para pintar el documento (crear() sí la exige)
        $vigenteHasta = trim($this->input('vigente_hasta', ''));
        $filaVence = preg_match('/^\d{4}-\d{2}-\d{2}$/', $vigenteHasta)
            ? $db->fetchOne("SELECT CONCAT(:f, ' 23:59:59') as vence", ['f' => $vigenteHasta])
            : $db->fetchOne("SELECT DATE_ADD(NOW(), INTERVAL 48 HOUR) as vence");

        // Nombre de la franquicia dueña del lead (solo para el encabezado)
        $franqNombre = $servicioDb['nombre'];
        if ($lead) {
            $filaFranq = $db->fetchOne(
                "SELECT ef.nombre_fantasia
                 FROM franquiciados f INNER JOIN empresas_franquiciados ef ON f.empresa_madre_id = ef.id
                 WHERE f.id = :fid", ['fid' => (int)$lead['franquiciado_id']]);
            if ($filaFranq) $franqNombre = $filaFranq['nombre_fantasia'];
        }

        $cotizFake = array(
            'id'                      => 0,
            'hash'                    => 'preview',
            'estado'                  => 'enviada',
            'tipo'                    => 'servicios',
            'productos_json'          => json_encode(array($itemPlan), JSON_UNESCAPED_UNICODE),
            'subtotal'                => $subtotal,
            'impuesto_pct'            => 19,
            'moneda'                  => 'CLP',
            'valor_uf'                => $valorUf,
            'descuento_valor'         => $descuentoValor,
            'descuento_tipo'          => $descuentoTipo,
            'vencimiento_at'          => $filaVence['vence'],
            'created_at'              => date('Y-m-d H:i:s'),
            'franquiciado_nombre'     => $franqNombre,
            'cliente_razon_social'    => $lead ? $lead['empresa_razon_social'] : 'Empresa Demo SpA',
            'cliente_rut'             => $lead ? $lead['empresa_rut'] : '',
            'cliente_contacto_nombre' => $lead ? $lead['contacto_nombre'] : 'Cliente',
            'cliente_contacto_correo' => $lead ? $lead['contacto_correo'] : '',
            'ejecutivo_nombre'        => SessionHelper::getNombre() ? SessionHelper::getNombre() : 'Ejecutivo',
            'ejecutivo_telefono'      => '',
            'ejecutivo_email'         => SessionHelper::getEmail() ? SessionHelper::getEmail() : '',
            'notas'                   => $notas,
            'total'                   => $totalFinal,
            'cuotas'                  => $cuotas,
        );

        // Plantilla ornamentada solo si el DISEÑO de la marca es 'moderno';
        // en el resto, documento unificado con el diseño de la marca.
        $marcaIdSrv = isset($servicioDb['marca_id']) ? (int)$servicioDb['marca_id'] : 0;
        if (IdentidadHelper::disenoDeMarca($marcaIdSrv) === 'moderno') {
            $this->renderPartial('cotizaciones/publica_servicio', array(
                'cotiz'    => $cotizFake,
                'servicio' => $servicioDb
            ));
            return;
        }
        $this->renderPartial('cotizaciones/publica', array(
            'cotiz'            => $cotizFake,
            'servicio'         => $servicioDb,
            'identidad'        => IdentidadHelper::resolver($marcaIdSrv, $servicioDb),
            'diseno'           => IdentidadHelper::disenoDeMarca($marcaIdSrv),
            'condicionesMarca' => IdentidadHelper::condicionesDeMarca($marcaIdSrv),
            'flowActivo'       => false
        ));
    }

    /**
     * Vista previa del documento con la identidad de MARCA que se está
     * editando (SIN guardar), sobre una cotización de productos de muestra.
     * POST desde el modal de marcas (branding + diseño elegidos en el form).
     * Devuelve el HTML del documento unificado.
     */
    public function previsualizarProducto() {
        if (!SessionHelper::isAdmin()) {
            http_response_code(403);
            echo '<div style="font-family:sans-serif;padding:3rem;text-align:center;color:#94a3b8">Sin permiso.</div>';
            exit;
        }
        if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
            http_response_code(405);
            echo '<div style="font-family:sans-serif;padding:3rem;text-align:center;color:#94a3b8">Método no soportado.</div>';
            exit;
        }

        $identidad = array_merge(IdentidadHelper::defaults(), IdentidadHelper::construirDesdePost($_POST));
        $diseno = isset($_POST['diseno_documento']) && array_key_exists($_POST['diseno_documento'], IdentidadHelper::disenos())
            ? $_POST['diseno_documento'] : 'ejecutivo';
        $nombreProd = trim(isset($_POST['nombre']) ? $_POST['nombre'] : '');
        if ($nombreProd === '') $nombreProd = 'Producto de ejemplo';

        $productos = array(
            array('clase' => 'producto', 'nombre' => $nombreProd, 'cantidad' => 1, 'precio' => 250000, 'precio_clp' => 250000, 'total' => 250000, 'moneda' => 'CLP'),
            array('clase' => 'producto', 'nombre' => 'Otro producto de ejemplo', 'cantidad' => 2, 'precio' => 45000, 'precio_clp' => 45000, 'total' => 90000, 'moneda' => 'CLP'),
        );
        $subtotal = 340000;
        $total = round($subtotal * 1.19);

        $cotizFake = array(
            'id'                      => 0,
            'hash'                    => 'preview',
            'estado'                  => 'enviada',
            'tipo'                    => 'productos',
            'productos_json'          => json_encode($productos, JSON_UNESCAPED_UNICODE),
            'subtotal'                => $subtotal,
            'impuesto_pct'            => 19,
            'moneda'                  => 'CLP',
            'valor_uf'                => 0,
            'descuento_valor'         => 0,
            'descuento_tipo'          => 'fijo',
            'vencimiento_at'          => date('Y-m-d H:i:s', time() + 172800),
            'created_at'              => date('Y-m-d H:i:s'),
            'franquiciado_nombre'     => 'Vista previa',
            'cliente_razon_social'    => 'Empresa Demo SpA',
            'cliente_rut'             => '77.222.222-2',
            'cliente_contacto_nombre' => 'Juan Pérez',
            'cliente_contacto_correo' => 'contacto@empresademo.cl',
            'ejecutivo_nombre'        => SessionHelper::getNombre() ? SessionHelper::getNombre() : 'Ejecutivo',
            'ejecutivo_telefono'      => '',
            'ejecutivo_email'         => SessionHelper::getEmail() ? SessionHelper::getEmail() : '',
            'notas'                   => 'Condiciones comerciales de ejemplo.',
            'total'                   => $total,
            'cuotas'                  => 1,
        );

        $this->renderPartial('cotizaciones/publica', array(
            'cotiz'      => $cotizFake,
            'servicio'   => null,
            'identidad'  => $identidad,
            'diseno'     => $diseno,
            'flowActivo' => false
        ));
    }

    /**
     * Ver Cotización (Vista Privada Ejecutivos)
     */
    public function ver() {
        $id = (int)$this->input('id', 0);
        if ($id <= 0) {
            $this->redirect('cotizaciones');
        }

        // Cargar por ID
        $db = Database::getInstance();
        $cotiz = $db->fetchOne("SELECT c.*, l.contacto_id FROM cotizaciones c INNER JOIN leads l ON c.lead_id = l.id WHERE c.id = :id", ['id' => $id]);
        if (!$cotiz) {
            $this->redirect('cotizaciones');
        }

        // Resolver completo con hash
        $cotizDetalle = $this->cotizacionModel->getByHash($cotiz['hash']);

        // Restringir por Tenant
        if (!SessionHelper::isAdmin() && $cotizDetalle['franquiciado_id'] != SessionHelper::getFranquiciadoId()) {
            $this->redirect('cotizaciones');
        }

        // Si solicita descarga de PDF: SIEMPRE por Puppeteer sobre el documento
        // público unificado (misma fuente que ve el cliente).
        if (isset($_GET['pdf']) && $_GET['pdf'] == '1') {
            $rutaPdf = PdfServicioHelper::generar($cotizDetalle['hash']);
            if ($rutaPdf) {
                header('Content-Type: application/pdf');
                header('Content-Disposition: attachment; filename="cotizacion_' . $cotizDetalle['hash'] . '.pdf"');
                header('Content-Length: ' . filesize($rutaPdf));
                readfile($rutaPdf);
                exit;
            }
            $this->redirect('cotizaciones/publica?h=' . $cotizDetalle['hash'] . '&pdf_no_disponible=1');
        }

        $this->render('cotizaciones/ver', [
            'title' => 'Ver Cotización ' . folio_cotizacion($cotizDetalle),
            'cotiz' => $cotizDetalle
        ]);
    }

    /**
     * Genera en Control Empresas (facturador) los documentos pendientes de una
     * cotización: la COTIZACION si aún no existe, y la FACTURA si está pagada
     * y aún no existe (AJAX POST).
     */
    public function generarDocumentoCE() {
        if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
            $this->json(['success' => false, 'message' => 'Método no soportado.'], 405);
        }

        $id = (int)$this->input('id');
        if ($id <= 0) $this->json(['success' => false, 'message' => 'ID inválido.']);

        $db = Database::getInstance();
        $cotiz = $db->fetchOne("SELECT * FROM cotizaciones WHERE id = :id", ['id' => $id]);
        if (!$cotiz) $this->json(['success' => false, 'message' => 'Cotización no encontrada.']);

        if (!SessionHelper::isAdmin() && $cotiz['franquiciado_id'] != SessionHelper::getFranquiciadoId()) {
            $this->json(['success' => false, 'message' => 'Sin permisos sobre esta cotización.'], 403);
        }

        $mensajes = array();
        $algunExito = false;

        if (empty($cotiz['ce_cotizacion_id'])) {
            $resDoc = IntegracionesHelper::generarDocumentoEnFranquiciado($id, 'COTIZACION');
            IntegracionesHelper::registrarResultado($cotiz['lead_id'], 'Documento en facturador (CE)', $resDoc);
            $mensajes[] = $resDoc['detalle'];
            if ($resDoc['success']) $algunExito = true;
        } else {
            $mensajes[] = 'Cotización ya generada en Control Empresas (N° ' . $cotiz['ce_cotizacion_numero'] . ').';
            $algunExito = true;
        }

        if ($cotiz['estado'] === 'pagada') {
            if (empty($cotiz['ce_factura_id'])) {
                $resFact = IntegracionesHelper::generarDocumentoEnFranquiciado($id, 'FACTURA');
                IntegracionesHelper::registrarResultado($cotiz['lead_id'], 'Factura en facturador (CE)', $resFact);
                $mensajes[] = $resFact['detalle'];
                if ($resFact['success']) $algunExito = true;
            } else {
                $mensajes[] = 'Factura ya generada en Control Empresas (N° ' . $cotiz['ce_factura_numero'] . ').';
            }
        }

        AuditHelper::log('generar_documento_ce', 'cotizaciones', $id, null, ['mensajes' => $mensajes]);
        $this->json(['success' => $algunExito, 'message' => implode(' ', $mensajes)]);
    }

    /**
     * Ver Cotización (Vista Pública Clientes - SIN LOGIN)
     */
    public function verPublica() {
        $hash = $this->input('h', '');
        if (empty($hash)) {
            header("HTTP/1.1 404 Not Found");
            echo "Enlace no válido.";
            exit;
        }

        $cotiz = $this->cotizacionModel->getByHash($hash);
        if (!$cotiz) {
            header("HTTP/1.1 404 Not Found");
            echo "Cotización no disponible o vencida.";
            exit;
        }

        $db = Database::getInstance();

        // Con servicio (o mixta): la transferencia es la aceptación (sin
        // accept/reject ni Flow). El flujo aceptar/rechazar/pagar es solo para
        // cotizaciones de productos puros.
        $esServicio = isset($cotiz['tipo']) && $cotiz['tipo'] !== 'productos';

        // Si solicita descargar PDF: SIEMPRE por Puppeteer sobre el documento
        // público (una sola fuente de verdad para productos, servicios y mixta).
        // Si Puppeteer no está disponible, se degrada al link imprimible.
        if (isset($_GET['pdf']) && $_GET['pdf'] == '1') {
            $rutaPdf = PdfServicioHelper::generar($hash);
            if ($rutaPdf) {
                header('Content-Type: application/pdf');
                header('Content-Disposition: attachment; filename="cotizacion_' . $hash . '.pdf"');
                header('Content-Length: ' . filesize($rutaPdf));
                readfile($rutaPdf);
                exit;
            }
            $this->redirect('cotizaciones/publica?h=' . $hash . '&pdf_no_disponible=1');
        }

        // Si es la primera vez que la abre, marcar estado como 'vista'
        if ($cotiz['estado'] === 'borrador' || $cotiz['estado'] === 'enviada') {
            $db->update('cotizaciones', ['estado' => 'vista', 'vista_at' => date('Y-m-d H:i:s')], "id = :id", ['id' => $cotiz['id']]);
            
            // Log en bitácora de actividades de CRM
            $ip = isset($_SERVER['REMOTE_ADDR']) ? $_SERVER['REMOTE_ADDR'] : 'desconocida';
            $db->insert('actividades_crm', [
                'lead_id' => $cotiz['lead_id'],
                'usuario_id' => null,
                'tipo' => 'sistema',
                'direccion' => 'entrante',
                'asunto' => 'Cotización visualizada',
                'detalles' => "El cliente abrió el enlace público de la cotización. IP de origen: " . $ip
            ]);
        }

        // Si el cliente realiza una acción (Acepta o rechaza).
        // En cotizaciones de servicio NO existe este flujo (patrón OPS): se ignora.
        if (isset($_GET['action']) && !$esServicio) {
            $action = $_GET['action'];
            if ($action === 'accept') {
                $db->update('cotizaciones', ['estado' => 'aceptada'], "id = :id", ['id' => $cotiz['id']]);
                EmbudoHelper::avanzarAutomatico($cotiz['lead_id'], ESTADO_NEGOCIANDO, 'el cliente aceptó la cotización digital.');

                $db->insert('actividades_crm', [
                    'lead_id' => $cotiz['lead_id'],
                    'usuario_id' => null,
                    'tipo' => 'sistema',
                    'direccion' => 'entrante',
                    'asunto' => 'Cotización aceptada',
                    'detalles' => "El cliente aceptó la cotización a través del enlace digital."
                ]);
                $this->redirect('cotizaciones/publica?h=' . $hash . '&status=accepted');
            } elseif ($action === 'reject') {
                $db->update('cotizaciones', ['estado' => 'rechazada'], "id = :id", ['id' => $cotiz['id']]);
                
                $db->insert('actividades_crm', [
                    'lead_id' => $cotiz['lead_id'],
                    'usuario_id' => null,
                    'tipo' => 'sistema',
                    'direccion' => 'entrante',
                    'asunto' => 'Cotización rechazada',
                    'detalles' => "El cliente rechazó la cotización a través del enlace digital."
                ]);
                $this->redirect('cotizaciones/publica?h=' . $hash . '&status=rejected');
            }
        }

        // Renderizar el documento público (HTML independiente sin layouts).
        // Plantilla: la ornamentada de Control Farma (publica_servicio) SOLO si
        // el servicio de la cotización está marcado 'moderno'; en cualquier otro
        // caso, el documento unificado (publica) con la identidad resuelta.
        $servicioModel = new Servicio();
        $servicioCfg = $cotiz['servicio_id'] ? $servicioModel->getDetalleCompleto($cotiz['servicio_id']) : null;

        // La identidad y el DISEÑO vienen de la MARCA de lo cotizado: la del
        // servicio si lo hay; si no, la del primer producto del snapshot.
        $marcaId = $servicioCfg && !empty($servicioCfg['marca_id'])
            ? (int)$servicioCfg['marca_id']
            : $this->marcaPrimerProducto($cotiz['productos_json']);
        $diseno = IdentidadHelper::disenoDeMarca($marcaId);

        // Plantilla ornamentada (ex Control Farma) solo con servicio + diseño moderno
        if ($servicioCfg && $diseno === 'moderno') {
            $this->renderPartial('cotizaciones/publica_servicio', [
                'title' => 'Cotización' . ($servicioCfg ? ' - ' . htmlspecialchars($servicioCfg['nombre']) : ''),
                'cotiz' => $cotiz,
                'servicio' => $servicioCfg
            ]);
            return;
        }

        $this->renderPartial('cotizaciones/publica', [
            'title' => 'Cotización - ' . htmlspecialchars($cotiz['franquiciado_nombre']),
            'cotiz' => $cotiz,
            'servicio' => $servicioCfg,
            'identidad' => IdentidadHelper::resolver($marcaId, $servicioCfg),
            'diseno' => $diseno,
            'condicionesMarca' => IdentidadHelper::condicionesDeMarca($marcaId),
            'flowActivo' => FlowHelper::activo()
        ]);
    }

    /**
     * marca_id del PRIMER producto (no servicio) del snapshot de la
     * cotización — fuente de identidad cuando la cotización no lleva servicio.
     */
    private function marcaPrimerProducto($productosJson) {
        $items = json_decode($productosJson, true);
        if (!is_array($items)) return 0;
        foreach ($items as $it) {
            if (isset($it['clase']) && $it['clase'] === 'servicio_plan') continue;
            $pid = isset($it['id']) ? (int)$it['id'] : 0;
            if ($pid > 0) {
                $fila = Database::getInstance()->fetchOne("SELECT marca_id FROM productos WHERE id = :id", array('id' => $pid));
                return $fila ? (int)$fila['marca_id'] : 0;
            }
        }
        return 0;
    }

    /**
     * Envía la cotización al cliente por los canales pedidos (AJAX POST).
     * Cuerpo: id + canales[] (correo|whatsapp). Usado por el modal de canal
     * al crear cotizaciones de productos y por el botón de reenvío.
     */
    public function enviar() {
        if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
            $this->json(['success' => false, 'message' => 'Método no soportado.'], 405);
        }
        $id = (int)$this->input('id', 0);
        $canales = isset($_POST['canales']) && is_array($_POST['canales']) ? $_POST['canales'] : array();
        $canales = array_values(array_intersect($canales, array('correo', 'whatsapp')));
        if ($id <= 0 || empty($canales)) {
            $this->json(['success' => false, 'message' => 'Debe indicar la cotización y al menos un canal de envío.']);
        }

        $db = Database::getInstance();
        $cotiz = $db->fetchOne("SELECT * FROM cotizaciones WHERE id = :id AND activo = 1", ['id' => $id]);
        if (!$cotiz) $this->json(['success' => false, 'message' => 'Cotización no encontrada.']);
        if (!SessionHelper::isAdmin() && $cotiz['franquiciado_id'] != SessionHelper::getFranquiciadoId()) {
            $this->json(['success' => false, 'message' => 'Sin permisos sobre esta cotización.'], 403);
        }

        try {
            $resultado = $this->enviarCotizacion($id, $canales);
            $this->json(['success' => $resultado['algun_exito'], 'message' => $resultado['resumen'], 'detalle' => $resultado['detalle']]);
        } catch (Exception $e) {
            error_log('Error enviando cotización: ' . $e->getMessage());
            $this->json(['success' => false, 'message' => 'Error inesperado al enviar la cotización.']);
        }
    }

    /**
     * Traduce el error técnico de un canal de envío a un mensaje entendible.
     * El detalle técnico completo queda SOLO en el error_log (para depurar);
     * al usuario jamás se le muestran códigos HTTP ni JSON crudo.
     */
    private function mensajeErrorEnvio($canal, $errorTecnico) {
        $errorTecnico = (string)$errorTecnico;
        error_log('[Cotizaciones] Envío por ' . $canal . ' falló: ' . $errorTecnico);

        if ($canal === 'whatsapp') {
            if (stripos($errorTecnico, '"exists":false') !== false) {
                return 'el número del lead no tiene WhatsApp';
            }
            if (stripos($errorTecnico, 'connection') !== false || stripos($errorTecnico, 'instance') !== false
                || stripos($errorTecnico, 'desconectada') !== false) {
                return 'la instancia de WhatsApp no está conectada';
            }
            return 'no se pudo enviar el WhatsApp, reintenta en unos minutos';
        }

        // Correo: los mensajes que ya son entendibles pasan tal cual
        if (stripos($errorTecnico, 'Destinatario inválido') !== false) {
            return 'el correo del lead no es válido';
        }
        if (stripos($errorTecnico, 'no configurada') !== false) {
            return 'el envío de correos no está configurado en el sistema';
        }
        return 'no se pudo enviar el correo, reintenta en unos minutos';
    }

    /**
     * Motor de envío: PDF + WhatsApp y/o correo según canales.
     * PDF SIEMPRE por Puppeteer sobre el documento público unificado; si falla,
     * se envía igual con el link (sin adjunto). Los textos se parametrizan estilo
     * OPS cuando hay servicio, o genéricos con el link cuando es solo productos.
     * Devuelve array(algun_exito, resumen, detalle[]).
     */
    private function enviarCotizacion($cotizId, $canales) {
        $db = Database::getInstance();
        $base = $db->fetchOne("SELECT hash FROM cotizaciones WHERE id = :id", ['id' => (int)$cotizId]);
        $cotiz = $this->cotizacionModel->getByHash($base['hash']);
        if (!$cotiz) throw new Exception('Cotización no encontrada para el envío.');

        $esServicio = isset($cotiz['tipo']) && $cotiz['tipo'] !== 'productos';
        $linkPublico = APP_URL . '/cotizaciones/publica?h=' . $cotiz['hash'];
        $folio = folio_cotizacion($cotiz);

        // Item del plan y configuración del servicio (para los textos estilo OPS)
        $items = json_decode($cotiz['productos_json'], true);
        if (!is_array($items)) $items = array();
        $itemPlan = null;
        foreach ($items as $item) {
            if (isset($item['clase']) && $item['clase'] === 'servicio_plan') $itemPlan = $item;
        }
        $servicioCfg = null;
        if ($esServicio && !empty($cotiz['servicio_id'])) {
            $servicioModel = new Servicio();
            $servicioCfg = $servicioModel->getDetalleCompleto($cotiz['servicio_id']);
        }
        // Identidad de la MARCA de lo cotizado (colores, teléfono, transferencia)
        $marcaIdEnvio = $servicioCfg && !empty($servicioCfg['marca_id'])
            ? (int)$servicioCfg['marca_id']
            : $this->marcaPrimerProducto($cotiz['productos_json']);
        $identidadEnvio = IdentidadHelper::resolver($marcaIdEnvio, $servicioCfg);
        $nombreOferta = $servicioCfg ? $servicioCfg['nombre'] : 'ASISMA';
        $primerNombre = trim($cotiz['cliente_contacto_nombre']);
        $partesNombre = explode(' ', $primerNombre);
        $primerNombre = $partesNombre[0];
        $nombreCliente = $cotiz['cliente_razon_social'] ? $cotiz['cliente_razon_social'] : $cotiz['cliente_contacto_nombre'];

        // ── PDF ─────────────────────────────────────────────────────────────
        // SIEMPRE por Puppeteer sobre el documento público unificado (productos,
        // servicios y mixta). Si falla, el envío sigue con el link (sin adjunto).
        $rutaPdf = PdfServicioHelper::generar($cotiz['hash'], true);
        $nombrePdf = 'Cotizacion_' . preg_replace('/[^A-Za-z0-9]+/', '_', $nombreOferta . '_' . $nombreCliente) . '.pdf';

        // ── Textos ──────────────────────────────────────────────────────────
        $totalTxt = '$' . number_format((float)$cotiz['total'], 0, ',', '.');
        $venceTxt = date('d-m-Y H:i', strtotime($cotiz['vencimiento_at']));
        $cuotas = isset($cotiz['cuotas']) ? (int)$cotiz['cuotas'] : 1;

        if ($esServicio && $itemPlan) {
            $lineasPago = array();
            if ($cuotas > 1) {
                $lineasPago[] = '💰 *Valor Primera Cuota (1 de ' . $cuotas . '):* $' . number_format(round((float)$cotiz['total'] / $cuotas), 0, ',', '.') . ' (IVA incl.)';
                $lineasPago[] = 'Inversión inicial total: ' . $totalTxt . ' en ' . $cuotas . ' cuotas sin recargo.';
            } else {
                $lineasPago[] = '💰 *Total Pago Inicial (IVA incl.):* ' . $totalTxt;
            }
            if ($itemPlan['precio_mensual'] !== null) {
                $mensualTxt = $itemPlan['moneda'] === 'UF'
                    ? number_format((float)$itemPlan['precio_mensual'], 1, ',', '.') . ' UF + IVA (≈ $' . number_format(round($itemPlan['precio_mensual_clp'] * 1.19), 0, ',', '.') . ')'
                    : '$' . number_format((float)$itemPlan['precio_mensual'], 0, ',', '.') . ' + IVA';
                $lineasPago[] = '📅 *Mensualidad:* ' . $mensualTxt;
            }
            $lineasBonos = array();
            foreach ($itemPlan['bonos'] as $bono) {
                $lineasBonos[] = '✨ ' . $bono['titulo'];
            }
            $transferencia = '';
            if (!empty($identidadEnvio['transferencia_titular'])) {
                $transferencia = "\n\n*Datos para realizar la transferencia:*\n"
                    . $identidadEnvio['transferencia_titular'] . "\n"
                    . 'RUT: ' . $identidadEnvio['transferencia_rut'] . "\n"
                    . $identidadEnvio['transferencia_banco'] . ' - ' . $identidadEnvio['transferencia_tipo_cuenta'] . ' ' . $identidadEnvio['transferencia_numero_cuenta'] . "\n"
                    . $identidadEnvio['transferencia_correo'];
            }
            $textoWhatsApp = 'Hola ' . $primerNombre . ", 👋\n\n"
                . 'Te dejo nuestra cotización del *' . $itemPlan['plan_nombre'] . '* de ' . $nombreOferta . ":\n\n"
                . implode("\n", $lineasPago) . "\n\n"
                . "🔗 Puedes revisarla en línea aquí:\n" . $linkPublico . "\n\n"
                . ($lineasBonos ? "*Bonos incluidos:*\n" . implode("\n", $lineasBonos) . "\n\n" : '')
                . '⏰ Cotización válida hasta el ' . $venceTxt . '.'
                . $transferencia;

            $cuerpoCorreo = '<div style="font-family:Arial,sans-serif;max-width:640px;margin:0 auto;color:#334155;">'
                . '<h2 style="color:' . htmlspecialchars($identidadEnvio['color_primario']) . ';">Cotización ' . htmlspecialchars($nombreOferta) . '</h2>'
                . '<p>Hola <b>' . htmlspecialchars($primerNombre) . '</b>,</p>'
                . '<p>Adjuntamos la cotización del <b>' . htmlspecialchars($itemPlan['plan_nombre']) . '</b> de ' . htmlspecialchars($nombreOferta) . ' para ' . htmlspecialchars($nombreCliente) . '.</p>'
                . '<p style="background:#f5f3ff;border:1px solid #ddd6fe;border-radius:10px;padding:14px;">' . implode('<br>', array_map('strip_tags', str_replace('*', '', $lineasPago))) . '</p>'
                . '<p style="text-align:center;margin:22px 0;"><a href="' . $linkPublico . '" style="background:' . htmlspecialchars($identidadEnvio['color_primario']) . ';color:#fff;padding:12px 26px;border-radius:10px;text-decoration:none;font-weight:bold;">Ver Cotización Interactiva</a></p>'
                . ($lineasBonos ? '<p><b>Bonos incluidos:</b><br>' . implode('<br>', array_map('htmlspecialchars', str_replace('✨ ', '✅ ', $lineasBonos))) . '</p>' : '')
                . '<p style="font-size:12px;color:#64748b;">Cotización válida hasta el ' . $venceTxt . '. ' . htmlspecialchars(!empty($identidadEnvio['telefono_contacto']) ? 'Consultas al ' . $identidadEnvio['telefono_contacto'] . '.' : '') . '</p>'
                . '</div>';
            $asunto = 'Cotización ' . $nombreOferta . ' - ' . $nombreCliente;
        } else {
            $textoWhatsApp = 'Hola ' . $primerNombre . ", 👋\n\n"
                . 'Te compartimos la cotización *' . $folio . '* de ' . $cotiz['franquiciado_nombre'] . ":\n\n"
                . '💰 *Total (IVA incl.):* ' . $totalTxt . "\n\n"
                . "🔗 Puedes revisarla y aceptarla en línea aquí:\n" . $linkPublico . "\n\n"
                . '⏰ Válida hasta el ' . $venceTxt . '.';
            $cuerpoCorreo = '<p>Hola <b>' . htmlspecialchars($primerNombre) . '</b>,</p>'
                . '<p>Le compartimos la cotización <b>' . $folio . '</b> por un total de <b>' . $totalTxt . '</b> (IVA incluido).</p>'
                . '<p style="text-align:center;margin:20px 0;"><a href="' . $linkPublico . '" style="background:#2563eb;color:#fff;padding:12px 26px;border-radius:10px;text-decoration:none;font-weight:bold;">Ver y aceptar en línea</a></p>'
                . '<p style="font-size:12px;color:#64748b;">Cotización válida hasta el ' . $venceTxt . '.</p>';
            $asunto = 'Cotización ' . $folio . ' - ' . $cotiz['franquiciado_nombre'];
        }

        // ── Despacho por canal ──────────────────────────────────────────────
        $detalle = array();
        $algunExito = false;

        if (in_array('correo', $canales)) {
            $correoDestino = $cotiz['lead_correo'] ? $cotiz['lead_correo'] : $cotiz['cliente_contacto_correo'];
            if (!$correoDestino || !filter_var($correoDestino, FILTER_VALIDATE_EMAIL)) {
                $detalle['correo'] = array('success' => false, 'detalle' => 'El lead no tiene un correo válido registrado.');
            } else {
                $resCorreo = $rutaPdf
                    ? CorreoHelper::enviarConAdjuntos($correoDestino, $asunto, $cuerpoCorreo, array($rutaPdf))
                    : CorreoHelper::enviar($correoDestino, $asunto, $cuerpoCorreo);
                if (!$resCorreo['success']) {
                    $resCorreo['detalle'] = $this->mensajeErrorEnvio('correo', $resCorreo['detalle']);
                }
                $detalle['correo'] = $resCorreo;
                if ($resCorreo['success']) $algunExito = true;
            }
        }

        if (in_array('whatsapp', $canales)) {
            $telefono = $cotiz['lead_telefono'];
            if (!$telefono) {
                $detalle['whatsapp'] = array('success' => false, 'detalle' => 'El lead no tiene teléfono registrado.');
            } else {
                $lead = $db->fetchOne("SELECT * FROM leads WHERE id = :id", ['id' => (int)$cotiz['lead_id']]);
                $instancia = WhatsAppHelper::getInstanciaParaLead($lead);
                if (!$instancia) {
                    $detalle['whatsapp'] = array('success' => false, 'detalle' => 'No hay una instancia de WhatsApp activa disponible.');
                } else {
                    if ($rutaPdf) {
                        $resWsp = WhatsAppHelper::enviar($instancia, $telefono, $textoWhatsApp, 'document',
                            base64_encode(file_get_contents($rutaPdf)), $nombrePdf, 'application/pdf');
                    } else {
                        $resWsp = WhatsAppHelper::enviar($instancia, $telefono, $textoWhatsApp, 'text');
                    }
                    if ($resWsp['success']) {
                        $algunExito = true;
                        WhatsAppHelper::registrarMensaje((int)$cotiz['lead_id'], $instancia['id'],
                            isset($resWsp['message_id']) ? $resWsp['message_id'] : null,
                            'saliente', $rutaPdf ? 'document' : 'text', $textoWhatsApp, null, null,
                            'sistema', SessionHelper::getUsuarioId());
                    }
                    $detalle['whatsapp'] = array(
                        'success' => $resWsp['success'],
                        'detalle' => $resWsp['success'] ? 'WhatsApp enviado' . ($rutaPdf ? ' con PDF adjunto.' : ' (solo link, sin PDF).')
                                                       : $this->mensajeErrorEnvio('whatsapp', isset($resWsp['error']) ? $resWsp['error'] : 'error desconocido')
                    );
                }
            }
        }

        // Estado + bitácora + auditoría
        if ($algunExito && $cotiz['estado'] === 'borrador') {
            $db->update('cotizaciones', ['estado' => 'enviada'], "id = :id", ['id' => (int)$cotiz['id']]);
        }
        $resumenPartes = array();
        foreach ($detalle as $canal => $res) {
            $resumenPartes[] = ($canal === 'correo' ? 'Correo' : 'WhatsApp') . ': ' . ($res['success'] ? 'enviado' : 'FALLÓ (' . $res['detalle'] . ')');
        }
        $resumen = implode(' | ', $resumenPartes);
        $db->insert('actividades_crm', [
            'lead_id' => (int)$cotiz['lead_id'],
            'usuario_id' => SessionHelper::getUsuarioId(),
            'tipo' => 'sistema',
            'direccion' => 'saliente',
            'asunto' => 'Cotización enviada al cliente',
            'detalles' => $folio . ' — ' . $resumen
        ]);
        AuditHelper::log('enviar_cotizacion', 'cotizaciones', (int)$cotiz['id'], null, [
            'canales' => $canales,
            'con_pdf' => (bool)$rutaPdf,
            'resultado' => $detalle
        ]);

        return array('algun_exito' => $algunExito, 'resumen' => $resumen, 'detalle' => $detalle);
    }

    /**
     * Iniciar el pago Flow de una cotización ACEPTADA (público, por hash).
     * Crea la orden en Flow y redirige al cliente a la pasarela.
     */
    public function pagar() {
        $hash = $this->input('h', '');
        if (empty($hash)) {
            header("HTTP/1.1 404 Not Found");
            exit('Enlace no válido.');
        }

        $cotiz = $this->cotizacionModel->getByHash($hash);
        if (!$cotiz) {
            header("HTTP/1.1 404 Not Found");
            exit('Cotización no disponible.');
        }
        if ($cotiz['estado'] === 'pagada') {
            $this->redirect('cotizaciones/publica?h=' . $hash . '&status=paid');
        }
        if ($cotiz['estado'] !== 'aceptada') {
            $this->redirect('cotizaciones/publica?h=' . $hash . '&status=debe_aceptar');
        }
        if (!FlowHelper::activo()) {
            $this->redirect('cotizaciones/publica?h=' . $hash . '&status=pago_no_disponible');
        }

        $db = Database::getInstance();
        $lead = $db->fetchOne("SELECT correo_real FROM leads WHERE id = :id", ['id' => $cotiz['lead_id']]);
        $email = ($lead && $lead['correo_real']) ? $lead['correo_real'] : '';

        $pago = FlowHelper::crearPago($cotiz, $email);
        if (!$pago['success']) {
            error_log('[Cotizaciones] Error al crear pago Flow: ' . $pago['detalle']);
            $this->redirect('cotizaciones/publica?h=' . $hash . '&status=error_pago');
        }

        // Guardar la orden para conciliar la confirmación
        $db->update('cotizaciones', ['flow_orden' => $pago['flow_orden']], "id = :id", ['id' => $cotiz['id']]);
        $db->insert('actividades_crm', [
            'lead_id' => $cotiz['lead_id'],
            'usuario_id' => null,
            'tipo' => 'sistema',
            'direccion' => 'entrante',
            'asunto' => 'Pago iniciado',
            'detalles' => 'El cliente inició el pago de la cotización en Flow (orden ' . $pago['flow_orden'] . ').'
        ]);

        header('Location: ' . $pago['url_pago']);
        exit;
    }

    /**
     * Webhook de confirmación de Flow (POST token). Consulta el estado real
     * del pago y, si está pagado, marca la cotización 'pagada', avanza el
     * lead a GANADO y dispara las integraciones (Control Empresas + facturación).
     */
    public function flowConfirmacion() {
        if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
            header("HTTP/1.1 405 Method Not Allowed");
            exit;
        }
        $token = isset($_POST['token']) ? trim($_POST['token']) : '';
        if ($token === '') {
            header("HTTP/1.1 400 Bad Request");
            exit('token requerido');
        }

        $estado = FlowHelper::estadoPago($token);
        if (!$estado['success']) {
            error_log('[Flow] Confirmación sin estado: ' . $estado['detalle']);
            header("HTTP/1.1 502 Bad Gateway");
            exit('no se pudo verificar');
        }

        $db = Database::getInstance();
        $cotiz = $db->fetchOne(
            "SELECT * FROM cotizaciones WHERE flow_orden = :orden AND activo = 1 LIMIT 1",
            ['orden' => $estado['commerceOrder']]
        );
        if (!$cotiz) {
            header("HTTP/1.1 404 Not Found");
            exit('orden desconocida');
        }

        // Estados Flow: 1 pendiente, 2 pagada, 3 rechazada, 4 anulada
        if ((int)$estado['status'] !== 2) {
            $db->insert('actividades_crm', [
                'lead_id' => $cotiz['lead_id'],
                'usuario_id' => null,
                'tipo' => 'sistema',
                'direccion' => 'entrante',
                'asunto' => 'Pago no completado',
                'detalles' => 'Flow informó estado ' . (int)$estado['status'] . ' para la orden ' . $cotiz['flow_orden'] . ' (1=pendiente, 3=rechazado, 4=anulado).'
            ]);
            exit('ok');
        }

        if ($cotiz['estado'] === 'pagada') {
            exit('ok'); // confirmación repetida: idempotente
        }

        // 1. Cotización pagada (aceptada → pagada automático)
        $db->update('cotizaciones', ['estado' => 'pagada', 'pagada_at' => date('Y-m-d H:i:s')], "id = :id", ['id' => $cotiz['id']]);
        $db->insert('actividades_crm', [
            'lead_id' => $cotiz['lead_id'],
            'usuario_id' => null,
            'tipo' => 'sistema',
            'direccion' => 'entrante',
            'asunto' => 'Pago confirmado',
            'detalles' => 'Flow confirmó el pago de la cotización ' . folio_cotizacion($cotiz) . ' por $' . number_format((float)$cotiz['total'], 0, ',', '.') . ' CLP (orden ' . $cotiz['flow_orden'] . ').'
        ]);
        AuditHelper::log('pago_flow', 'cotizaciones', $cotiz['id'], ['estado' => $cotiz['estado']], ['estado' => 'pagada']);

        // 2. Lead a GANADO + integraciones + notificación a la marca (secuencia compartida)
        $this->ejecutarPostPago($cotiz, 'el cliente pagó la cotización vía Flow.');

        exit('ok');
    }

    /**
     * Si el modal de facturación mandó datos (fact_*), se validan y guardan
     * en la ficha de la empresa. Devuelve la empresa actualizada.
     */
    private function guardarDatosFacturacionSiVienen($empresa) {
        if (!isset($_POST['fact_giro']) && !isset($_POST['fact_direccion'])) {
            return $empresa;
        }
        $db = Database::getInstance();
        $factEmail = trim($this->input('fact_email', ''));
        if ($factEmail !== '' && !filter_var($factEmail, FILTER_VALIDATE_EMAIL)) {
            $this->json(['success' => false, 'message' => 'El correo de facturación no es válido.']);
        }
        $factComunaId = (int)$this->input('fact_comuna_id');
        if ($factComunaId > 0 && !$db->fetchOne("SELECT id FROM comunas_ce WHERE id = :id", [':id' => $factComunaId])) {
            $this->json(['success' => false, 'message' => 'La comuna seleccionada no existe en el catálogo de Control Empresas.']);
        }
        $datosFacturacion = [
            'giro'      => trim($this->input('fact_giro', '')),
            'direccion' => trim($this->input('fact_direccion', '')),
            'comuna_id' => $factComunaId > 0 ? $factComunaId : null,
            'email'     => $factEmail !== '' ? $factEmail : $empresa['email'],
            'telefono'  => trim($this->input('fact_telefono', '')) !== '' ? trim($this->input('fact_telefono', '')) : $empresa['telefono']
        ];
        $db->update('empresas', $datosFacturacion, "id = :id", ['id' => $empresa['id']]);
        AuditHelper::log('completar_facturacion_empresa', 'empresas', $empresa['id'],
            ['giro' => $empresa['giro'], 'direccion' => $empresa['direccion'], 'comuna_id' => $empresa['comuna_id']],
            $datosFacturacion);
        return $db->fetchOne("SELECT * FROM empresas WHERE id = :id", ['id' => $empresa['id']]);
    }

    /** Campos de facturación que faltan en la ficha (regla 20-07: siempre completos). */
    private function camposFacturacionFaltantes($empresa, $lead) {
        $faltan = [];
        if (empty($empresa['giro']))      $faltan[] = 'giro';
        if (empty($empresa['direccion'])) $faltan[] = 'dirección';
        if (empty($empresa['comuna_id'])) $faltan[] = 'comuna';
        if (empty($empresa['email']) && empty($lead['correo_real']))       $faltan[] = 'correo';
        if (empty($empresa['telefono']) && empty($lead['telefono_real']))  $faltan[] = 'teléfono';
        return $faltan;
    }

    /** Payload faltan_datos: el front levanta el modal de facturación con esto. */
    private function respuestaFaltanDatos($empresa, $faltan) {
        $db = Database::getInstance();
        return [
            'success'      => false,
            'faltan_datos' => true,
            'message'      => (empty($empresa['ce_cliente_id'])
                    ? 'La empresa aún no está creada en Control Empresas y le faltan datos de facturación: '
                    : 'A la empresa le faltan datos de facturación (se actualizarán también en Control Empresas): ')
                . implode(', ', $faltan) . '.',
            'campos'       => $faltan,
            'empresa'      => [
                'id'        => (int)$empresa['id'],
                'nombre'    => $empresa['nombre_fantasia'] ? $empresa['nombre_fantasia'] : $empresa['razon_social'],
                'rut'       => $empresa['rut'],
                'giro'      => $empresa['giro'],
                'direccion' => $empresa['direccion'],
                'comuna_id' => $empresa['comuna_id'] ? (int)$empresa['comuna_id'] : null,
                'email'     => $empresa['email'],
                'telefono'  => $empresa['telefono']
            ],
            'comunas'      => $db->fetchAll("SELECT id, nombre FROM comunas_ce ORDER BY nombre ASC")
        ];
    }

    /**
     * POST (matriz o franquiciado dueño): el franquiciado confirma que le
     * PAGARON una cotización ACEPTADA. Marca la cotización como pagada,
     * avanza el lead a GANADO, genera la FACTURA en Control Empresas y
     * notifica la venta a la MARCA por correo. Todo queda en auditoría.
     */
    public function marcarPagada() {
        if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
            $this->json(['success' => false, 'message' => 'Método no soportado.'], 405);
        }
        $id = (int)$this->input('id');
        if ($id <= 0) {
            $this->json(['success' => false, 'message' => 'ID inválido.']);
        }

        $db = Database::getInstance();
        $cotiz = $db->fetchOne("SELECT * FROM cotizaciones WHERE id = :id AND activo = 1", ['id' => $id]);
        if (!$cotiz) {
            $this->json(['success' => false, 'message' => 'Cotización no encontrada.']);
        }
        if (!SessionHelper::isAdmin() && (int)$cotiz['franquiciado_id'] !== (int)SessionHelper::getFranquiciadoId()) {
            $this->json(['success' => false, 'message' => 'Solo el franquiciado dueño de la cotización (o la matriz) puede marcarla como pagada.'], 403);
        }
        if ($cotiz['estado'] === 'pagada') {
            $this->json(['success' => false, 'message' => 'Esta cotización ya está marcada como pagada.']);
        }
        $esServicioPago = isset($cotiz['tipo']) && $cotiz['tipo'] !== 'productos';
        if ($esServicioPago) {
            // Flujo OPS: la TRANSFERENCIA es la aceptación. Se permite marcar
            // pagada desde enviada/vista/borrador; queda aceptada y pagada.
            if (!in_array($cotiz['estado'], array('borrador', 'enviada', 'vista', 'aceptada'))) {
                $this->json(['success' => false, 'message' => 'Esta cotización no se puede marcar como pagada (estado actual: ' . $cotiz['estado'] . ').']);
            }
            if ($cotiz['estado'] !== 'aceptada') {
                $db->update('cotizaciones', ['estado' => 'aceptada'], "id = :id", ['id' => $id]);
                $db->insert('actividades_crm', [
                    'lead_id' => $cotiz['lead_id'],
                    'usuario_id' => SessionHelper::getUsuarioId(),
                    'tipo' => 'sistema',
                    'direccion' => 'interna',
                    'asunto' => 'Cotización aceptada por transferencia',
                    'detalles' => 'Cotización de servicio: el pago por transferencia constituye la aceptación de la cotización.'
                ]);
                $cotiz['estado'] = 'aceptada';
            }
        } elseif ($cotiz['estado'] !== 'aceptada') {
            $this->json(['success' => false, 'message' => 'Solo una cotización ACEPTADA por el cliente se puede marcar como pagada (estado actual: ' . $cotiz['estado'] . ').']);
        }

        // ── Datos de facturación del CLIENTE (regla 20-07: siempre completos).
        // Normalmente ya se pidieron AL GENERAR la cotización (regla 22-07);
        // esto queda como red de seguridad para cotizaciones antiguas o fichas
        // editadas después.
        $lead = $db->fetchOne("SELECT * FROM leads WHERE id = :id", ['id' => $cotiz['lead_id']]);
        $empresa = ($lead && $lead['empresa_id'])
            ? $db->fetchOne("SELECT * FROM empresas WHERE id = :id", ['id' => $lead['empresa_id']])
            : null;
        if (!$empresa) {
            $this->json(['success' => false, 'message' => 'El lead no tiene empresa registrada; completa la ficha antes de facturar.']);
        }

        $empresa = $this->guardarDatosFacturacionSiVienen($empresa);
        $faltan = $this->camposFacturacionFaltantes($empresa, $lead);
        if (!empty($faltan)) {
            $this->json($this->respuestaFaltanDatos($empresa, $faltan));
        }

        $folio = folio_cotizacion($cotiz);
        $db->update('cotizaciones', ['estado' => 'pagada', 'pagada_at' => date('Y-m-d H:i:s')], "id = :id", ['id' => $cotiz['id']]);
        $db->insert('actividades_crm', [
            'lead_id'    => $cotiz['lead_id'],
            'usuario_id' => SessionHelper::getUsuarioId(),
            'tipo'       => 'sistema',
            'direccion'  => 'interna',
            'asunto'     => 'Pago confirmado por el franquiciado',
            'detalles'   => SessionHelper::getNombre() . ' confirmó el pago de la cotización ' . $folio . ' por $' . number_format((float)$cotiz['total'], 0, ',', '.') . ' CLP (marcada como pagada manualmente).'
        ]);
        AuditHelper::log('marcar_pagado', 'cotizaciones', $cotiz['id'],
            ['estado' => $cotiz['estado']],
            ['estado' => 'pagada', 'total' => (float)$cotiz['total'], 'confirmado_por' => SessionHelper::getNombre()]);

        $mensajes = $this->ejecutarPostPago($cotiz, 'el franquiciado confirmó el pago de la cotización.');

        $this->json([
            'success'  => true,
            'message'  => 'Cotización ' . $folio . ' marcada como PAGADA.',
            'detalles' => $mensajes
        ]);
    }

    /**
     * Secuencia post-pago compartida (Flow y marcado manual): lead a GANADO,
     * cliente + FACTURA en Control Empresas, notificación a la facturación del
     * franquiciado y correo con el detalle de la venta a la MARCA.
     * Devuelve los mensajes de cada paso para mostrarlos al usuario.
     */
    private function ejecutarPostPago($cotiz, $motivoGanado) {
        $db = Database::getInstance();
        $mensajes = [];

        EmbudoHelper::avanzarAutomatico($cotiz['lead_id'], ESTADO_GANADO, $motivoGanado);

        $lead = $db->fetchOne("SELECT * FROM leads WHERE id = :id", ['id' => $cotiz['lead_id']]);
        if ($lead) {
            try {
                $resCE = IntegracionesHelper::provisionarClienteControlEmpresas($lead);
                IntegracionesHelper::registrarResultado($lead['id'], 'Provisión Control Empresas', $resCE);
            } catch (Exception $e) {
                error_log('Error integración Control Empresas (post-pago): ' . $e->getMessage());
            }
            try {
                $resCliente = IntegracionesHelper::crearClienteEnFranquiciado($lead);
                IntegracionesHelper::registrarResultado($lead['id'], 'Cliente en cartera del franquiciado (CE)', $resCliente);
            } catch (Exception $e) {
                error_log('Error registrando cliente en franquiciado CE (post-pago): ' . $e->getMessage());
            }
            // Con el cliente asegurado, sus datos de facturación se sincronizan
            // SIEMPRE al auxiliar de CE (regla 20-07: la ficha del CRM manda)
            try {
                $resActualiza = IntegracionesHelper::actualizarClienteEnFranquiciado($lead);
                IntegracionesHelper::registrarResultado($lead['id'], 'Datos del cliente actualizados en CE', $resActualiza);
                $mensajes[] = $resActualiza['success']
                    ? 'Cliente en Control Empresas: ' . $resActualiza['detalle']
                    : 'Actualización del cliente en CE: FALLÓ — ' . $resActualiza['detalle'];
            } catch (Exception $e) {
                error_log('Error actualizando cliente en CE (post-pago): ' . $e->getMessage());
                $mensajes[] = 'Actualización del cliente en CE: error inesperado.';
            }
            try {
                $resFacturaCE = IntegracionesHelper::generarDocumentoEnFranquiciado($cotiz['id'], 'FACTURA');
                IntegracionesHelper::registrarResultado($lead['id'], 'Factura en facturador (CE)', $resFacturaCE);
                $mensajes[] = $resFacturaCE['success']
                    ? 'Factura en Control Empresas: ' . (isset($resFacturaCE['numero']) ? 'N° ' . $resFacturaCE['numero'] : $resFacturaCE['detalle'])
                    : 'Factura en Control Empresas: FALLÓ — ' . $resFacturaCE['detalle'];
            } catch (Exception $e) {
                error_log('Error generando factura CE (post-pago): ' . $e->getMessage());
                $mensajes[] = 'Factura en Control Empresas: error inesperado.';
            }
            try {
                $resFact = IntegracionesHelper::notificarFacturacion($lead);
                IntegracionesHelper::registrarResultado($lead['id'], 'Notificación de facturación', $resFact);
            } catch (Exception $e) {
                error_log('Error integración facturación (post-pago): ' . $e->getMessage());
            }
            // La MARCA se entera de CADA venta: correo con el detalle de la compra
            try {
                $resMarca = CorreoHelper::notificarVentaAMarca($cotiz['id']);
                $mensajes[] = $resMarca['success']
                    ? 'Marca notificada por correo: ' . $resMarca['detalle']
                    : 'Notificación a la marca: FALLÓ — ' . $resMarca['detalle'];
            } catch (Exception $e) {
                error_log('Error notificando a la marca (post-pago): ' . $e->getMessage());
                $mensajes[] = 'Notificación a la marca: error inesperado.';
            }
        }

        return $mensajes;
    }
}
