<?php

require_once __DIR__ . '/../../core/database.php';
require_once __DIR__ . '/../../core/config.php';
require_once __DIR__ . '/BillingHelpers.php';
require_once __DIR__ . '/BillingRuntimeGate.php';
require_once __DIR__ . '/BillingIssueValidationService.php';
require_once __DIR__ . '/BillingPaymentOptionsService.php';
require_once __DIR__ . '/BillingReceivablesService.php';
require_once __DIR__ . '/../facto/FactoClient.php';
require_once __DIR__ . '/../facto/FactoFileStorageService.php';
require_once __DIR__ . '/../crm/OpportunityStageHistoryService.php';
require_once __DIR__ . '/../crm/RestockForecastService.php';
require_once __DIR__ . '/../crm/QuoteInvoiceReconciliationService.php';
require_once __DIR__ . '/../finance/ExchangeRateService.php';
require_once __DIR__ . '/../costs/ProductCostSnapshotService.php';

class BillingIssueService
{
    private $pdo;
    private $validator;
    private $storage;
    private $exchangeRates;

    public function __construct(?PDO $pdo = null)
    {
        $this->pdo = $pdo ?: db();
        $this->validator = new BillingIssueValidationService($this->pdo);
        $this->storage = new FactoFileStorageService();
        $this->exchangeRates = new ExchangeRateService($this->pdo);
    }

    public function issueSandbox($quoteId, $documentKey, array $options = array())
    {
        // Compatibilidad con integraciones anteriores. El ambiente efectivo se
        // resuelve en issueQuote y nunca debe provenir del navegador.
        return $this->issueQuote($quoteId, $documentKey, $options);
    }

    public function issueQuote($quoteId, $documentKey, array $options = array())
    {
        $environment = billing_normalize_environment($options['environment'] ?? 'sandbox');
        billing_assert_runtime_operation('issue', $environment, 'Emisión desde cotización');
        billing_assert_issue_confirmation($options['confirmation'] ?? '', $environment);

        $validation = $this->validator->validateQuoteForIssue($quoteId, $documentKey, $options);
        if (empty($validation['ok'])) {
            return array(
                'ok' => false,
                'blocked' => true,
                'validation' => $validation,
                'message' => 'La validacion tributaria sigue bloqueada. Corrige los bloqueos antes de emitir.'
            );
        }

        $previewLog = $this->findPreviewLog($validation['idempotency_key'], $environment);
        if (!$previewLog) {
            throw new RuntimeException('Debes generar una vista previa aprobada en el mismo ambiente antes de confirmar la emisión.');
        }

        $this->assertNoSuccessfulIssue($validation['idempotency_key']);
        $this->assertNoMatchingIssuedDocument($validation);

        $payload = $validation['payload_preview'];
        if (!isset($payload['options']) || !is_array($payload['options'])) {
            $payload['options'] = array();
        }
        unset($payload['options']['draft_preview'], $payload['options']['draft_only']);
        $this->assertPreviewPayloadMatches($previewLog, $payload);

        // La emision tributaria y el correo son operaciones distintas. FACTO
        // documenta receiver_email como disparador del envio y no documenta
        // options.send_email para POST /documents. Se omiten ambos al emitir;
        // el usuario puede enviar luego el PDF/XML desde el paso Resultado.
        if (isset($payload['header']) && is_array($payload['header'])) {
            unset($payload['header']['receiver_email']);
        }
        if (isset($payload['options']) && is_array($payload['options'])) {
            unset($payload['options']['send_email']);
        }

        $markAsPaid = !empty($options['mark_as_paid']);
        if (!empty($validation['document_type']['is_export']) && $markAsPaid) {
            throw new RuntimeException('La factura de exportación crea una cuenta por cobrar. El pago debe registrarse posteriormente en Cobranza.');
        }
        $paymentDate = $this->cleanDate($options['payment_date'] ?? date('Y-m-d'));
        $paymentMethodId = (int)($options['payment_method_id'] ?? 0);
        $cashAccountId = (int)($options['cash_account_id'] ?? 0);
        $paymentReference = trim((string)($options['payment_reference'] ?? '')) ?: null;
        $paymentNotes = trim((string)($options['payment_notes'] ?? '')) ?: null;
        $paymentMethod = trim((string)($options['payment_method'] ?? '')) ?: null;
        $paymentMethodLabel = trim((string)($options['payment_method_label'] ?? '')) ?: null;
        $cashAccountLabel = trim((string)($options['cash_account_label'] ?? '')) ?: null;

        if ($markAsPaid) {
            if (!$paymentDate) {
                throw new RuntimeException('Debes informar la fecha de pago para registrar la factura como pagada.');
            }
            if ($paymentMethodId <= 0) {
                throw new RuntimeException('Debes seleccionar un metodo de pago estructurado.');
            }
            if ($cashAccountId <= 0) {
                throw new RuntimeException('Debes seleccionar la caja o cuenta destino del pago.');
            }
            $paymentOptions = new BillingPaymentOptionsService($this->pdo);
            $methodRow = $paymentOptions->getPaymentMethodById($paymentMethodId);
            if (!$methodRow) {
                throw new RuntimeException('El metodo de pago seleccionado no existe o esta inactivo.');
            }
            $cashAccountRow = $paymentOptions->getCashAccountById($cashAccountId);
            if (!$cashAccountRow) {
                throw new RuntimeException('La caja o cuenta destino seleccionada no existe o esta inactiva.');
            }
            $paymentMethod = $methodRow['method_key'];
            $paymentMethodLabel = $methodRow['method_name'];
            $cashAccountLabel = $cashAccountRow['account_name'];
        }

        // Contexto exclusivamente local: se guarda en el log para que una
        // conciliación posterior respete exactamente la decisión de pago del
        // usuario. Nunca se envía a FACTO.
        $logPayload = $payload;
        $logPayload['_icylab_payment'] = array(
            'mark_as_paid' => $markAsPaid ? 1 : 0,
            'payment_date' => $paymentDate,
            'payment_method' => $paymentMethod,
            'payment_method_label' => $paymentMethodLabel,
            'payment_method_id' => $paymentMethodId ?: null,
            'cash_account_id' => $cashAccountId ?: null,
            'cash_account_label' => $cashAccountLabel,
            'payment_reference' => $paymentReference,
            'payment_notes' => $paymentNotes,
        );

        $policyContext = array(
            'document_key' => $validation['document_type']['document_key'] ?? $documentKey,
            'user_id' => function_exists('current_user_id') ? (int)current_user_id() : 0,
            'customer_id' => (int)($validation['customer']['id'] ?? 0),
            'total_amount' => (float)($validation['calculation']['total_amount'] ?? 0),
        );
        billing_assert_issue_payload_policy($environment, $payload, $policyContext);
        $logId = $this->claimPreviewForIssue($previewLog, $logPayload);

        $factoDocumentId = null;
        $documentNumber = null;
        $effectiveResponse = null;
        $saved = array('pdf_file_path' => null, 'xml_file_path' => null);
        try {
            $client = new FactoClient($environment);
            $response = $client->createDocument($payload, array('generate_stamp' => 1), $policyContext);
            $businessError = $this->extractBusinessError($response);
            $factoDocumentId = $this->extractDocumentId($response);
            if ($businessError && !$factoDocumentId) {
                // Un error funcional puede ocurrir despues de que FACTO haya
                // consumido el folio (por ejemplo durante el envio de email).
                // Se trata como resultado incierto y se bloquea todo reintento
                // hasta sincronizar/conciliar, evitando facturas duplicadas.
                $this->upsertIssueLog($validation, $logPayload, 'pending_reconcile', $response, null, null, null, null, 'FACTO_POST_RESULT_UNCERTAIN', $businessError);
                return array(
                    'ok' => false,
                    'blocked' => false,
                    'pending_reconcile' => true,
                    'validation' => $validation,
                    'message' => 'FACTO informo una observacion despues del envio y no devolvio document_id. No vuelvas a emitir: sincroniza los DTE y concilia el documento existente. Detalle: ' . $businessError,
                    'facto_response_summary' => $this->responseSummary($response)
                );
            }

            if (!$factoDocumentId) {
                $this->upsertIssueLog($validation, $logPayload, 'pending_reconcile', $response, null, null, null, null, 'NO_DOCUMENT_ID', 'FACTO no devolvio document_id en la emision.');
                return array(
                    'ok' => false,
                    'pending_reconcile' => true,
                    'validation' => $validation,
                    'message' => 'FACTO no devolvio document_id. La emision queda pendiente de conciliacion.',
                    'facto_response_summary' => $this->responseSummary($response)
                );
            }

            $detailResponse = null;
            try {
                $detailResponse = $client->getDocument($factoDocumentId, true);
            } catch (Throwable $ignored) {
                $detailResponse = null;
            }

            $effectiveResponse = is_array($detailResponse) ? $detailResponse : $response;
            $documentNumber = $this->extractDocumentNumber($effectiveResponse) ?: $this->extractDocumentNumber($response);
            $electronicDocument = $this->extractElectronicDocument($effectiveResponse);
            if (empty($electronicDocument)) {
                $electronicDocument = $this->extractElectronicDocument($response);
            }

            $fileStorageWarning = null;
            $fileBase = $this->issueFileBaseName($validation, $factoDocumentId, $logId);
            try {
                $saved = $this->saveIssueFiles($electronicDocument, $environment, $fileBase);
            } catch (Throwable $fileError) {
                // Los archivos son recuperables con GET /documents. Un fallo de
                // disco local nunca debe perder el DTE ya emitido ni impedir que
                // se vinculen cotización, oportunidad y cuenta por cobrar.
                $fileStorageWarning = 'La factura fue emitida y vinculada, pero el PDF/XML local queda pendiente de recuperación automática.';
                error_log('FACTO issued file storage pending for document ' . $factoDocumentId . ': ' . $fileError->getMessage());
            }

            $status = 'success';
            $issueWarning = $businessError ?: $fileStorageWarning;
            $issueWarningCode = $businessError ? 'FACTO_POST_ISSUE_WARNING' : ($fileStorageWarning ? 'LOCAL_FILE_STORAGE_PENDING' : null);
            $logId = $this->upsertIssueLog(
                $validation,
                $logPayload,
                $status,
                $effectiveResponse,
                null,
                $factoDocumentId,
                $documentNumber,
                $saved,
                $issueWarningCode,
                $issueWarning
            );

            $this->pdo->beginTransaction();
            try {
                $this->persistFactoDocument($validation, $effectiveResponse, $factoDocumentId, $documentNumber, $saved, $markAsPaid, $paymentDate);
                $this->persistDocumentLines($validation, $factoDocumentId, $effectiveResponse);
                $collectionId = $this->persistCollection($validation, $factoDocumentId, $documentNumber, $markAsPaid, $paymentDate);
                if ($markAsPaid) {
                    $this->persistPayment($validation, $collectionId, $factoDocumentId, $paymentDate, $paymentMethod, $paymentMethodLabel, $paymentMethodId, $cashAccountId, $cashAccountLabel, $paymentReference, $paymentNotes);
                }
                $this->updateQuote($validation, $factoDocumentId, $documentNumber, $markAsPaid, $paymentDate, $paymentNotes);
                if (!empty($validation['document_type']['is_export'])) {
                    $fxApplied = $this->exchangeRates->applyToFactoDocumentByDocumentId($validation['environment'], $factoDocumentId, false);
                    if (empty($fxApplied['ok'])) {
                        throw new RuntimeException('Documento emitido, pero no fue posible congelar su tipo de cambio histórico: ' . ($fxApplied['message'] ?? 'sin detalle'));
                    }
                }
                $this->updateOpportunity($validation, $factoDocumentId, $markAsPaid, $paymentDate);
                $this->insertCrmDocument($validation, $factoDocumentId, $documentNumber, $saved);
                $this->insertActivity($validation, $factoDocumentId, $documentNumber, $markAsPaid);
                $this->pdo->commit();
                (new ProductCostSnapshotService($this->pdo))->snapshotDocumentSafe(
                    $validation['environment'],
                    $factoDocumentId,
                    function_exists('current_user_id') ? (int)current_user_id() : null
                );
            } catch (Throwable $e) {
                if ($this->pdo->inTransaction()) {
                    $this->pdo->rollBack();
                }
                $this->upsertIssueLog($validation, $logPayload, 'pending_reconcile', $effectiveResponse, null, $factoDocumentId, $documentNumber, $saved, 'LOCAL_PERSIST_ERROR', $e->getMessage());
                return array(
                    'ok' => false,
                    'pending_reconcile' => true,
                    'validation' => $validation,
                    'issue' => array(
                        'log_id' => (int)$logId,
                        'environment' => $environment,
                        'facto_document_id' => $factoDocumentId,
                        'document_number' => $documentNumber,
                        'has_pdf' => !empty($saved['pdf_file_path']),
                        'has_xml' => !empty($saved['xml_file_path']),
                        'pdf_url' => !empty($saved['pdf_file_path']) ? '/modules/billing/preview_pdf.php?log_id=' . (int)$logId : null
                    ),
                    'message' => 'FACTO emitio el documento, pero ICYLAB OS no pudo guardar todo el cierre local. Queda pendiente de conciliacion. Error: ' . $e->getMessage(),
                    'facto_response_summary' => $this->responseSummary($effectiveResponse)
                );
            }

            $forecastResult = null;
            $forecastOpportunityId = !empty($validation['quote']['opportunity_id']) ? (int)$validation['quote']['opportunity_id'] : 0;
            if ($forecastOpportunityId > 0) {
                try {
                    $forecastResult = (new RestockForecastService($this->pdo))->refreshFromOpportunity(
                        $forecastOpportunityId,
                        (string)$factoDocumentId,
                        'billing_issue'
                    );
                } catch (Throwable $forecastError) {
                    error_log('Restock forecast billing issue ' . $forecastOpportunityId . ': ' . $forecastError->getMessage());
                }
            }

            $this->notifyIssue($validation, $factoDocumentId, $documentNumber, $markAsPaid);

            return array(
                'ok' => true,
                'blocked' => false,
                'validation' => $validation,
                'issue' => array(
                    'log_id' => (int)$logId,
                    'environment' => $environment,
                    'facto_document_id' => $factoDocumentId,
                    'document_number' => $documentNumber,
                    'has_pdf' => !empty($saved['pdf_file_path']),
                    'has_xml' => !empty($saved['xml_file_path']),
                    'pdf_url' => !empty($saved['pdf_file_path']) ? '/modules/billing/preview_pdf.php?log_id=' . (int)$logId : null,
                    'quote_status' => $markAsPaid ? 'Pagada' : 'Esperando pago',
                    'payment_status' => $markAsPaid ? 'pagado' : 'por_cobrar',
                    'collection_created' => true,
                    'payment_registered' => $markAsPaid,
                    'restock_forecast' => $forecastResult,
                    'payment_method' => $markAsPaid ? $paymentMethodLabel : null,
                    'cash_account' => $markAsPaid ? $cashAccountLabel : null
                ),
                'message' => 'Documento emitido correctamente y vinculado a la cotización, oportunidad y cuenta por cobrar.' . ($fileStorageWarning ? ' El PDF/XML se recuperará al sincronizar.' : '') . ($businessError ? ' Advertencia de entrega por correo: ' . $businessError : ''),
                'warning' => $issueWarning,
                'facto_response_summary' => $this->responseSummary($effectiveResponse)
            );
        } catch (FactoApiException $e) {
            $httpCode = (int)$e->getHttpCode();
            $uncertain = $httpCode === 0 || $httpCode === 408 || $httpCode === 429 || $httpCode >= 500;
            $status = $uncertain ? 'pending_reconcile' : 'error';
            $errorCode = $uncertain ? 'FACTO_HTTP_UNCERTAIN' : 'FACTO_HTTP_ERROR';
            $this->upsertIssueLog($validation, $logPayload, $status, $e->getDecodedResponse() ?: $e->getResponseBody(), $httpCode, null, null, null, $errorCode, $e->getMessage());
            return array(
                'ok' => false,
                'blocked' => false,
                'pending_reconcile' => $uncertain,
                'validation' => $validation,
                'message' => $uncertain
                    ? 'FACTO no confirmó si recibió la emisión. No vuelvas a emitir: el documento quedó pendiente de conciliación. Detalle: ' . $e->getMessage()
                    : $e->getMessage(),
                'http_code' => $httpCode,
                'facto_error' => $e->getDecodedResponse()
            );
        } catch (Throwable $e) {
            if ($factoDocumentId !== null && trim((string)$factoDocumentId) !== '') {
                $this->upsertIssueLog(
                    $validation,
                    $logPayload,
                    'pending_reconcile',
                    is_array($effectiveResponse) ? $effectiveResponse : array('error' => $e->getMessage()),
                    null,
                    $factoDocumentId,
                    $documentNumber,
                    $saved,
                    'REMOTE_ISSUED_LOCAL_PENDING',
                    $e->getMessage()
                );
                return array(
                    'ok' => false,
                    'pending_reconcile' => true,
                    'validation' => $validation,
                    'issue' => array(
                        'log_id' => (int)$logId,
                        'environment' => $environment,
                        'facto_document_id' => $factoDocumentId,
                        'document_number' => $documentNumber,
                        'has_pdf' => !empty($saved['pdf_file_path']),
                        'has_xml' => !empty($saved['xml_file_path'])
                    ),
                    'message' => 'FACTO creó el documento, pero el cierre local quedó pendiente. No vuelvas a emitir; sincroniza y concilia el documento existente.'
                );
            }
            $this->upsertIssueLog($validation, $logPayload, 'error', array('error' => $e->getMessage()), null, null, null, null, 'ISSUE_RUNTIME_ERROR', $e->getMessage());
            throw $e;
        }
    }

    /**
     * Localiza el intento que originó un documento FACTO ya existente y
     * completa sus vínculos locales. Nunca llama POST /documents.
     */
    public function repairExistingDocumentLinks($idOrDocumentId, $environment = 'production', $quoteNumber = null)
    {
        $environment = billing_normalize_environment($environment);
        billing_assert_runtime_operation('read', $environment, 'Verificacion de documento FACTO existente');
        billing_assert_runtime_operation('finance', $environment, 'Reparacion de vinculos de documento emitido');

        $lookupValue = trim((string)$idOrDocumentId);
        $isExplicitDocumentId = strpos($lookupValue, 'document_id:') === 0;
        if ($isExplicitDocumentId) {
            $lookupValue = trim(substr($lookupValue, strlen('document_id:')));
            $document = false;
        } else {
            $stmt = $this->pdo->prepare('SELECT * FROM facto_documents WHERE environment=? AND id=? LIMIT 1');
            $stmt->execute(array($environment, (int)$lookupValue));
            $document = $stmt->fetch(PDO::FETCH_ASSOC);
        }
        if (!$document) {
            $stmt = $this->pdo->prepare('SELECT * FROM facto_documents WHERE environment=? AND BINARY document_id=BINARY ? LIMIT 1');
            $stmt->execute(array($environment, $lookupValue));
            $document = $stmt->fetch(PDO::FETCH_ASSOC);
        }
        if (!$document) {
            throw new RuntimeException('Documento FACTO emitido no encontrado en ICYLAB OS.');
        }

        $factoDocumentId = trim((string)($document['document_id'] ?? ''));
        if ($factoDocumentId === '') {
            throw new RuntimeException('El documento local no contiene document_id. No se modifico ningun vinculo.');
        }

        $quoteNumber = trim((string)$quoteNumber);
        if ($quoteNumber === '') {
            $rawDocument = json_decode((string)($document['raw_payload'] ?? ''), true);
            $quoteNumber = $this->extractQuoteNumberFromValue($rawDocument);
        }

        // Prueba de existencia e identidad contra FACTO. Es deliberadamente
        // una lectura; esta ruta no contiene ninguna llamada de emision.
        $client = new FactoClient($environment);
        $remoteDocument = $client->getDocument($factoDocumentId, true);

        $reconciliation = (new QuoteInvoiceReconciliationService($this->pdo))->reconcileDocument(
            $environment,
            $factoDocumentId,
            true,
            $quoteNumber !== '' ? $quoteNumber : null,
            is_array($remoteDocument) ? $remoteDocument : array()
        );
        if (empty($reconciliation['matched'])) {
            $reason = trim((string)($reconciliation['reason'] ?? 'No existe una coincidencia unica y verificable.'));
            throw new RuntimeException($reason . ' No se modifico ningun vinculo ni se emitio un nuevo DTE.');
        }

        $documentStmt = $this->pdo->prepare('SELECT * FROM facto_documents WHERE environment=? AND BINARY document_id=BINARY ? LIMIT 1');
        $documentStmt->execute(array($environment, $factoDocumentId));
        $document = $documentStmt->fetch(PDO::FETCH_ASSOC) ?: $document;

        $logWarning = null;
        try {
            $this->recordRecoveredIssue($document, $reconciliation, is_array($remoteDocument) ? $remoteDocument : array());
        } catch (Throwable $logError) {
            $logWarning = 'El vinculo se reparo, pero no fue posible normalizar el log historico: ' . $logError->getMessage();
            error_log('Billing recovered issue log ' . $factoDocumentId . ': ' . $logError->getMessage());
        }

        $localDocumentId = (int)($document['id'] ?? 0);
        if ($localDocumentId > 0) {
            (new BillingReceivablesService($this->pdo))->refreshCalculatedReceivableStatuses($environment, $localDocumentId);
        }
        (new ProductCostSnapshotService($this->pdo))->snapshotDocumentSafe(
            $environment,
            $factoDocumentId,
            function_exists('current_user_id') ? (int)current_user_id() : null
        );

        $opportunityId = (int)($reconciliation['opportunity_id'] ?? 0);
        if ($opportunityId > 0) {
            try {
                (new RestockForecastService($this->pdo))->refreshFromOpportunity($opportunityId, $factoDocumentId, 'billing_repair');
            } catch (Throwable $ignored) {
            }
        }

        $paymentStatus = strtolower(trim((string)($document['payment_status'] ?? 'por_cobrar')));
        return array(
            'ok' => true,
            'reconciled' => true,
            'recovered' => true,
            'no_new_issue' => true,
            'quote_id' => (int)$reconciliation['quote_id'],
            'opportunity_id' => $opportunityId ?: null,
            'facto_document_id' => $factoDocumentId,
            'document_number' => $document['document_number'] ?? null,
            'quote_status' => $reconciliation['target_status'] ?? null,
            'payment_status' => $paymentStatus !== '' ? $paymentStatus : 'por_cobrar',
            'warning' => $logWarning,
            'message' => 'Documento FACTO existente verificado y vinculado correctamente. No se genero otra factura, no se consumio otro folio y no se registro ningun pago.'
        );
    }

    /**
     * Implementacion historica conservada temporalmente para compatibilidad
     * durante la actualizacion; ya no es la ruta publica de reparacion.
     */
    private function repairExistingDocumentLinksLegacy($idOrDocumentId, $environment = 'production', $quoteNumber = null)
    {
        $environment = billing_normalize_environment($environment);
        billing_assert_runtime_operation('read', $environment, 'Búsqueda de origen de documento emitido');
        billing_assert_runtime_operation('finance', $environment, 'Reparación de vínculos de documento emitido');

        $documentFields = 'id, document_id, document_number, document_type_id, issue_date, receiver_tax_id_code, total_amount, raw_payload, crm_quote_id';
        $stmt = $this->pdo->prepare('SELECT ' . $documentFields . ' FROM facto_documents WHERE environment = ? AND id = ? LIMIT 1');
        $stmt->execute(array($environment, (int)$idOrDocumentId));
        $document = $stmt->fetch(PDO::FETCH_ASSOC);
        if (!$document) {
            $stmt = $this->pdo->prepare('SELECT ' . $documentFields . ' FROM facto_documents WHERE environment = ? AND BINARY document_id = BINARY ? LIMIT 1');
            $stmt->execute(array($environment, trim((string)$idOrDocumentId)));
            $document = $stmt->fetch(PDO::FETCH_ASSOC);
        }
        if (!$document) {
            throw new RuntimeException('Documento FACTO emitido no encontrado en ICYLAB OS.');
        }

        $factoDocumentId = trim((string)$document['document_id']);
        $quoteNumber = trim((string)$quoteNumber);
        if ($quoteNumber === '') {
            $rawDocument = json_decode((string)($document['raw_payload'] ?? ''), true);
            $quoteNumber = $this->extractQuoteNumberFromValue($rawDocument);
        }

        $quoteId = 0;
        if ($quoteNumber !== '') {
            $quoteStmt = $this->pdo->prepare('SELECT id, quote_number FROM crm_quotes WHERE BINARY quote_number = BINARY ? AND deleted_at IS NULL LIMIT 2');
            $quoteStmt->execute(array($quoteNumber));
            $quotes = $quoteStmt->fetchAll(PDO::FETCH_ASSOC);
            if (count($quotes) !== 1) {
                throw new RuntimeException('La cotización indicada no existe o no es única. No se modificó ningún vínculo.');
            }
            $quoteId = (int)$quotes[0]['id'];
            $quoteNumber = (string)$quotes[0]['quote_number'];
        }

        $sql = "\n            SELECT l.*, q.quote_number\n            FROM billing_issue_logs l\n            INNER JOIN crm_quotes q ON q.id = l.quote_id\n            WHERE l.environment = ? AND l.mode = 'issue' AND l.quote_id IS NOT NULL";
        $params = array($environment);
        if ($quoteId > 0) {
            $sql .= ' AND l.quote_id = ?';
            $params[] = $quoteId;
        }
        $sql .= "\n            ORDER BY CASE WHEN BINARY l.facto_document_id = BINARY ? THEN 0 ELSE 1 END, l.id DESC\n            LIMIT 2000";
        $params[] = $factoDocumentId;
        $stmt = $this->pdo->prepare($sql);
        $stmt->execute($params);

        $matchedLog = null;
        $identityMatches = array();
        foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
            $storedResponse = json_decode((string)($row['response_payload'] ?? ''), true);
            $candidateId = trim((string)($row['facto_document_id'] ?? ''));
            if ($candidateId === '' && is_array($storedResponse)) {
                $candidateId = trim((string)($this->extractDocumentId($storedResponse) ?: ''));
            }
            if ($candidateId !== '' && hash_equals($factoDocumentId, $candidateId)) {
                $matchedLog = $row;
                break;
            }

            $candidateNumber = trim((string)($row['document_number'] ?? ''));
            if ($candidateNumber === '' && is_array($storedResponse)) {
                $candidateNumber = trim((string)($this->extractDocumentNumber($storedResponse) ?: ''));
            }
            $sameType = empty($document['document_type_id']) || empty($row['document_type_id'])
                || (string)$document['document_type_id'] === (string)$row['document_type_id'];
            $sameNumber = trim((string)($document['document_number'] ?? '')) !== ''
                && $candidateNumber !== ''
                && hash_equals(trim((string)$document['document_number']), $candidateNumber);
            if ($sameType && $this->issuePayloadMatchesDocument($row['request_payload'] ?? null, $document, $row['quote_number'] ?? null)) {
                $identityMatches[(int)$row['id']] = $row;
                if ($sameNumber) {
                    $matchedLog = $row;
                    break;
                }
            }
        }
        if (!$matchedLog && count($identityMatches) === 1) {
            $matchedLog = reset($identityMatches);
        }
        if (!$matchedLog) {
            throw new RuntimeException('No se encontró un intento de emisión verificable para este document_id. Indica la cotización de origen (por ejemplo COT-2026-1493) para validar por RUT, fecha y total. No se modificó ningún vínculo.');
        }

        return $this->reconcileExistingIssue((int)$matchedLog['quote_id'], (int)$matchedLog['id'], $factoDocumentId);
    }

    /**
     * Completa solamente el cierre local de una emision que FACTO ya creo.
     * Este metodo nunca llama POST /documents y, por lo tanto, no consume un
     * nuevo folio. El document_id se obtiene exclusivamente del log guardado.
     */
    public function reconcileExistingIssue($quoteId, $logId, $expectedFactoDocumentId = null)
    {
        $quoteId = (int)$quoteId;
        $logId = (int)$logId;
        $environment = billing_runtime_environment(null);
        billing_assert_runtime_operation('read', $environment, 'Consulta de documento ya emitido para conciliacion');
        billing_assert_runtime_operation('finance', $environment, 'Cierre local de documento ya emitido');

        if ($quoteId <= 0 || $logId <= 0) {
            throw new InvalidArgumentException('Cotizacion o log de conciliacion invalido.');
        }

        $stmt = $this->pdo->prepare("\n            SELECT *\n            FROM billing_issue_logs\n            WHERE id = ? AND quote_id = ? AND environment = ? AND mode = 'issue'\n            LIMIT 1\n        ");
        $stmt->execute(array($logId, $quoteId, $environment));
        $issueLog = $stmt->fetch(PDO::FETCH_ASSOC);
        if (!$issueLog) {
            // Compatibilidad con intentos antiguos en los que FACTO emitio el
            // DTE pero el error de correo dejo el registro como draft_preview.
            // Solo se recupera si el log conserva un document_id y la ruta
            // moderna lo verifica por GET, RUT, fecha, tipo y montos.
            $fallbackStmt = $this->pdo->prepare('SELECT * FROM billing_issue_logs WHERE id=? AND quote_id=? AND environment=? LIMIT 1');
            $fallbackStmt->execute(array($logId, $quoteId, $environment));
            $fallbackLog = $fallbackStmt->fetch(PDO::FETCH_ASSOC);
            if ($fallbackLog) {
                $fallbackResponse = json_decode((string)($fallbackLog['response_payload'] ?? ''), true);
                $fallbackDocumentId = trim((string)($fallbackLog['facto_document_id'] ?? ''));
                if ($fallbackDocumentId === '' && is_array($fallbackResponse)) {
                    $fallbackDocumentId = trim((string)($this->extractDocumentId($fallbackResponse) ?: ''));
                }
                if ($fallbackDocumentId !== '') {
                    $quoteStmt = $this->pdo->prepare('SELECT quote_number FROM crm_quotes WHERE id=? LIMIT 1');
                    $quoteStmt->execute(array($quoteId));
                    $fallbackQuoteNumber = trim((string)$quoteStmt->fetchColumn());
                    return $this->repairExistingDocumentLinks($fallbackDocumentId, $environment, $fallbackQuoteNumber ?: (string)$quoteId);
                }
            }
            throw new RuntimeException('No existe una emision conciliable para esta cotizacion. No se realizo una emision nueva.');
        }

        $expectedFactoDocumentId = trim((string)$expectedFactoDocumentId);
        $storedResponse = json_decode((string)($issueLog['response_payload'] ?? ''), true);
        if (!is_array($storedResponse) && $expectedFactoDocumentId === '') {
            throw new RuntimeException('El log no contiene una respuesta FACTO verificable. No se realizo ninguna emision nueva.');
        }
        if (!is_array($storedResponse)) $storedResponse = array();
        $factoDocumentId = trim((string)($issueLog['facto_document_id'] ?? ''));
        if ($factoDocumentId === '') {
            $factoDocumentId = trim((string)($this->extractDocumentId($storedResponse) ?: ''));
        }
        if ($expectedFactoDocumentId !== '') {
            if ($factoDocumentId !== '' && !hash_equals($expectedFactoDocumentId, $factoDocumentId)) {
                throw new RuntimeException('El document_id guardado no coincide con el documento seleccionado. No se modificó ningún vínculo.');
            }
            $factoDocumentId = $expectedFactoDocumentId;
        }
        if ($factoDocumentId === '') {
            throw new RuntimeException('FACTO no informo document_id en el intento pendiente. No se puede conciliar automaticamente.');
        }

        $storedPayload = json_decode((string)($issueLog['request_payload'] ?? ''), true);
        if (!is_array($storedPayload) || empty($storedPayload['header']) || !is_array($storedPayload['header'])) {
            throw new RuntimeException('El log no contiene el payload tributario original para verificar la conciliacion.');
        }
        $header = $storedPayload['header'];
        $documentKey = trim((string)($issueLog['document_type_key'] ?? ''));
        if ($documentKey === '') {
            throw new RuntimeException('El log pendiente no identifica el tipo de documento.');
        }

        $validationOptions = array(
            'environment' => $environment,
            'issue_date' => $header['issue_date'] ?? date('Y-m-d'),
            'payment_terms' => $header['payment_conditions'] ?? '0',
            'receiver_tax_id_code' => $header['receiver_tax_id_code'] ?? null,
            'receiver_tax_id_type' => $header['receiver_tax_id_type'] ?? null,
            'receiver_legal_name' => $header['receiver_legal_name'] ?? null,
            'receiver_address' => $header['receiver_address'] ?? null,
            'receiver_district' => $header['receiver_district'] ?? null,
            'receiver_city' => $header['receiver_city'] ?? null,
            'receiver_country_id' => $header['receiver_country_id'] ?? null,
            'receiver_phone' => $header['receiver_phone'] ?? null,
            'receiver_activity' => $header['receiver_activity'] ?? null,
            'receiver_email' => $header['receiver_email'] ?? null,
            'exchange_rate_value' => $header['exchange_rate_value'] ?? null,
            'sale_modality' => $header['sale_modality'] ?? null,
            'sale_clause' => $header['sale_clause'] ?? null,
            '_reconcile_log_id' => $logId
        );
        if (!empty($header['currency_id'])) {
            $currencyStmt = $this->pdo->prepare('SELECT currency_code FROM billing_currencies WHERE facto_currency_id = ? LIMIT 1');
            $currencyStmt->execute(array($header['currency_id']));
            $currencyCode = trim((string)$currencyStmt->fetchColumn());
            if ($currencyCode !== '') $validationOptions['currency_code'] = $currencyCode;
        }

        $validation = $this->validator->validateQuoteForIssue($quoteId, $documentKey, $validationOptions);
        if (empty($validation['ok'])) {
            throw new RuntimeException('La cotizacion ya no coincide con la emision pendiente: ' . implode(' | ', $validation['errors'] ?? array('validacion rechazada')));
        }
        $currentPayload = $validation['payload_preview'];
        if (isset($currentPayload['options']) && is_array($currentPayload['options'])) {
            unset($currentPayload['options']['draft_preview'], $currentPayload['options']['draft_only']);
        }
        $this->assertPreviewPayloadMatches($issueLog, $currentPayload);

        // Solo consulta el documento existente; nunca crea ni reemite.
        $client = new FactoClient($environment);
        $remoteResponse = $client->getDocument($factoDocumentId, true);
        $remoteDocumentId = $this->extractDocumentId($remoteResponse);
        if ($remoteDocumentId && (string)$remoteDocumentId !== (string)$factoDocumentId) {
            throw new RuntimeException('La respuesta FACTO no coincide con el document_id pendiente. Conciliacion cancelada.');
        }

        $effectiveResponse = is_array($remoteResponse) ? $remoteResponse : $storedResponse;
        $documentNumber = $this->extractDocumentNumber($effectiveResponse)
            ?: $this->extractDocumentNumber($storedResponse)
            ?: ($issueLog['document_number'] ?? null);
        $electronicDocument = $this->extractElectronicDocument($effectiveResponse);
        if (empty($electronicDocument)) {
            $electronicDocument = $this->extractElectronicDocument($storedResponse);
        }
        $fileBase = $this->issueFileBaseName($validation, $factoDocumentId, $logId);
        $saved = $this->saveIssueFiles($electronicDocument, $environment, $fileBase);
        $warning = $this->extractBusinessError($storedResponse);

        $logId = $this->upsertIssueLog(
            $validation,
            $storedPayload,
            'success',
            $effectiveResponse,
            null,
            $factoDocumentId,
            $documentNumber,
            $saved,
            $warning ? 'FACTO_POST_ISSUE_WARNING' : null,
            $warning ?: null
        );

        $paymentContext = isset($storedPayload['_icylab_payment']) && is_array($storedPayload['_icylab_payment'])
            ? $storedPayload['_icylab_payment']
            : array();
        $markAsPaid = !empty($paymentContext['mark_as_paid']);
        $paymentDate = $this->cleanDate($paymentContext['payment_date'] ?? ($validation['payment']['issue_date'] ?? date('Y-m-d')));
        $paymentMethod = trim((string)($paymentContext['payment_method'] ?? '')) ?: null;
        $paymentMethodLabel = trim((string)($paymentContext['payment_method_label'] ?? '')) ?: null;
        $paymentMethodId = (int)($paymentContext['payment_method_id'] ?? 0);
        $cashAccountId = (int)($paymentContext['cash_account_id'] ?? 0);
        $cashAccountLabel = trim((string)($paymentContext['cash_account_label'] ?? '')) ?: null;
        $paymentReference = trim((string)($paymentContext['payment_reference'] ?? '')) ?: null;
        $paymentNotes = trim((string)($paymentContext['payment_notes'] ?? '')) ?: null;
        if ($markAsPaid && (!$paymentDate || $paymentMethodId <= 0 || $cashAccountId <= 0)) {
            throw new RuntimeException('El log indica pago al emitir, pero no conserva todos los datos financieros requeridos. Conciliación detenida para evitar un pago incompleto.');
        }
        try {
            $this->pdo->beginTransaction();
            $this->persistFactoDocument($validation, $effectiveResponse, $factoDocumentId, $documentNumber, $saved, $markAsPaid, $paymentDate);
            $this->persistDocumentLines($validation, $factoDocumentId, $effectiveResponse);
            $collectionId = $this->persistCollection($validation, $factoDocumentId, $documentNumber, $markAsPaid, $paymentDate);
            if ($markAsPaid) {
                $existingPayment = $this->pdo->prepare("SELECT COUNT(*) FROM billing_payments WHERE environment=? AND direction='received' AND BINARY facto_document_id=BINARY ?" . (billing_table_has_column($this->pdo, 'billing_payments', 'is_voided') ? ' AND COALESCE(is_voided,0)=0' : ''));
                $existingPayment->execute(array($environment, $factoDocumentId));
                if ((int)$existingPayment->fetchColumn() === 0) {
                    $this->persistPayment($validation, $collectionId, $factoDocumentId, $paymentDate, $paymentMethod, $paymentMethodLabel, $paymentMethodId, $cashAccountId, $cashAccountLabel, $paymentReference, $paymentNotes);
                }
            }
            $this->updateQuote($validation, $factoDocumentId, $documentNumber, $markAsPaid, $paymentDate, $paymentNotes ?: 'Conciliado desde emision FACTO existente.');
            if (!empty($validation['document_type']['is_export'])) {
                $fxApplied = $this->exchangeRates->applyToFactoDocumentByDocumentId($environment, $factoDocumentId, false);
                if (empty($fxApplied['ok'])) {
                    throw new RuntimeException('No fue posible congelar el tipo de cambio historico.');
                }
            }
            $this->updateOpportunity($validation, $factoDocumentId, $markAsPaid, $paymentDate);
            $this->insertCrmDocument($validation, $factoDocumentId, $documentNumber, $saved);
            $this->insertActivity($validation, $factoDocumentId, $documentNumber, $markAsPaid);
            $this->pdo->commit();
        } catch (Throwable $e) {
            if ($this->pdo->inTransaction()) $this->pdo->rollBack();
            $this->upsertIssueLog($validation, $storedPayload, 'pending_reconcile', $effectiveResponse, null, $factoDocumentId, $documentNumber, $saved, 'LOCAL_PERSIST_ERROR', $e->getMessage());
            return array(
                'ok' => false,
                'pending_reconcile' => true,
                'validation' => $validation,
                'issue' => array('log_id' => (int)$logId, 'environment' => $environment, 'facto_document_id' => $factoDocumentId, 'document_number' => $documentNumber),
                'message' => 'El documento existe en FACTO, pero el cierre local sigue pendiente. No vuelvas a emitir. Error: ' . $e->getMessage(),
                'facto_response_summary' => $this->responseSummary($effectiveResponse)
            );
        }

        // Si el documento ya había sido sincronizado, conserva como fuente de
        // verdad únicamente los pagos activos y propaga el estado resultante.
        $docStmt = $this->pdo->prepare('SELECT id FROM facto_documents WHERE environment = ? AND BINARY document_id = BINARY ? LIMIT 1');
        $docStmt->execute(array($environment, $factoDocumentId));
        $localDocumentId = (int)$docStmt->fetchColumn();
        if ($localDocumentId > 0) {
            (new BillingReceivablesService($this->pdo))->refreshCalculatedReceivableStatuses($environment, $localDocumentId);
        }

        (new ProductCostSnapshotService($this->pdo))->snapshotDocumentSafe(
            $environment,
            $factoDocumentId,
            function_exists('current_user_id') ? (int)current_user_id() : null
        );
        $forecastOpportunityId = !empty($validation['quote']['opportunity_id']) ? (int)$validation['quote']['opportunity_id'] : 0;
        if ($forecastOpportunityId > 0) {
            try {
                (new RestockForecastService($this->pdo))->refreshFromOpportunity($forecastOpportunityId, (string)$factoDocumentId, 'billing_reconcile');
            } catch (Throwable $ignored) {
            }
        }
        $this->notifyIssue($validation, $factoDocumentId, $documentNumber, $markAsPaid);

        return array(
            'ok' => true,
            'reconciled' => true,
            'validation' => $validation,
            'issue' => array(
                'log_id' => (int)$logId,
                'environment' => $environment,
                'facto_document_id' => $factoDocumentId,
                'document_number' => $documentNumber,
                'has_pdf' => !empty($saved['pdf_file_path']),
                'has_xml' => !empty($saved['xml_file_path']),
                'pdf_url' => !empty($saved['pdf_file_path']) ? '/modules/billing/preview_pdf.php?log_id=' . (int)$logId : null,
                'quote_status' => $markAsPaid ? 'Pagada' : 'Esperando pago',
                'payment_status' => $markAsPaid ? 'pagado' : 'por_cobrar',
                'collection_created' => true,
                'payment_registered' => $markAsPaid
            ),
            'message' => 'Documento FACTO existente conciliado correctamente. No se genero una nueva factura ni se consumio otro folio.' . ($warning ? ' El envio automatico por correo quedo pendiente.' : ''),
            'warning' => $warning ?: null,
            'facto_response_summary' => $this->responseSummary($effectiveResponse)
        );
    }

    private function recordRecoveredIssue(array $document, array $reconciliation, array $remoteDocument)
    {
        $quoteId = (int)($reconciliation['quote_id'] ?? 0);
        if ($quoteId <= 0) return;

        $stmt = $this->pdo->prepare('SELECT id, opportunity_id, customer_id FROM crm_quotes WHERE id=? LIMIT 1');
        $stmt->execute(array($quoteId));
        $quote = $stmt->fetch(PDO::FETCH_ASSOC);
        if (!$quote) return;

        $typeId = (int)($document['document_type_id'] ?? 0);
        $typeMap = array(
            2 => 'invoice_electronic',
            32 => 'invoice_exempt_electronic',
            46 => 'invoice_export_electronic',
        );
        $documentKey = $typeMap[$typeId] ?? ('facto_document_' . $typeId);
        $environment = (string)$document['environment'];
        $idempotencyKey = 'quote:' . $quoteId . ':document:' . $documentKey . ':env:' . $environment . ':v1';
        $requestPayload = array(
            '_recovery' => array(
                'operation' => 'link_existing_document',
                'facto_document_id' => (string)$document['document_id'],
                'verified_by' => 'GET /documents/{document_id}',
                'new_document_created' => false,
                'payment_created' => false,
            ),
        );
        $responseForLog = $remoteDocument;
        if (isset($responseForLog['electronic_document']) && is_array($responseForLog['electronic_document'])) {
            $responseForLog['electronic_document'] = array(
                'document_pdf_available' => !empty($responseForLog['electronic_document']['document_pdf']),
                'document_xml_available' => !empty($responseForLog['electronic_document']['document_xml']),
            );
        }
        if (isset($responseForLog['document']['electronic_document']) && is_array($responseForLog['document']['electronic_document'])) {
            $responseForLog['document']['electronic_document'] = array(
                'document_pdf_available' => !empty($responseForLog['document']['electronic_document']['document_pdf']),
                'document_xml_available' => !empty($responseForLog['document']['electronic_document']['document_xml']),
            );
        }
        $actorId = function_exists('current_user_id') ? (int)current_user_id() : 0;

        $stmt = $this->pdo->prepare("
            INSERT INTO billing_issue_logs
            (quote_id,opportunity_id,customer_id,document_type_key,document_type_id,environment,mode,status,idempotency_key,request_payload,response_payload,http_code,facto_document_id,document_number,pdf_file_path,xml_file_path,error_code,error_message,created_by)
            VALUES (?,?,?,?,?,?,'issue','success',?,?,?,?,?,?,?,?,?,?,?)
            ON DUPLICATE KEY UPDATE
                quote_id=VALUES(quote_id),
                opportunity_id=VALUES(opportunity_id),
                customer_id=VALUES(customer_id),
                document_type_key=VALUES(document_type_key),
                document_type_id=VALUES(document_type_id),
                mode='issue',
                status='success',
                request_payload=COALESCE(request_payload,VALUES(request_payload)),
                response_payload=VALUES(response_payload),
                http_code=VALUES(http_code),
                facto_document_id=VALUES(facto_document_id),
                document_number=VALUES(document_number),
                pdf_file_path=COALESCE(VALUES(pdf_file_path),pdf_file_path),
                xml_file_path=COALESCE(VALUES(xml_file_path),xml_file_path),
                error_code=VALUES(error_code),
                error_message=VALUES(error_message),
                updated_at=CURRENT_TIMESTAMP
        ");
        $stmt->execute(array(
            $quoteId,
            !empty($quote['opportunity_id']) ? (int)$quote['opportunity_id'] : null,
            !empty($quote['customer_id']) ? (int)$quote['customer_id'] : null,
            $documentKey,
            $typeId ?: null,
            $environment,
            $idempotencyKey,
            $this->jsonEncode($requestPayload),
            $this->jsonEncode($responseForLog),
            200,
            (string)$document['document_id'],
            $document['document_number'] ?? null,
            $document['pdf_file_path'] ?? null,
            $document['xml_file_path'] ?? null,
            'RECOVERED_EXISTING_DTE',
            'Documento existente verificado por GET y vinculado sin nueva emision ni registro de pago.',
            $actorId ?: null,
        ));
    }

    private function issuePayloadMatchesDocument($encodedPayload, array $document, $quoteNumber = null)
    {
        $payload = is_array($encodedPayload) ? $encodedPayload : json_decode((string)$encodedPayload, true);
        if (!is_array($payload) || empty($payload['header']) || !is_array($payload['header'])) return false;

        $quoteNumber = trim((string)$quoteNumber);
        if ($quoteNumber !== '') {
            $payloadQuoteNumber = $this->extractQuoteNumberFromValue($payload);
            if ($payloadQuoteNumber === '' || strcasecmp($payloadQuoteNumber, $quoteNumber) !== 0) return false;
        }

        $header = $payload['header'];
        $totals = isset($payload['totals']) && is_array($payload['totals']) ? $payload['totals'] : array();
        $checks = 0;
        $mismatches = 0;

        $documentRut = billing_clean_rut($document['receiver_tax_id_code'] ?? '');
        $payloadRut = billing_clean_rut($header['receiver_tax_id_code'] ?? '');
        if ($documentRut !== '' && $payloadRut !== '') {
            $checks++;
            if (!hash_equals($documentRut, $payloadRut)) $mismatches++;
        }

        if (isset($document['total_amount'], $totals['total_amount']) && is_numeric($document['total_amount']) && is_numeric($totals['total_amount'])) {
            $checks++;
            if (abs((float)$document['total_amount'] - (float)$totals['total_amount']) > 1.0) $mismatches++;
        }

        $documentDate = $this->normalizeIdentityDate($document['issue_date'] ?? null);
        $payloadDate = $this->normalizeIdentityDate($header['issue_date'] ?? null);
        if ($documentDate !== '' && $payloadDate !== '') {
            $checks++;
            if (!hash_equals($documentDate, $payloadDate)) $mismatches++;
        }

        return $checks >= 2 && $mismatches === 0;
    }

    private function extractQuoteNumberFromValue($value, $depth = 0)
    {
        if ($depth > 10) return '';
        if (is_string($value)) {
            if (preg_match('/\bCOT-\d{4}-\d+\b/i', $value, $match)) return strtoupper($match[0]);
            $decoded = json_decode($value, true);
            if (is_array($decoded)) return $this->extractQuoteNumberFromValue($decoded, $depth + 1);
            return '';
        }
        if (!is_array($value)) return '';
        foreach ($value as $child) {
            $found = $this->extractQuoteNumberFromValue($child, $depth + 1);
            if ($found !== '') return $found;
        }
        return '';
    }

    private function normalizeIdentityDate($value)
    {
        if ($value === null || trim((string)$value) === '') return '';
        $timestamp = strtotime((string)$value);
        return $timestamp ? date('Y-m-d', $timestamp) : '';
    }

    private function findPreviewLog($idempotencyKey, $environment)
    {
        $sql = "SELECT * FROM billing_issue_logs WHERE idempotency_key = ? AND environment = ? AND mode = 'draft_preview' AND status = 'validated'";
        if ($environment !== 'production') {
            $sql .= ' AND pdf_file_path IS NOT NULL';
        }
        $sql .= ' ORDER BY id DESC LIMIT 1';
        $stmt = $this->pdo->prepare($sql);
        $stmt->execute(array($idempotencyKey, $environment));
        return $stmt->fetch(PDO::FETCH_ASSOC) ?: null;
    }

    private function assertPreviewPayloadMatches(array $previewLog, array $issuePayload)
    {
        $previewPayload = json_decode((string)($previewLog['request_payload'] ?? ''), true);
        if (!is_array($previewPayload)) {
            throw new RuntimeException('La vista previa aprobada no contiene un payload verificable. Vuelve a generarla.');
        }

        if (!hash_equals($this->payloadFingerprint($previewPayload), $this->payloadFingerprint($issuePayload))) {
            throw new RuntimeException('Los datos cambiaron después de la vista previa. Vuelve a validar y previsualizar antes de emitir.');
        }
    }

    private function claimPreviewForIssue(array $previewLog, array $payload)
    {
        $stmt = $this->pdo->prepare("\n            UPDATE billing_issue_logs\n            SET mode='issue', status='sent', request_payload=?, response_payload=NULL,\n                http_code=NULL, error_code=NULL, error_message=NULL, updated_at=CURRENT_TIMESTAMP\n            WHERE id=? AND mode='draft_preview' AND status='validated'\n        ");
        $stmt->execute(array($this->jsonEncode($payload), (int)$previewLog['id']));
        if ($stmt->rowCount() !== 1) {
            throw new RuntimeException('La emisión ya fue tomada por otro proceso o la vista previa dejó de estar vigente. No vuelvas a confirmar hasta revisar el resultado.');
        }
        return (int)$previewLog['id'];
    }

    private function payloadFingerprint(array $payload)
    {
        unset($payload['_icylab_payment']);
        if (isset($payload['header']) && is_array($payload['header'])) {
            unset($payload['header']['receiver_email']);
        }
        if (isset($payload['options']) && is_array($payload['options'])) {
            unset($payload['options']['draft_preview'], $payload['options']['draft_only'], $payload['options']['send_email']);
        }
        $payload = $this->sortPayloadRecursively($payload);
        $encoded = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION);
        if ($encoded === false) {
            throw new RuntimeException('No fue posible verificar la integridad del payload tributario.');
        }
        return hash('sha256', $encoded);
    }

    private function sortPayloadRecursively($value)
    {
        // JSON may store an integral float (for example 2.0) as the integer 2.
        // Both values are tributarily identical, so canonicalize them before
        // comparing the approved preview with the payload rebuilt for issuing.
        if (is_float($value) && floor($value) === $value) {
            return (int)$value;
        }
        if (!is_array($value)) {
            return $value;
        }
        $keys = array_keys($value);
        $isList = $keys === range(0, count($keys) - 1);
        if (!$isList) {
            ksort($value);
        }
        foreach ($value as $key => $item) {
            $value[$key] = $this->sortPayloadRecursively($item);
        }
        return $value;
    }

    private function assertNoSuccessfulIssue($idempotencyKey)
    {
        $stmt = $this->pdo->prepare("SELECT id, facto_document_id, document_number FROM billing_issue_logs WHERE idempotency_key = ? AND mode = 'issue' AND status IN ('sent','success','pending_reconcile') LIMIT 1");
        $stmt->execute(array($idempotencyKey));
        $row = $stmt->fetch(PDO::FETCH_ASSOC);
        if ($row) {
            throw new RuntimeException('Ya existe una emision para esta idempotency key. Log #' . $row['id'] . '.');
        }
    }

    /**
     * Segunda barrera de idempotencia basada en el DTE sincronizado. Protege
     * incluso si un error posterior al POST dejo el log como vista previa o
     * error (por ejemplo, un fallo del correo automatico de FACTO).
     */
    private function assertNoMatchingIssuedDocument(array $validation)
    {
        $documentTypeId = (int)($validation['document_type']['facto_document_type_id'] ?? 0);
        $issueDate = trim((string)($validation['payment']['issue_date'] ?? ''));
        $receiverTaxId = str_replace('-', '', billing_clean_rut($validation['customer']['tax_id'] ?? ''));
        $calculation = $validation['calculation'] ?? array();
        $netAmount = (float)($calculation['net_amount'] ?? 0);
        $taxAmount = (float)($calculation['taxes_amount'] ?? 0);
        $totalAmount = (float)($calculation['total_amount'] ?? 0);

        if ($documentTypeId <= 0 || $issueDate === '' || $receiverTaxId === '' || $totalAmount <= 0) {
            return;
        }

        $normalizedRut = "REPLACE(REPLACE(REPLACE(UPPER(receiver_tax_id_code),'.',''),'-',''),' ','')";
        $stmt = $this->pdo->prepare("
            SELECT document_id, document_number, crm_quote_id
            FROM facto_documents
            WHERE environment=?
              AND received_issued_flag=1
              AND document_status IN (1,2,3)
              AND document_type_id=?
              AND issue_date=?
              AND " . $normalizedRut . "=?
              AND ABS(COALESCE(net_amount,0)-?)<=1
              AND ABS(COALESCE(taxes_amount,0)-?)<=1
              AND ABS(COALESCE(total_amount,0)-?)<=1
              AND COALESCE(payment_status,'')<>'anulado'
            ORDER BY id DESC
            LIMIT 3
        ");
        $stmt->execute(array(
            (string)$validation['environment'],
            $documentTypeId,
            $issueDate,
            $receiverTaxId,
            $netAmount,
            $taxAmount,
            $totalAmount,
        ));
        $matches = $stmt->fetchAll(PDO::FETCH_ASSOC);
        if (empty($matches)) {
            return;
        }

        $document = $matches[0];
        $folio = trim((string)($document['document_number'] ?? ''));
        $identity = 'document_id ' . (string)$document['document_id'] . ($folio !== '' ? ', folio ' . $folio : '');
        if ((int)($document['crm_quote_id'] ?? 0) === (int)($validation['quote']['id'] ?? 0)) {
            throw new RuntimeException('Esta cotizacion ya tiene una factura emitida y vinculada (' . $identity . '). No se envio un nuevo DTE.');
        }
        throw new RuntimeException('Ya existe un DTE emitido que coincide exactamente con esta cotizacion (' . $identity . '). Usa Conciliar/vincular; no se envio un nuevo DTE.');
    }

    private function upsertIssueLog(array $validation, array $payload, $status, $responsePayload = null, $httpCode = null, $factoDocumentId = null, $documentNumber = null, $savedFiles = null, $errorCode = null, $errorMessage = null)
    {
        $quote = $validation['quote'];
        $customer = $validation['customer'];
        $documentType = $validation['document_type'];
        $userId = function_exists('current_user_id') ? (int)current_user_id() : 0;
        $pdfPath = is_array($savedFiles) && !empty($savedFiles['pdf_file_path']) ? $savedFiles['pdf_file_path'] : null;
        $xmlPath = is_array($savedFiles) && !empty($savedFiles['xml_file_path']) ? $savedFiles['xml_file_path'] : null;
        $responseEncoded = $responsePayload === null ? null : $this->jsonEncode($responsePayload);

        $stmt = $this->pdo->prepare("\n            INSERT INTO billing_issue_logs\n            (quote_id, opportunity_id, customer_id, document_type_key, document_type_id, environment, mode, status, idempotency_key, request_payload, response_payload, http_code, facto_document_id, document_number, pdf_file_path, xml_file_path, error_code, error_message, created_by)\n            VALUES\n            (?, ?, ?, ?, ?, ?, 'issue', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n            ON DUPLICATE KEY UPDATE\n                mode = VALUES(mode),\n                status = VALUES(status),\n                request_payload = VALUES(request_payload),\n                response_payload = VALUES(response_payload),\n                http_code = VALUES(http_code),\n                facto_document_id = COALESCE(VALUES(facto_document_id), facto_document_id),\n                document_number = COALESCE(VALUES(document_number), document_number),\n                pdf_file_path = COALESCE(VALUES(pdf_file_path), pdf_file_path),\n                xml_file_path = COALESCE(VALUES(xml_file_path), xml_file_path),\n                error_code = VALUES(error_code),\n                error_message = VALUES(error_message),\n                updated_at = CURRENT_TIMESTAMP\n        ");

        $stmt->execute(array(
            (int)$quote['id'],
            isset($quote['opportunity_id']) ? (int)$quote['opportunity_id'] : null,
            isset($customer['id']) ? (int)$customer['id'] : null,
            $documentType['document_key'],
            (int)$documentType['facto_document_type_id'],
            $validation['environment'],
            $status,
            $validation['idempotency_key'],
            $this->jsonEncode($payload),
            $responseEncoded,
            $httpCode,
            $factoDocumentId,
            $documentNumber,
            $pdfPath,
            $xmlPath,
            $errorCode,
            $errorMessage,
            $userId ?: null
        ));

        $stmt = $this->pdo->prepare('SELECT id FROM billing_issue_logs WHERE idempotency_key = ? LIMIT 1');
        $stmt->execute(array($validation['idempotency_key']));
        return (int)$stmt->fetchColumn();
    }

    private function persistFactoDocument(array $validation, array $response, $factoDocumentId, $documentNumber, array $saved, $markAsPaid, $paymentDate)
    {
        $quote = $validation['quote'];
        $customer = $validation['customer'];
        $docType = $validation['document_type'];
        $calc = $validation['calculation'];
        $payment = $validation['payment'];
        $header = $this->extractHeader($response);
        $totals = $this->extractTotals($response);
        $raw = $this->jsonEncode($response);

        $stmt = $this->pdo->prepare("\n            INSERT INTO facto_documents\n            (document_id, environment, account_id, issue_date, document_number, document_type_id, issuer_tax_id_code, receiver_legal_name, receiver_tax_id_code, customer_supplier_id, crm_customer_id, crm_quote_id, crm_opportunity_id, finance_center_id, link_status, link_confidence, linked_by_user_id, linked_at, document_status, currency_id, received_issued_flag, net_amount, taxes_amount, total_amount, payment_status, payment_due_date, payment_paid_date, document_type_taxbureau, taxbureau_sending_status, taxbureau_validation_status, document_sequence_code, pdf_file_path, xml_file_path, raw_payload, payload_hash, last_synced_at)\n            VALUES\n            (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'auto_linked', 100.00, ?, NOW(), ?, ?, 0, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, SHA2(?, 256), NOW())\n            ON DUPLICATE KEY UPDATE\n                environment = VALUES(environment),\n                account_id = VALUES(account_id),\n                issue_date = VALUES(issue_date),\n                document_number = VALUES(document_number),\n                document_type_id = VALUES(document_type_id),\n                issuer_tax_id_code = VALUES(issuer_tax_id_code),\n                receiver_legal_name = VALUES(receiver_legal_name),\n                receiver_tax_id_code = VALUES(receiver_tax_id_code),\n                customer_supplier_id = VALUES(customer_supplier_id),\n                crm_customer_id = VALUES(crm_customer_id),\n                crm_quote_id = VALUES(crm_quote_id),\n                crm_opportunity_id = VALUES(crm_opportunity_id),\n                finance_center_id = VALUES(finance_center_id),\n                link_status = VALUES(link_status),\n                link_confidence = VALUES(link_confidence),\n                linked_by_user_id = VALUES(linked_by_user_id),\n                linked_at = VALUES(linked_at),\n                document_status = VALUES(document_status),\n                currency_id = VALUES(currency_id),\n                received_issued_flag = VALUES(received_issued_flag),\n                net_amount = VALUES(net_amount),\n                taxes_amount = VALUES(taxes_amount),\n                total_amount = VALUES(total_amount),\n                payment_status = VALUES(payment_status),\n                payment_due_date = VALUES(payment_due_date),\n                payment_paid_date = VALUES(payment_paid_date),\n                document_type_taxbureau = VALUES(document_type_taxbureau),\n                taxbureau_sending_status = VALUES(taxbureau_sending_status),\n                taxbureau_validation_status = VALUES(taxbureau_validation_status),\n                document_sequence_code = VALUES(document_sequence_code),\n                pdf_file_path = VALUES(pdf_file_path),\n                xml_file_path = VALUES(xml_file_path),\n                raw_payload = VALUES(raw_payload),\n                payload_hash = VALUES(payload_hash),\n                last_synced_at = NOW()\n        ");

        $userId = function_exists('current_user_id') ? (int)current_user_id() : null;
        $stmt->execute(array(
            $factoDocumentId,
            $validation['environment'],
            $header['account_id'] ?? null,
            $header['issue_date'] ?? $payment['issue_date'],
            $documentNumber,
            $header['document_type_id'] ?? $docType['facto_document_type_id'],
            $header['issuer_tax_id_code'] ?? null,
            $header['receiver_legal_name'] ?? $customer['name'],
            $header['receiver_tax_id_code'] ?? $customer['tax_id'],
            $header['customer_supplier_id'] ?? null,
            $customer['id'],
            $quote['id'],
            $quote['opportunity_id'] ?? null,
            $this->resolveFinanceCenterId($validation),
            $userId ?: null,
            $header['document_status'] ?? 1,
            $header['currency_id'] ?? null,
            $totals['net_amount'] ?? $calc['net_amount'],
            $totals['taxes_amount'] ?? $calc['taxes_amount'],
            $totals['total_amount'] ?? $calc['total_amount'],
            $markAsPaid ? 'pagado' : 'por_cobrar',
            $payment['due_date'] ?? null,
            $markAsPaid ? $paymentDate : null,
            $header['document_type_taxbureau'] ?? $docType['sii_external_code'],
            $header['taxbureau_sending_status'] ?? null,
            $header['taxbureau_validation_status'] ?? null,
            $header['document_sequence_code'] ?? null,
            $saved['pdf_file_path'] ?? null,
            $saved['xml_file_path'] ?? null,
            $raw,
            $raw
        ));

        // Toda factura creada por este servicio es un DTE emitido. La marca
        // debe coincidir con FACTO (1=emitido, 0=recibido) para que la
        // conciliacion y la cobranza nunca la excluyan.
        $this->pdo->prepare('UPDATE facto_documents SET received_issued_flag=1 WHERE environment=? AND BINARY document_id=BINARY ?')
            ->execute(array($validation['environment'], $factoDocumentId));

        if (!empty($docType['is_export'])) {
            $currencyCode = strtoupper((string)($calc['document_currency_code'] ?? 'USD'));
            $rateMeta = $validation['exchange_rate'] ?? array();
            $rateValue = (float)($rateMeta['rate_to_clp'] ?? ($calc['exchange_rate_value'] ?? 0));
            $this->pdo->prepare("UPDATE facto_documents SET currency_code=?, exchange_rate_value=?, exchange_rate_date=?, exchange_rate_source=?, exchange_rate_status=?, receiver_country_id=?, receiver_tax_id_type=?, sale_modality=?, sale_clause=?, source_mode='quote' WHERE environment=? AND document_id=?")
                ->execute(array(
                    $currencyCode, $rateValue,
                    $rateMeta['rate_date'] ?? $payment['issue_date'],
                    $rateMeta['source'] ?? 'FINDIC_BCCH',
                    $rateMeta['quality_status'] ?? 'validated',
                    (int)($header['receiver_country_id'] ?? 0), (string)($header['receiver_tax_id_type'] ?? ''),
                    (int)($header['sale_modality'] ?? 0), (int)($header['sale_clause'] ?? 0),
                    $validation['environment'], $factoDocumentId
                ));
        }
    }

    private function persistDocumentLines(array $validation, $factoDocumentId, array $response)
    {
        $this->pdo->prepare('DELETE FROM billing_document_lines WHERE environment = ? AND facto_document_id = ?')
            ->execute(array($validation['environment'], $factoDocumentId));

        $stmt = $this->pdo->prepare("\n            INSERT INTO billing_document_lines\n            (environment, facto_document_id, quote_id, source_line_id, product_id, sku, description, quantity, unit_measure, unit_price, discount_percent, discount_amount, net_amount, tax_amount, total_amount, tax_treatment, finance_center_id, raw_payload)\n            VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n        ");

        foreach (($validation['calculation']['lines'] ?? array()) as $line) {
            $stmt->execute(array(
                $validation['environment'],
                $factoDocumentId,
                $validation['quote']['id'],
                $line['source_line_id'] ?? null,
                $line['product_id'] ?? null,
                $line['sku'] ?? null,
                $line['description'] ?? '',
                $line['quantity'] ?? 0,
                $line['unit_measure'] ?? 'UN',
                $line['unit_price'] ?? 0,
                $line['discount_percent'] ?? 0,
                ($line['line_discount_amount'] ?? 0) + ($line['global_discount_allocated'] ?? 0),
                $line['net_amount'] ?? 0,
                $line['tax_amount'] ?? 0,
                $line['gross_amount'] ?? 0,
                $line['tax_treatment'] ?? 'afecto',
                $line['finance_center_id'] ?? $this->resolveFinanceCenterId($validation),
                $this->jsonEncode($line)
            ));
        }
    }

    private function persistCollection(array $validation, $factoDocumentId, $documentNumber, $markAsPaid, $paymentDate)
    {
        $calc = $validation['calculation'];
        $payment = $validation['payment'];
        $quote = $validation['quote'];
        $customer = $validation['customer'];
        $isExport = !empty($validation['document_type']['is_export']);
        $baseTotal = $this->baseCollectionAmount($validation);
        $status = $markAsPaid ? 'pagado' : 'por_cobrar';
        $paidAmount = $markAsPaid ? $baseTotal : 0;
        $balance = $markAsPaid ? 0 : $baseTotal;
        $currencyCode = $isExport ? strtoupper((string)($calc['document_currency_code'] ?? 'USD')) : 'CLP';
        $currencyId = $this->currencyIdByCode($currencyCode);
        $exchangeRate = $isExport ? (float)($calc['exchange_rate_value'] ?? 0) : 1.0;
        $foreignTotal = $isExport ? (float)$calc['total_amount'] : $baseTotal;
        $foreignPaid = $markAsPaid ? $foreignTotal : 0;
        $foreignBalance = $markAsPaid ? 0 : $foreignTotal;

        $stmt = $this->pdo->prepare("\n            INSERT INTO billing_collections\n            (environment, direction, source_table, facto_document_id, quote_id, opportunity_id, customer_id, document_number, document_type_id, issue_date, due_date, total_amount, paid_amount, balance_amount, collection_status, payment_terms, last_payment_date, finance_center_id, notes, currency_code, currency_id, exchange_rate_value, exchange_rate_date, exchange_rate_source, exchange_rate_status, foreign_total_amount, foreign_paid_amount, foreign_balance_amount)\n            VALUES (?, 'receivable', 'facto_documents', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n            ON DUPLICATE KEY UPDATE\n                quote_id = VALUES(quote_id),\n                opportunity_id = VALUES(opportunity_id),\n                customer_id = VALUES(customer_id),\n                document_number = VALUES(document_number),\n                document_type_id = VALUES(document_type_id),\n                issue_date = VALUES(issue_date),\n                due_date = VALUES(due_date),\n                total_amount = VALUES(total_amount),\n                paid_amount = VALUES(paid_amount),\n                balance_amount = VALUES(balance_amount),\n                collection_status = VALUES(collection_status),\n                payment_terms = VALUES(payment_terms),\n                last_payment_date = VALUES(last_payment_date),\n                finance_center_id = VALUES(finance_center_id),\n                notes = VALUES(notes),\n                currency_code = VALUES(currency_code),\n                currency_id = VALUES(currency_id),\n                exchange_rate_value = VALUES(exchange_rate_value),\n                exchange_rate_date = VALUES(exchange_rate_date),\n                exchange_rate_source = VALUES(exchange_rate_source),\n                exchange_rate_status = VALUES(exchange_rate_status),\n                foreign_total_amount = VALUES(foreign_total_amount),\n                foreign_paid_amount = VALUES(foreign_paid_amount),\n                foreign_balance_amount = VALUES(foreign_balance_amount),\n                updated_at = CURRENT_TIMESTAMP\n        ");
        $stmt->execute(array(
            $validation['environment'],
            $factoDocumentId,
            $quote['id'],
            $quote['opportunity_id'] ?? null,
            $customer['id'],
            $documentNumber,
            $validation['document_type']['facto_document_type_id'],
            $payment['issue_date'],
            $payment['due_date'],
            $baseTotal,
            $paidAmount,
            $balance,
            $status,
            $payment['payment_conditions'],
            $markAsPaid ? $paymentDate : null,
            $this->resolveFinanceCenterId($validation),
            $isExport ? 'Factura de exportación emitida desde cotización en ICYLAB OS.' : 'Creado automáticamente por emisión ' . $validation['environment'] . ' desde ICYLAB OS.',
            $currencyCode,
            $currencyId,
            $exchangeRate > 0 ? $exchangeRate : 1,
            $isExport ? (($validation['exchange_rate']['rate_date'] ?? null) ?: $payment['issue_date']) : $payment['issue_date'],
            $isExport ? ($validation['exchange_rate']['source'] ?? 'FINDIC_BCCH') : 'CLP',
            $isExport ? ($validation['exchange_rate']['quality_status'] ?? 'validated') : 'validated',
            $foreignTotal,
            $foreignPaid,
            $foreignBalance
        ));

        $stmt = $this->pdo->prepare('SELECT id FROM billing_collections WHERE environment = ? AND direction = ? AND facto_document_id = ? LIMIT 1');
        $stmt->execute(array($validation['environment'], 'receivable', $factoDocumentId));
        return (int)$stmt->fetchColumn();
    }

    private function persistPayment(array $validation, $collectionId, $factoDocumentId, $paymentDate, $paymentMethod, $paymentMethodLabel, $paymentMethodId, $cashAccountId, $cashAccountLabel, $paymentReference, $paymentNotes)
    {
        $cols = array('collection_id', 'environment', 'direction', 'facto_document_id', 'quote_id', 'opportunity_id', 'customer_id', 'payment_date', 'amount', 'payment_method', 'external_reference', 'finance_center_id', 'notes', 'created_by');
        $vals = array(
            $collectionId ?: null,
            $validation['environment'],
            'received',
            $factoDocumentId,
            $validation['quote']['id'],
            $validation['quote']['opportunity_id'] ?? null,
            $validation['customer']['id'],
            $paymentDate,
            $this->baseCollectionAmount($validation),
            $paymentMethodLabel ?: $paymentMethod,
            $paymentReference,
            $this->resolveFinanceCenterId($validation),
            $paymentNotes,
            function_exists('current_user_id') ? (int)current_user_id() : null
        );

        if (billing_table_has_column($this->pdo, 'billing_payments', 'payment_method_id')) {
            $cols[] = 'payment_method_id';
            $vals[] = $paymentMethodId ?: null;
        }
        if (billing_table_has_column($this->pdo, 'billing_payments', 'cash_account_id')) {
            $cols[] = 'cash_account_id';
            $vals[] = $cashAccountId ?: null;
        }
        if (billing_table_has_column($this->pdo, 'billing_payments', 'currency_code')) {
            $calc = $validation['calculation'] ?? array();
            $rateMeta = $validation['exchange_rate'] ?? array();
            $currencyCode = strtoupper(trim((string)($calc['document_currency_code'] ?? 'CLP')));
            if ($currencyCode === '') $currencyCode = 'CLP';
            $exchangeRate = $currencyCode === 'CLP' ? 1.0 : (float)($rateMeta['rate_to_clp'] ?? ($calc['exchange_rate_value'] ?? 0));
            $foreignAmount = ($currencyCode !== 'CLP' && $exchangeRate > 0) ? (float)($calc['total_amount'] ?? 0) : null;
            $cols = array_merge($cols, array('currency_code','exchange_rate_value','exchange_rate_date','exchange_rate_source','exchange_rate_status','foreign_amount'));
            $vals = array_merge($vals, array(
                $currencyCode,
                $exchangeRate > 0 ? $exchangeRate : null,
                $rateMeta['rate_date'] ?? ($validation['payment']['issue_date'] ?? null),
                $rateMeta['source'] ?? ($currencyCode === 'CLP' ? 'CLP' : null),
                $rateMeta['quality_status'] ?? ($currencyCode === 'CLP' ? 'validated' : null),
                $foreignAmount
            ));
        }

        $placeholders = implode(',', array_fill(0, count($cols), '?'));
        $sql = 'INSERT INTO billing_payments (' . implode(',', $cols) . ') VALUES (' . $placeholders . ')';
        $this->pdo->prepare($sql)->execute($vals);
    }

    private function updateQuote(array $validation, $factoDocumentId, $documentNumber, $markAsPaid, $paymentDate, $paymentNotes)
    {
        $quote = $validation['quote'];
        $payment = $validation['payment'];
        $calc = $validation['calculation'];
        $status = $markAsPaid ? 'Pagada' : 'Esperando pago';
        $paymentStatus = $markAsPaid ? 'pagado' : 'por_cobrar';
        $userId = function_exists('current_user_id') ? (int)current_user_id() : null;

        $stmt = $this->pdo->prepare("\n            UPDATE crm_quotes SET\n                status = ?,\n                invoice_number = ?,\n                invoice_issue_date = ?,\n                facto_document_id = ?,\n                billing_document_type = ?,\n                billing_document_type_id = ?,\n                billing_environment = ?,\n                payment_status = ?,\n                payment_terms_selected = ?,\n                payment_due_date = ?,\n                payment_paid_date = ?,\n                payment_amount = ?,\n                payment_notes = ?,\n                billing_match_status = 'matched',\n                facto_linked_at = NOW(),\n                facto_linked_by = ?,\n                emitted_by_user_id = ?,\n                emitted_at = NOW(),\n                finance_center_id = ?\n            WHERE id = ?\n        ");
        $stmt->execute(array(
            $status,
            $documentNumber,
            $payment['issue_date'],
            $factoDocumentId,
            $validation['document_type']['document_key'],
            $validation['document_type']['facto_document_type_id'],
            $validation['environment'],
            $paymentStatus,
            $payment['payment_conditions'],
            $payment['due_date'],
            $markAsPaid ? $paymentDate : null,
            $markAsPaid ? $this->baseCollectionAmount($validation) : null,
            $paymentNotes,
            $userId ?: null,
            $userId ?: null,
            $this->resolveFinanceCenterId($validation),
            $quote['id']
        ));

        if (!empty($validation['document_type']['is_export'])) {
            $rateMeta = $validation['exchange_rate'] ?? array();
            $this->pdo->prepare("UPDATE crm_quotes SET billing_currency_code=?, billing_exchange_rate_value=?, billing_exchange_rate_date=?, billing_exchange_rate_source=?, billing_exchange_rate_status=?, billing_receiver_country_id=?, billing_sale_modality=?, billing_sale_clause=? WHERE id=?")
                ->execute(array(
                    strtoupper((string)($calc['document_currency_code'] ?? 'USD')),
                    (float)($rateMeta['rate_to_clp'] ?? ($calc['exchange_rate_value'] ?? 0)),
                    $rateMeta['rate_date'] ?? $payment['issue_date'],
                    $rateMeta['source'] ?? 'FINDIC_BCCH',
                    $rateMeta['quality_status'] ?? 'validated',
                    (int)($validation['payload_preview']['header']['receiver_country_id'] ?? 0),
                    (int)($validation['payload_preview']['header']['sale_modality'] ?? 0),
                    (int)($validation['payload_preview']['header']['sale_clause'] ?? 0),
                    $quote['id']
                ));
        }
    }

    private function updateOpportunity(array $validation, $factoDocumentId, $markAsPaid, $paymentDate)
    {
        $opportunityId = isset($validation['quote']['opportunity_id']) ? (int)$validation['quote']['opportunity_id'] : 0;
        if ($opportunityId <= 0) return;

        $select = $this->pdo->prepare("SELECT * FROM crm_opportunities WHERE id=? AND deleted_at IS NULL LIMIT 1 FOR UPDATE");
        $select->execute(array($opportunityId));
        $old = $select->fetch(PDO::FETCH_ASSOC);
        if (!$old) return;

        $isArchived = !empty($old['archived_at']);
        $sql = "UPDATE crm_opportunities SET
                    payment_status = ?, payment_due_date = ?, payment_paid_date = ?,
                    facto_document_id = ?, finance_center_id = ?, updated_at = NOW()";
        if (!$isArchived) {
            $sql .= ", stage = 'facturada', probability = 100,
                       commercial_closed_at = COALESCE(commercial_closed_at, NOW()),
                       closed_at = COALESCE(closed_at, NOW()),
                       closure_outcome = 'invoiced',
                       closure_reason = COALESCE(NULLIF(closure_reason,''), 'DTE emitido desde ICYLAB OS')";
        }
        $sql .= " WHERE id = ?";
        $stmt = $this->pdo->prepare($sql);
        $stmt->execute(array(
            $markAsPaid ? 'pagado' : 'por_cobrar',
            $validation['payment']['due_date'] ?? null,
            $markAsPaid ? $paymentDate : null,
            $factoDocumentId,
            $this->resolveFinanceCenterId($validation),
            $opportunityId
        ));

        if (!$isArchived && (string)($old['stage'] ?? '') !== 'facturada') {
            $userId = function_exists('current_user_id') ? (int)current_user_id() : null;
            $userName = function_exists('current_user') ? (string)((current_user()['name'] ?? current_user()['email'] ?? 'Sistema')) : 'Sistema';
            OpportunityStageHistoryService::record($this->pdo, $opportunityId, $old['stage'] ?? null, 'facturada', $userId ?: null, $userName, 'system');
            OpportunityStageHistoryService::lifecycle($this->pdo, $opportunityId, 'billing_issue', array('stage' => $old['stage'] ?? null, 'archived_at' => $old['archived_at'] ?? null), array('stage' => 'facturada', 'facto_document_id' => $factoDocumentId), $userId ?: null, $userName, 'billing');
        }
    }

    private function insertCrmDocument(array $validation, $factoDocumentId, $documentNumber, array $saved)
    {
        $cols = array('opportunity_id', 'customer_id', 'document_type', 'document_name', 'document_number', 'document_date', 'net_amount', 'total_amount', 'file_path', 'facto_document_id', 'notes');
        $vals = array(
            $validation['quote']['opportunity_id'] ?? null,
            $validation['customer']['id'],
            'factura',
            'Factura FACTO ' . ($documentNumber ?: $factoDocumentId),
            $documentNumber,
            $validation['payment']['issue_date'],
            $validation['calculation']['net_amount'],
            $validation['calculation']['total_amount'],
            $saved['pdf_file_path'] ?? null,
            $factoDocumentId,
            'Documento emitido desde ICYLAB OS en ' . $validation['environment'] . '. Cotizacion ' . $validation['quote']['quote_number'] . '.'
        );

        if (billing_table_has_column($this->pdo, 'crm_documents', 'quote_id')) {
            $cols[] = 'quote_id';
            $vals[] = $validation['quote']['id'];
        }
        if (billing_table_has_column($this->pdo, 'crm_documents', 'link_status')) {
            $cols[] = 'link_status';
            $vals[] = 'auto';
        }
        if (billing_table_has_column($this->pdo, 'crm_documents', 'finance_center_id')) {
            $cols[] = 'finance_center_id';
            $vals[] = $this->resolveFinanceCenterId($validation);
        }

        $placeholders = implode(',', array_fill(0, count($cols), '?'));
        $sql = 'INSERT INTO crm_documents (' . implode(',', $cols) . ') VALUES (' . $placeholders . ')';
        $this->pdo->prepare($sql)->execute($vals);
    }

    private function insertActivity(array $validation, $factoDocumentId, $documentNumber, $markAsPaid)
    {
        if (!billing_table_has_column($this->pdo, 'crm_activities', 'title')) {
            return;
        }
        $quote = $validation['quote'];
        $customer = $validation['customer'];
        $title = 'Documento tributario emitido (' . $quote['quote_number'] . ')';
        $description = 'Factura ' . $validation['environment'] . " emitida desde ICYLAB OS.\n" .
            'FACTO document_id: ' . $factoDocumentId . "\n" .
            'Folio: ' . ($documentNumber ?: 'no informado') . "\n" .
            'Total: $' . number_format((float)$validation['calculation']['total_amount'], 0, ',', '.') . "\n" .
            'Cobranza: ' . ($markAsPaid ? 'pagada' : 'por cobrar') . '.';

        $stmt = $this->pdo->prepare("\n            INSERT INTO crm_activities\n            (opportunity_id, customer_id, customer_name, activity_type, title, description, activity_date, owner_name)\n            VALUES (?, ?, ?, 'factura', ?, ?, NOW(), ?)\n        ");
        $stmt->execute(array(
            $quote['opportunity_id'] ?? null,
            $customer['id'],
            $customer['name'],
            $title,
            $description,
            $this->currentOwnerName()
        ));
    }

    private function notifyIssue(array $validation, $factoDocumentId, $documentNumber, $markAsPaid)
    {
        $servicePath = __DIR__ . '/../notifications/NotificationService.php';
        if (!is_file($servicePath)) {
            return;
        }
        require_once $servicePath;
        if (!class_exists('NotificationService')) {
            return;
        }
        try {
            $notifier = new NotificationService($this->pdo);
            $notifier->notify($markAsPaid ? 'billing.paid' : 'billing.por_cobrar', array(
                'module' => 'billing',
                'priority' => $markAsPaid ? 'success' : 'info',
                'title' => 'Factura emitida desde ICYLAB OS',
                'body' => 'Cotizacion ' . $validation['quote']['quote_number'] . ' emitida en ' . $validation['environment'] . '. Folio ' . ($documentNumber ?: '-') . ', FACTO document_id ' . $factoDocumentId . '.',
                'url' => '/modules/billing/',
                'related_type' => 'quote',
                'related_id' => $validation['quote']['id'],
                'actor_user_id' => function_exists('current_user_id') ? (int)current_user_id() : null,
                'dedupe_key' => 'billing.issue:' . $validation['environment'] . ':' . $factoDocumentId
            ));
        } catch (Throwable $ignored) {
        }
    }

    private function resolveFinanceCenterId(array $validation)
    {
        if (!empty($validation['quote']['finance_center_id'])) return (int)$validation['quote']['finance_center_id'];
        if (!empty($validation['customer']['default_income_center_id'])) return (int)$validation['customer']['default_income_center_id'];
        $stmt = $this->pdo->query("SELECT id FROM finance_centers WHERE center_code = 'NO_INCOME_CENTER' LIMIT 1");
        $id = $stmt ? (int)$stmt->fetchColumn() : 0;
        if ($id > 0) return $id;
        $stmt = $this->pdo->query("SELECT id FROM finance_centers WHERE is_default = 1 ORDER BY id ASC LIMIT 1");
        return $stmt ? ((int)$stmt->fetchColumn() ?: null) : null;
    }

    private function saveIssueFiles(array $electronicDocument, $environment, $fileBase)
    {
        $saved = array('pdf_file_path' => null, 'xml_file_path' => null);
        if (!empty($electronicDocument['document_pdf'])) {
            $saved['pdf_file_path'] = $this->storage->saveBase64File($electronicDocument['document_pdf'], $environment, 'issued_pdf', $fileBase . '.pdf');
        }
        if (!empty($electronicDocument['document_xml'])) {
            $saved['xml_file_path'] = $this->storage->saveBase64File($electronicDocument['document_xml'], $environment, 'issued_xml', $fileBase . '.xml');
        }
        return $saved;
    }

    private function extractElectronicDocument($response)
    {
        if (!is_array($response)) return array();
        if (isset($response['electronic_document']) && is_array($response['electronic_document'])) return $response['electronic_document'];
        if (isset($response['document']['electronic_document']) && is_array($response['document']['electronic_document'])) return $response['document']['electronic_document'];
        $doc = array();
        foreach (array('document_pdf','document_xml','stamp_img','document_transfer_copy_pdf') as $key) {
            if (!empty($response[$key])) $doc[$key] = $response[$key];
        }
        return $doc;
    }

    private function extractDocumentId($response)
    {
        if (!is_array($response)) return null;
        if (!empty($response['document_id'])) return (string)$response['document_id'];
        if (!empty($response['header']['document_id'])) return (string)$response['header']['document_id'];
        if (!empty($response['document']['document_id'])) return (string)$response['document']['document_id'];
        return null;
    }

    private function extractDocumentNumber($response)
    {
        if (!is_array($response)) return null;
        if (!empty($response['document_number'])) return (string)$response['document_number'];
        if (!empty($response['header']['document_number'])) return (string)$response['header']['document_number'];
        if (!empty($response['document']['header']['document_number'])) return (string)$response['document']['header']['document_number'];
        return null;
    }

    private function extractHeader($response)
    {
        if (!is_array($response)) return array();
        if (isset($response['header']) && is_array($response['header'])) return $response['header'];
        if (isset($response['document']['header']) && is_array($response['document']['header'])) return $response['document']['header'];
        return array();
    }

    private function extractTotals($response)
    {
        if (!is_array($response)) return array();
        if (isset($response['totals']) && is_array($response['totals'])) return $response['totals'];
        if (isset($response['document']['totals']) && is_array($response['document']['totals'])) return $response['document']['totals'];
        return array();
    }

    private function extractBusinessError($response)
    {
        if (!is_array($response)) return null;
        if (!empty($response['result']['error_message'])) return (string)$response['result']['error_message'];
        if (!empty($response['error_message'])) return (string)$response['error_message'];
        return null;
    }

    private function responseSummary($response)
    {
        return array(
            'document_id' => $this->extractDocumentId($response),
            'document_number' => $this->extractDocumentNumber($response),
            'has_electronic_document' => !empty($this->extractElectronicDocument($response)),
            'result_status' => is_array($response) && isset($response['result']['status']) ? $response['result']['status'] : null,
            'result_error_message' => is_array($response) && isset($response['result']['error_message']) ? $response['result']['error_message'] : null,
            'top_level_keys' => is_array($response) ? array_keys($response) : array()
        );
    }

    private function issueFileBaseName(array $validation, $factoDocumentId, $logId)
    {
        $quoteNumber = preg_replace('/[^A-Za-z0-9_\-]/', '_', (string)$validation['quote']['quote_number']);
        return 'issue_' . $quoteNumber . '_facto_' . preg_replace('/[^A-Za-z0-9_\-]/', '_', (string)$factoDocumentId) . '_log_' . (int)$logId;
    }

    private function baseCollectionAmount(array $validation)
    {
        $calc = $validation['calculation'];
        return !empty($validation['document_type']['is_export'])
            ? billing_money($calc['clp_total_amount'] ?? ((float)$calc['total_amount'] * (float)($calc['exchange_rate_value'] ?? 0)))
            : billing_money($calc['total_amount']);
    }

    private function currencyIdByCode($currencyCode)
    {
        $stmt = $this->pdo->prepare('SELECT facto_currency_id FROM billing_currencies WHERE currency_code=? AND is_active=1 LIMIT 1');
        $stmt->execute(array(strtoupper(trim((string)$currencyCode))));
        return (int)($stmt->fetchColumn() ?: 39);
    }

    private function cleanDate($value)
    {
        $ts = strtotime((string)$value);
        return $ts ? date('Y-m-d', $ts) : date('Y-m-d');
    }

    private function currentOwnerName()
    {
        if (function_exists('current_user_name')) {
            $name = trim((string)current_user_name());
            if ($name !== '') return $name;
        }
        if (!empty($_SESSION['user']['name'])) return (string)$_SESSION['user']['name'];
        if (!empty($_SESSION['user_name'])) return (string)$_SESSION['user_name'];
        return 'Sistema';
    }

    private function jsonEncode($value)
    {
        return json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION);
    }
}
