<?php

require_once __DIR__ . '/../../core/database.php';
require_once __DIR__ . '/BillingPaymentOptionsService.php';
require_once __DIR__ . '/BillingSupplierResolverService.php';
require_once __DIR__ . '/BillingRuntimeGate.php';

class BillingPayablesService
{
    private $pdo;

    public function __construct(?PDO $pdo = null)
    {
        $this->pdo = $pdo ?: db();
    }

    private function isPayableDocumentType($documentTypeId)
    {
        return in_array((int)$documentTypeId, array(9, 33), true);
    }

    public function refreshAgingState($environment = 'sandbox')
    {
        $environment = $this->normalizeEnvironment($environment);
        billing_assert_runtime_operation('finance', $environment, 'Actualización de aging de proveedores');
        $this->resolveSupplierDefaults($environment, 500);
        $this->refreshCalculatedPayableStatuses($environment);
        $this->refreshOverdue($environment);
    }

    public function listPayables(array $filters = array())
    {
        $environment = $this->normalizeEnvironment(isset($filters['environment']) ? $filters['environment'] : (defined('FACTO_DEFAULT_ENVIRONMENT') ? FACTO_DEFAULT_ENVIRONMENT : 'production'));
        billing_assert_runtime_operation('read', $environment, 'Listado de cuentas por pagar');
        $status = isset($filters['status']) ? trim((string)$filters['status']) : 'open';
        $search = isset($filters['search']) ? trim((string)$filters['search']) : '';
        $dateFrom = isset($filters['date_from']) ? trim((string)$filters['date_from']) : '';
        $dateTo = isset($filters['date_to']) ? trim((string)$filters['date_to']) : '';
        $limit = isset($filters['limit']) ? max(1, min(1000, (int)$filters['limit'])) : 1000;
        $effectiveStatusSql = $this->effectivePayableStatusSql();
        $financialTypeSql = "CAST(COALESCE(d.document_type_id,0) AS UNSIGNED) IN (9,33)";
        $approvedTypeSql = $this->approvedPayableStatusSql('d');

        // La vista de CxP sólo contiene documentos que pueden originar una
        // cuenta: factura afecta (FACTO 9) y factura exenta (FACTO 33).
        $where = array('d.environment = ?', $financialTypeSql);
        $params = array($environment);
        if ($status !== '' && $status !== 'all') {
            if (in_array($status, array('open','impago','pendiente'), true)) {
                $where[] = "(c.id IS NOT NULL AND (" . $effectiveStatusSql . ") IN ('por_pagar','vencido','parcial'))";
            } elseif ($status === 'pending_approval') {
                $where[] = "(" . $financialTypeSql . " AND c.id IS NULL AND NOT (" . $approvedTypeSql . "))";
            } elseif ($status === 'pending_cxp') {
                $where[] = "(" . $financialTypeSql . " AND c.id IS NULL AND (" . $approvedTypeSql . "))";
            } else {
                $where[] = "(" . $financialTypeSql . " AND c.id IS NOT NULL AND (" . $effectiveStatusSql . ") = ?)";
                $params[] = $status;
            }
        }
        if ($dateFrom !== '') { $where[] = 'd.document_date >= ?'; $params[] = $dateFrom; }
        if ($dateTo !== '') { $where[] = 'd.document_date <= ?'; $params[] = $dateTo; }
        if ($search !== '') {
            $where[] = '(d.issuer_name LIKE ? OR d.issuer_tax_id_code LIKE ? OR d.document_number LIKE ?)';
            $like = '%' . $search . '%'; array_push($params, $like, $like, $like);
        }

        $sql = "
            SELECT d.id,d.environment,d.inbox_document_id,d.document_id,d.document_date,d.receive_date,
                d.issuer_name,d.issuer_tax_id_code,d.document_type_id,d.document_type_taxbureau,d.document_number,
                d.net_amount,d.taxes_amount,d.total_amount,d.supplier_customer_id,d.finance_center_id,
                COALESCE(bdt.document_label, CONCAT('DTE ',COALESCE(d.document_type_taxbureau,d.document_type_id))) AS document_label,
                COALESCE(bdt.document_family,'other') AS document_family,bdt.document_key,
                CASE WHEN " . $financialTypeSql . " THEN 1 ELSE 0 END AS is_financial_document,
                CASE WHEN CAST(COALESCE(d.document_type_id,0) AS UNSIGNED)=33 THEN 1 ELSE 0 END AS is_tax_exempt,
                CASE WHEN CAST(COALESCE(d.document_type_id,0) AS UNSIGNED)=33 THEN 'invoice_exempt' ELSE 'invoice_affected' END AS account_document_group,
                CASE WHEN " . $approvedTypeSql . " THEN 1 ELSE 0 END AS is_approved_for_payable,
                COALESCE(d.finance_center_id,s.supplier_default_cost_center_id) AS effective_finance_center_id,
                CASE WHEN d.finance_center_id IS NOT NULL THEN 'document_override' WHEN s.supplier_default_cost_center_id IS NOT NULL THEN 'supplier_default' ELSE 'none' END AS finance_center_source,
                CASE
                    WHEN NOT (" . $financialTypeSql . ") THEN 'no_aplica'
                    WHEN c.id IS NULL AND (" . $approvedTypeSql . ") THEN 'pendiente_cxp'
                    WHEN c.id IS NULL THEN 'pendiente_aprobacion'
                    ELSE " . $effectiveStatusSql . "
                END AS payment_status,
                d.payment_due_date,d.payment_paid_date,d.payment_terms_id,d.supplier_resolution_status,
                d.supplier_resolution_notes,d.supplier_resolved_at,d.document_approval_status,
                d.reception_confirmation_status,d.product_receipt_status,d.pdf_file_path,d.xml_file_path,
                s.customer_name AS supplier_name,s.customer_tax_id AS supplier_tax_id,s.is_supplier,
                s.supplier_default_cost_center_id,s.supplier_payment_terms_id,
                pt.term_label AS supplier_payment_term_label,pt.days AS supplier_payment_term_days,
                COALESCE(fc_doc.center_name,fc_supplier.center_name) AS finance_center_name,
                c.id AS collection_id,c.collection_status,c.due_date,c.total_amount AS collection_total,
                COALESCE(c.paid_amount,0) AS paid_amount,
                CASE WHEN " . $financialTypeSql . " AND c.id IS NOT NULL THEN c.balance_amount ELSE 0 END AS balance_amount,
                c.last_payment_date
            FROM facto_inbox_documents d
            LEFT JOIN billing_document_types bdt ON bdt.direction='received' AND bdt.facto_document_type_id=CAST(d.document_type_id AS UNSIGNED)
            LEFT JOIN crm_customers s ON s.id=d.supplier_customer_id
            LEFT JOIN finance_centers fc_doc ON fc_doc.id=d.finance_center_id
            LEFT JOIN finance_centers fc_supplier ON fc_supplier.id=s.supplier_default_cost_center_id
            LEFT JOIN billing_payment_terms pt ON pt.id=COALESCE(d.payment_terms_id,s.supplier_payment_terms_id)
            LEFT JOIN billing_collections c ON c.direction='payable' AND c.environment=d.environment AND c.inbox_document_id=d.inbox_document_id
            WHERE " . implode(' AND ', $where) . "
            ORDER BY FIELD(COALESCE(bdt.document_family,'other'),'invoice','export','credit_note','debit_note','dispatch_guide','receipt','other'),
                     COALESCE(c.due_date,d.payment_due_date,d.document_date) DESC,d.id DESC
            LIMIT " . (int)$limit;
        $stmt=$this->pdo->prepare($sql); $stmt->execute($params); $rows=$stmt->fetchAll(PDO::FETCH_ASSOC);
        return array('kpis'=>$this->getKpis($environment),'rows'=>$rows,'filters'=>array('environment'=>$environment,'status'=>$status,'search'=>$search,'date_from'=>$dateFrom,'date_to'=>$dateTo,'limit'=>$limit));
    }

    public function getKpis($environment = 'sandbox')
    {
        $environment = $this->normalizeEnvironment($environment);
        billing_assert_runtime_operation('read', $environment, 'KPIs de cuentas por pagar');
        $effectiveStatusSql = $this->effectivePayableStatusSql();
        $sql = "
            SELECT COUNT(*) AS documents_count,COALESCE(SUM(d.total_amount),0) AS total_received,
                COALESCE(SUM(CASE WHEN c.id IS NOT NULL AND " . $effectiveStatusSql . " IN ('por_pagar','vencido','parcial') THEN c.balance_amount ELSE 0 END),0) AS open_balance,
                COALESCE(SUM(CASE WHEN c.id IS NOT NULL AND " . $effectiveStatusSql . "='vencido' THEN c.balance_amount ELSE 0 END),0) AS overdue_balance,
                COALESCE(SUM(CASE WHEN d.supplier_customer_id IS NULL THEN 1 ELSE 0 END),0) AS unlinked_suppliers,
                COALESCE(SUM(CASE WHEN COALESCE(d.finance_center_id,s.supplier_default_cost_center_id) IS NULL THEN 1 ELSE 0 END),0) AS without_center
            FROM facto_inbox_documents d
            LEFT JOIN crm_customers s ON s.id=d.supplier_customer_id
            LEFT JOIN billing_collections c ON c.direction='payable' AND c.environment=d.environment AND c.inbox_document_id=d.inbox_document_id
            WHERE d.environment=? AND CAST(COALESCE(d.document_type_id,0) AS UNSIGNED) IN (9,33)";
        $stmt=$this->pdo->prepare($sql); $stmt->execute(array($environment)); return $stmt->fetch(PDO::FETCH_ASSOC) ?: array();
    }

    public function getDocument($idOrInboxId, $environment = 'sandbox')
    {
        $environment = $this->normalizeEnvironment($environment);
        billing_assert_runtime_operation('read', $environment, 'Detalle de cuenta por pagar');

        // IMPORTANTE: las vistas de Cuentas por pagar envian d.id (PK local).
        // El inbox_document_id de FACTO puede coincidir numericamente con el id local de otro DTE.
        // Por eso se busca primero por PK local y solo si no existe se usa inbox_document_id.
        $row = $this->fetchDocumentByCondition('d.id = ?', array((string)$idOrInboxId), $environment);
        if (!$row) {
            $row = $this->fetchDocumentByCondition('d.inbox_document_id = ?', array((string)$idOrInboxId), $environment);
        }

        if (!$row) {
            throw new Exception('Documento recibido no encontrado.');
        }

        // v6.12: abrir el detalle es una lectura pura. La resolucion de proveedor,
        // el recalculo persistente y la materializacion de CxP se ejecutan solo
        // mediante acciones financieras explicitas.

        return $row;
    }

    private function getDocumentByInboxId($inboxDocumentId, $environment = 'sandbox')
    {
        $environment = $this->normalizeEnvironment($environment);
        $row = $this->fetchDocumentByCondition('d.inbox_document_id = ?', array((string)$inboxDocumentId), $environment);
        if (!$row) {
            throw new Exception('Documento recibido no encontrado por inbox_document_id.');
        }
        return $row;
    }

    private function fetchDocumentByCondition($condition, array $params, $environment)
    {
        $financialTypeSql = "CAST(COALESCE(d.document_type_id,0) AS UNSIGNED) IN (9,33)";
        $approvedTypeSql = $this->approvedPayableStatusSql('d');
        $sql = "SELECT d.*,
                   COALESCE(bdt.document_label,CONCAT('DTE ',COALESCE(d.document_type_taxbureau,d.document_type_id))) AS document_label,
                   COALESCE(bdt.document_family,'other') AS document_family,bdt.document_key,
                   CASE WHEN " . $financialTypeSql . " THEN 1 ELSE 0 END AS is_financial_document,
                   CASE WHEN " . $approvedTypeSql . " THEN 1 ELSE 0 END AS is_approved_for_payable,
                   s.customer_name AS supplier_name,s.customer_tax_id AS supplier_tax_id,s.is_supplier,
                   s.supplier_default_cost_center_id,s.supplier_payment_terms_id,
                   COALESCE(d.finance_center_id,s.supplier_default_cost_center_id) AS effective_finance_center_id,
                   CASE WHEN d.finance_center_id IS NOT NULL THEN 'document_override' WHEN s.supplier_default_cost_center_id IS NOT NULL THEN 'supplier_default' ELSE 'none' END AS finance_center_source,
                   pt.term_label AS supplier_payment_term_label,pt.days AS supplier_payment_term_days,
                   COALESCE(fc_doc.center_name,fc_supplier.center_name) AS finance_center_name,
                   c.id AS collection_id,c.collection_status,c.due_date,c.total_amount AS collection_total,
                   COALESCE(c.paid_amount,0) AS paid_amount,
                   CASE WHEN " . $financialTypeSql . " AND c.id IS NOT NULL THEN c.balance_amount ELSE 0 END AS balance_amount,
                   c.last_payment_date,c.notes AS collection_notes
            FROM facto_inbox_documents d
            LEFT JOIN billing_document_types bdt ON bdt.direction='received' AND bdt.facto_document_type_id=CAST(d.document_type_id AS UNSIGNED)
            LEFT JOIN crm_customers s ON s.id=d.supplier_customer_id
            LEFT JOIN finance_centers fc_doc ON fc_doc.id=d.finance_center_id
            LEFT JOIN finance_centers fc_supplier ON fc_supplier.id=s.supplier_default_cost_center_id
            LEFT JOIN billing_payment_terms pt ON pt.id=COALESCE(d.payment_terms_id,s.supplier_payment_terms_id)
            LEFT JOIN billing_collections c ON c.direction='payable' AND c.environment=d.environment AND c.inbox_document_id=d.inbox_document_id
            WHERE d.environment=? AND " . $condition . " LIMIT 1";
        $stmt=$this->pdo->prepare($sql); $stmt->execute(array_merge(array($environment),$params)); $row=$stmt->fetch(PDO::FETCH_ASSOC);
        if ($row) { $row['payment_status'] = !empty($row['is_financial_document']) ? $this->deriveEffectivePayableStatus($row) : 'no_aplica'; }
        return $row;
    }

    public function getPaymentHistory($idOrInboxId, $environment = 'sandbox')
    {
        $environment = $this->normalizeEnvironment($environment);
        $doc = $this->getDocument($idOrInboxId, $environment);
        if (!$doc['collection_id']) {
            return array();
        }

        $stmt = $this->pdo->prepare(" 
            SELECT p.id,
                   p.payment_date,
                   p.amount,
                   p.payment_method,
                   p.payment_method_id,
                   p.cash_account_id,
                   p.external_reference,
                   p.notes,
                   p.created_at,
                   pm.method_name,
                   ca.account_name,
                   b.id AS batch_id,
                   b.amount_total AS batch_total,
                   b.external_reference AS batch_reference
            FROM billing_payments p
            LEFT JOIN billing_payment_methods pm ON pm.id = p.payment_method_id
            LEFT JOIN billing_cash_accounts ca ON ca.id = p.cash_account_id
            LEFT JOIN billing_payment_batches b ON b.id = p.payment_batch_id
            WHERE p.collection_id = ? AND p.direction = 'paid' AND COALESCE(p.is_voided,0)=0
            ORDER BY p.payment_date DESC, p.id DESC
        ");
        $stmt->execute(array((int)$doc['collection_id']));
        return $stmt->fetchAll(PDO::FETCH_ASSOC);
    }

    public function getOptions($environment = 'sandbox')
    {
        $environment = $this->normalizeEnvironment($environment);
        $paymentOptions = new BillingPaymentOptionsService($this->pdo);

        return array(
            'environment' => $environment,
            'finance_centers' => $this->listFinanceCenters(),
            'payment_methods' => $paymentOptions->listPaymentMethods(),
            'cash_accounts' => $paymentOptions->listCashAccounts(),
            'payment_terms' => (new BillingSupplierResolverService($this->pdo))->listPaymentTerms(),
            'statuses' => array(
                array('value' => 'open', 'label' => 'Impagos / parciales'),
                array('value' => 'por_pagar', 'label' => 'Por pagar'),
                array('value' => 'vencido', 'label' => 'Vencido'),
                array('value' => 'parcial', 'label' => 'Parcial'),
                array('value' => 'pagado', 'label' => 'Pagado'),
                array('value' => 'rechazado', 'label' => 'Rechazado'),
                array('value' => 'anulado', 'label' => 'Anulado')
            )
        );
    }

    public function updateDocument($idOrInboxId, array $data, $userId = null)
    {
        $environment = $this->normalizeEnvironment(isset($data['environment']) ? $data['environment'] : 'sandbox');
        billing_assert_runtime_operation('finance', $environment, 'Actualización de cuenta por pagar');
        $doc = $this->getDocument($idOrInboxId, $environment);
        if (!$this->isPayableDocumentType($doc['document_type_id'])) { throw new Exception('Solo las facturas recibidas generan cuentas por pagar.'); }
        $financeCenterId = $this->nullableInt(isset($data['finance_center_id']) ? $data['finance_center_id'] : null);
        $dueDate = $this->resolveCalculatedDueDate($doc);
        $status = $this->calculateStatusFromAmounts($doc, $dueDate);
        $notes = isset($data['notes']) ? trim((string)$data['notes']) : null;
        $materializeCollection = !empty($data['materialize_collection']);
        $existingCollection = $this->getPayableCollectionByInboxId((string)$doc['inbox_document_id'], $environment);
        $expectedInboxDocumentId = isset($data['expected_inbox_document_id']) ? trim((string)$data['expected_inbox_document_id']) : '';

        if ($expectedInboxDocumentId !== '' && $expectedInboxDocumentId !== (string)$doc['inbox_document_id']) {
            throw new Exception('La identidad del DTE cambió desde que se abrió el documento. Actualiza y vuelve a intentar.');
        }

        $effectiveCenterId = $financeCenterId !== null
            ? $financeCenterId
            : $this->nullableInt(isset($doc['supplier_default_cost_center_id']) ? $doc['supplier_default_cost_center_id'] : null);

        if ($materializeCollection && empty($existingCollection)) {
            if (!$this->isApprovedPayableDocument($doc)) {
                throw new Exception('La factura debe estar aprobada antes de crear la cuenta por pagar.');
            }
            if (empty($doc['supplier_customer_id'])) {
                throw new Exception('Vincula un proveedor antes de crear la cuenta por pagar.');
            }
            if ($effectiveCenterId === null) {
                throw new Exception('Selecciona un centro de costo antes de crear la cuenta por pagar.');
            }
            if (empty($doc['payment_terms_id']) && empty($doc['supplier_payment_terms_id'])) {
                throw new Exception('Define una condición de pago para el proveedor antes de crear la cuenta por pagar.');
            }
            if (!$dueDate) {
                throw new Exception('No fue posible calcular la fecha de vencimiento de la cuenta por pagar.');
            }
        }

        // Guardar ajustes del DTE es una acción explícita, pero no materializa CxP
        // salvo que el usuario envíe materialize_collection=1.
        $stmt = $this->pdo->prepare(" 
            UPDATE facto_inbox_documents
            SET finance_center_id = ?, payment_due_date = ?, payment_status = ?, updated_at = CURRENT_TIMESTAMP
            WHERE id = ?
        ");
        $stmt->execute(array($financeCenterId, $dueDate, $status, $doc['id']));

        $collection = null;
        if ($existingCollection || $materializeCollection) {
            $collection = $this->ensurePayableCollection($doc['inbox_document_id'], $environment, array(
                'finance_center_id' => $effectiveCenterId,
                'due_date' => $dueDate,
                'collection_status' => $status,
                'notes' => $notes
            ));
        }

        return array(
            'document' => $this->getDocument($doc['id'], $environment),
            'collection' => $collection,
            'materialized' => $collection !== null
        );
    }

    public function autoLinkSupplier($idOrInboxId, $environment = 'sandbox')
    {
        $environment = $this->normalizeEnvironment($environment);
        billing_assert_runtime_operation('finance', $environment, 'Vinculación de proveedor');
        $doc = $this->getDocument($idOrInboxId, $environment);
        $rut = trim((string)$doc['issuer_tax_id_code']);
        if ($rut === '') {
            throw new Exception('El documento recibido no tiene RUT de emisor para homologar proveedor.');
        }

        $stmt = $this->pdo->prepare(" 
            SELECT id, customer_name, customer_tax_id
            FROM crm_customers
            WHERE deleted_at IS NULL
              AND UPPER(REPLACE(customer_tax_id, '.', '')) = UPPER(REPLACE(?, '.', ''))
            LIMIT 1
        ");
        $stmt->execute(array($rut));
        $supplier = $stmt->fetch(PDO::FETCH_ASSOC);
        if (!$supplier) {
            throw new Exception('No existe proveedor/cliente homologable con RUT ' . $rut . '. Crear o sincronizar proveedor antes de vincular.');
        }

        $stmt = $this->pdo->prepare('UPDATE facto_inbox_documents SET supplier_customer_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?');
        $stmt->execute(array((int)$supplier['id'], (int)$doc['id']));

        // v6.14: vincular proveedor no crea una CxP implícitamente.
        return array(
            'supplier' => $supplier,
            'document' => $this->getDocument($doc['id'], $environment)
        );
    }
    public function registerPayment($idOrInboxId, array $data, $userId = null)
    {
        $environment = $this->normalizeEnvironment(isset($data['environment']) ? $data['environment'] : 'sandbox');
        billing_assert_runtime_operation('finance', $environment, 'Registro de pago a proveedor');
        $doc = $this->getDocument($idOrInboxId, $environment);
        if (!$this->isPayableDocumentType($doc['document_type_id'])) { throw new Exception('Solo las facturas recibidas admiten pagos.'); }
        $collection = $this->getPayableCollectionByInboxId((string)$doc['inbox_document_id'], $environment);
        if (!$collection) {
            throw new Exception('Crea primero la cuenta por pagar antes de registrar un pago.');
        }

        $expectedCollectionId = $this->nullableInt(isset($data['expected_collection_id']) ? $data['expected_collection_id'] : null);
        $expectedInboxDocumentId = isset($data['expected_inbox_document_id']) ? trim((string)$data['expected_inbox_document_id']) : '';
        $expectedBalance = $this->normalizeExpectedBalance(isset($data['expected_balance']) ? $data['expected_balance'] : null, $environment);
        $idempotencyKey = $this->normalizePaymentIdempotencyKey(isset($data['idempotency_key']) ? $data['idempotency_key'] : null, $environment);
        if ($environment === 'production' && ($expectedCollectionId === null || $expectedInboxDocumentId === '')) {
            throw new Exception('Faltan los identificadores esperados de la cuenta para el pago productivo. Actualiza la pantalla.');
        }
        if ($expectedCollectionId !== null && $expectedCollectionId !== (int)$collection['id']) {
            throw new Exception('La cuenta por pagar cambió desde que se abrió el documento. Actualiza y vuelve a intentar.');
        }
        if ($expectedInboxDocumentId !== '' && $expectedInboxDocumentId !== (string)$doc['inbox_document_id']) {
            throw new Exception('La identidad del DTE cambió desde que se abrió el documento. Actualiza y vuelve a intentar.');
        }

        $paymentDate = $this->normalizeDate(isset($data['payment_date']) ? $data['payment_date'] : date('Y-m-d'));
        $amount = round((float)(isset($data['amount']) ? $data['amount'] : 0), 2);
        $methodId = $this->nullableInt(isset($data['payment_method_id']) ? $data['payment_method_id'] : null);
        $cashAccountId = $this->nullableInt(isset($data['cash_account_id']) ? $data['cash_account_id'] : null);
        $reference = isset($data['external_reference']) ? trim((string)$data['external_reference']) : '';
        $notes = isset($data['notes']) ? trim((string)$data['notes']) : null;

        $this->validatePaymentHeader($paymentDate, $amount, $methodId, $cashAccountId);
        if ($environment === 'production' && $paymentDate > date('Y-m-d')) {
            throw new Exception('La fecha de un pago productivo no puede estar en el futuro.');
        }
        $this->assertProductionPaymentAuditFields($environment, $reference, $idempotencyKey);

        $options = new BillingPaymentOptionsService($this->pdo);
        $method = $options->getPaymentMethodById($methodId);
        $account = $options->getCashAccountById($cashAccountId);
        if (!$method) {
            throw new Exception('Método de pago no válido.');
        }
        if (!$account) {
            throw new Exception('Caja/cuenta no válida.');
        }

        $replay = $this->findPaymentReplay(
            $environment,
            'outgoing',
            $idempotencyKey,
            (int)$collection['id'],
            'paid',
            (string)$doc['document_id'],
            (string)$doc['inbox_document_id'],
            $paymentDate,
            $amount,
            $methodId,
            $cashAccountId,
            $reference
        );
        if ($replay) {
            return array(
                'document' => $this->getDocument($doc['id'], $environment),
                'payments' => $this->getPaymentHistory($doc['id'], $environment),
                'idempotent_replay' => true,
                'payment_batch_id' => (int)$replay['batch_id'],
                'payment_id' => (int)$replay['payment_id'],
            );
        }

        $this->assertPaymentReferenceAvailable($environment, 'outgoing', $cashAccountId, $reference, $idempotencyKey);

        $this->pdo->beginTransaction();
        try {
            if ($environment === 'production') {
                $this->lockPaymentAccount($cashAccountId);
                $this->assertPaymentReferenceAvailable($environment, 'outgoing', $cashAccountId, $reference, $idempotencyKey);
            }
            $collection = $this->lockAndRefreshPayableCollection((int)$collection['id']);
            if ($collection['environment'] !== $environment || $collection['direction'] !== 'payable' || (string)$collection['inbox_document_id'] !== (string)$doc['inbox_document_id']) {
                throw new Exception('La identidad financiera de la cuenta por pagar cambió. Actualiza y vuelve a intentar.');
            }
            $balance = round((float)$collection['balance_amount'], 2);
            if ($expectedBalance !== null && abs($balance - $expectedBalance) > 0.01) {
                throw new Exception('El saldo cambió desde que se abrió el documento. Saldo vigente: ' . $this->formatMoney($balance) . '.');
            }
            if ($balance <= 0.01) {
                throw new Exception('El documento ya no tiene saldo pendiente.');
            }
            if ($amount > $balance + 0.01) {
                throw new Exception('El monto a pagar supera el saldo pendiente del documento. Saldo: ' . $this->formatMoney($balance));
            }

            $batchId = $this->createPaymentBatch($environment, $paymentDate, $methodId, $cashAccountId, $amount, $reference, $notes, $userId, $idempotencyKey);
            $paymentId = $this->insertPaymentForCollection($collection, $doc, $environment, $paymentDate, $amount, $method, $methodId, $cashAccountId, $reference, $notes, $userId, $batchId);
            $this->insertAllocation($batchId, $paymentId, (int)$collection['id'], $doc['inbox_document_id'], $amount);
            $this->updateBatchAllocated($batchId);
            $this->recalculateCollection((int)$collection['id']);
            $this->pdo->commit();
        } catch (Throwable $e) {
            if ($this->pdo->inTransaction()) {
                $this->pdo->rollBack();
            }
            $replay = $this->findPaymentReplay(
                $environment,
                'outgoing',
                $idempotencyKey,
                (int)$collection['id'],
                'paid',
                (string)$doc['document_id'],
                (string)$doc['inbox_document_id'],
                $paymentDate,
                $amount,
                $methodId,
                $cashAccountId,
                $reference
            );
            if ($replay) {
                return array(
                    'document' => $this->getDocument($doc['id'], $environment),
                    'payments' => $this->getPaymentHistory($doc['id'], $environment),
                    'idempotent_replay' => true,
                    'payment_batch_id' => (int)$replay['batch_id'],
                    'payment_id' => (int)$replay['payment_id'],
                );
            }
            throw $e;
        }

        return array(
            'document' => $this->getDocument($doc['id'], $environment),
            'payments' => $this->getPaymentHistory($doc['id'], $environment),
            'idempotent_replay' => false,
            'payment_batch_id' => $batchId,
            'payment_id' => $paymentId,
        );
    }

    public function registerBulkPayment(array $ids, array $data, $userId = null)
    {
        $environment = $this->normalizeEnvironment(isset($data['environment']) ? $data['environment'] : 'sandbox');
        billing_assert_runtime_operation('finance', $environment, 'Registro masivo de pagos a proveedores');

        $ids = array_values(array_unique(array_filter(array_map('intval', $ids), function ($id) { return $id > 0; })));
        if (!$ids) {
            throw new Exception('Debes seleccionar al menos un documento.');
        }
        if (count($ids) > 100) {
            throw new Exception('El pago masivo permite un máximo de 100 documentos por operación.');
        }

        $paymentDate = $this->normalizeDate(isset($data['payment_date']) ? $data['payment_date'] : date('Y-m-d'));
        $amountInformed = round((float)(isset($data['amount']) ? $data['amount'] : 0), 2);
        $methodId = $this->nullableInt(isset($data['payment_method_id']) ? $data['payment_method_id'] : null);
        $cashAccountId = $this->nullableInt(isset($data['cash_account_id']) ? $data['cash_account_id'] : null);
        $reference = isset($data['external_reference']) ? trim((string)$data['external_reference']) : '';
        $notes = isset($data['notes']) ? trim((string)$data['notes']) : null;
        $fullPayment = !empty($data['full_payment']);
        $idempotencyKey = $this->normalizePaymentIdempotencyKey(isset($data['idempotency_key']) ? $data['idempotency_key'] : null, $environment);
        $snapshot = $this->normalizePayableBulkSnapshot(isset($data['selection_snapshot']) ? $data['selection_snapshot'] : null, $environment);

        $this->validatePaymentHeader($paymentDate, $amountInformed > 0 ? $amountInformed : 1, $methodId, $cashAccountId);
        if ($environment === 'production' && $paymentDate > date('Y-m-d')) {
            throw new Exception('La fecha de un pago masivo productivo no puede estar en el futuro.');
        }
        $this->assertProductionPaymentAuditFields($environment, $reference, $idempotencyKey);

        if (!$snapshot) {
            foreach ($ids as $id) {
                $doc = $this->getDocument($id, $environment);
                $collection = $this->getPayableCollectionByInboxId((string)$doc['inbox_document_id'], $environment);
                if (!$collection) {
                    throw new Exception('Todos los documentos del pago masivo deben tener una cuenta por pagar creada previamente.');
                }
                $snapshot[] = array(
                    'id' => (int)$doc['id'],
                    'collection_id' => (int)$collection['id'],
                    'document_id' => (string)$doc['document_id'],
                    'inbox_document_id' => (string)$doc['inbox_document_id'],
                    'expected_balance' => round((float)$collection['balance_amount'], 2),
                    'due_date' => $collection['due_date'] ?: ($doc['document_date'] ?: '9999-12-31'),
                    'folio' => (string)$doc['document_number'],
                );
            }
        }

        $snapshotIds = array_map(function ($row) { return (int)$row['id']; }, $snapshot);
        sort($snapshotIds);
        $sortedIds = $ids;
        sort($sortedIds);
        if ($snapshotIds !== $sortedIds) {
            throw new Exception('La selección cambió desde que se abrió el pago masivo. Cierra el modal y vuelve a seleccionar los documentos.');
        }

        $options = new BillingPaymentOptionsService($this->pdo);
        $method = $options->getPaymentMethodById($methodId);
        $account = $options->getCashAccountById($cashAccountId);
        if (!$method) {
            throw new Exception('Método de pago no válido.');
        }
        if (!$account) {
            throw new Exception('Caja/cuenta no válida.');
        }

        $entries = array();
        foreach ($snapshot as $expected) {
            $doc = $this->getDocument((int)$expected['id'], $environment);
            $collection = $this->getPayableCollectionByInboxId((string)$doc['inbox_document_id'], $environment);
            if (!$collection) {
                throw new Exception('El folio ' . ($doc['document_number'] ?: '-') . ' no tiene una cuenta por pagar materializada.');
            }
            if ((int)$collection['id'] !== (int)$expected['collection_id']
                || (string)$doc['document_id'] !== (string)$expected['document_id']
                || (string)$doc['inbox_document_id'] !== (string)$expected['inbox_document_id']) {
                throw new Exception('La identidad de una cuenta por pagar cambió. Actualiza la lista antes de registrar el lote.');
            }
            $entries[] = array(
                'document' => $doc,
                'collection' => $collection,
                'expected_balance' => round((float)$expected['expected_balance'], 2),
                'expected_due_date' => (string)$expected['due_date'],
                'balance' => round((float)$expected['expected_balance'], 2),
                'sort_date' => (string)$expected['due_date'],
                'local_id' => (int)$doc['id'],
            );
        }

        $selectedBalance = round(array_sum(array_map(function ($entry) { return (float)$entry['expected_balance']; }, $entries)), 2);
        $amountTotal = $fullPayment ? $selectedBalance : $amountInformed;
        if ($amountTotal <= 0) {
            throw new Exception('El monto a pagar debe ser mayor a cero.');
        }
        if ($amountTotal > $selectedBalance + 0.01) {
            throw new Exception('El monto total supera el saldo pendiente seleccionado. Saldo seleccionado: ' . $this->formatMoney($selectedBalance));
        }

        $expectedPlan = $this->buildPayableBulkAllocationPlan($entries, $amountTotal);
        $replay = $this->findPayableBulkReplay($environment, $idempotencyKey, $paymentDate, $methodId, $cashAccountId, $amountTotal, $reference, $expectedPlan);
        if ($replay) {
            return $replay;
        }
        $this->assertPaymentReferenceAvailable($environment, 'outgoing', $cashAccountId, $reference, $idempotencyKey);

        $this->pdo->beginTransaction();
        try {
            if ($environment === 'production') {
                $this->lockPaymentAccount($cashAccountId);
                $this->assertPaymentReferenceAvailable($environment, 'outgoing', $cashAccountId, $reference, $idempotencyKey);
            }
            $lockOrder = $entries;
            usort($lockOrder, function ($a, $b) { return (int)$a['collection']['id'] <=> (int)$b['collection']['id']; });
            $lockedByCollection = array();
            foreach ($lockOrder as $entry) {
                $locked = $this->lockAndRefreshPayableCollection((int)$entry['collection']['id']);
                if ($locked['environment'] !== $environment || $locked['direction'] !== 'payable'
                    || (string)$locked['facto_document_id'] !== (string)$entry['document']['document_id']
                    || (string)$locked['inbox_document_id'] !== (string)$entry['document']['inbox_document_id']) {
                    throw new Exception('La identidad financiera de una cuenta por pagar cambió. Actualiza la lista.');
                }
                $balance = round((float)$locked['balance_amount'], 2);
                if (abs($balance - (float)$entry['expected_balance']) > 0.01) {
                    throw new Exception('El saldo del folio ' . ($entry['document']['document_number'] ?: '-') . ' cambió. Saldo vigente: ' . $this->formatMoney($balance) . '.');
                }
                $currentDueDate = (string)($locked['due_date'] ?: ($entry['document']['document_date'] ?: '9999-12-31'));
                if ($currentDueDate !== (string)$entry['expected_due_date']) {
                    throw new Exception('La fecha de vencimiento del folio ' . ($entry['document']['document_number'] ?: '-') . ' cambió. Actualiza la lista.');
                }
                if ($balance <= 0.01 || in_array($locked['collection_status'], array('pagado','rechazado','anulado'), true)) {
                    throw new Exception('El folio ' . ($entry['document']['document_number'] ?: '-') . ' ya no tiene saldo pagable.');
                }
                $lockedByCollection[(int)$locked['id']] = $locked;
            }

            foreach ($entries as &$entry) {
                $entry['collection'] = $lockedByCollection[(int)$entry['collection']['id']];
                $entry['balance'] = round((float)$entry['collection']['balance_amount'], 2);
                $entry['sort_date'] = (string)($entry['collection']['due_date'] ?: ($entry['document']['document_date'] ?: '9999-12-31'));
            }
            unset($entry);

            $currentPlan = $this->buildPayableBulkAllocationPlan($entries, $amountTotal);
            if ($this->bulkPayablePlanSignature($currentPlan) !== $this->bulkPayablePlanSignature($expectedPlan)) {
                throw new Exception('La distribución del pago cambió. Actualiza la lista antes de continuar.');
            }

            $batchId = $this->createPaymentBatch($environment, $paymentDate, $methodId, $cashAccountId, $amountTotal, $reference, $notes, $userId, $idempotencyKey);
            $processed = array();
            foreach ($currentPlan as $planRow) {
                $entry = $planRow['entry'];
                $apply = (float)$planRow['amount'];
                $paymentId = $this->insertPaymentForCollection(
                    $entry['collection'],
                    $entry['document'],
                    $environment,
                    $paymentDate,
                    $apply,
                    $method,
                    $methodId,
                    $cashAccountId,
                    $reference,
                    $notes,
                    $userId,
                    $batchId
                );
                $this->insertAllocation($batchId, $paymentId, (int)$entry['collection']['id'], $entry['document']['inbox_document_id'], $apply);
                $this->recalculateCollection((int)$entry['collection']['id']);
                $updatedCollection = $this->getCollectionById((int)$entry['collection']['id']);
                $processed[] = array(
                    'collection_id' => (int)$entry['collection']['id'],
                    'id' => (int)$entry['document']['id'],
                    'folio' => $entry['document']['document_number'],
                    'supplier' => $entry['document']['supplier_name'] ?: $entry['document']['issuer_name'],
                    'amount_applied' => $apply,
                    'balance_after' => (float)$updatedCollection['balance_amount'],
                    'status_after' => $updatedCollection['collection_status'],
                );
            }
            $this->updateBatchAllocated($batchId);
            $this->pdo->commit();
        } catch (Throwable $e) {
            if ($this->pdo->inTransaction()) {
                $this->pdo->rollBack();
            }
            $replay = $this->findPayableBulkReplay($environment, $idempotencyKey, $paymentDate, $methodId, $cashAccountId, $amountTotal, $reference, $expectedPlan);
            if ($replay) {
                return $replay;
            }
            throw $e;
        }

        return array(
            'batch_id' => $batchId,
            'amount_total' => $amountTotal,
            'amount_allocated' => $amountTotal,
            'unallocated' => 0.0,
            'processed' => $processed,
            'selected_balance' => $selectedBalance,
            'idempotent_replay' => false,
        );
    }



    private function normalizePayableBulkSnapshot($raw, $environment)
    {
        if (is_string($raw)) {
            $decoded = json_decode($raw, true);
        } elseif (is_array($raw)) {
            $decoded = $raw;
        } else {
            $decoded = array();
        }
        if (!$decoded) {
            if ($environment === 'production') {
                throw new Exception('Falta el snapshot de la selección masiva. Actualiza la pantalla.');
            }
            return array();
        }
        $normalized = array();
        $seenIds = array();
        $seenCollections = array();
        foreach ($decoded as $row) {
            if (!is_array($row)) throw new Exception('Snapshot de selección inválido.');
            $id = isset($row['id']) ? (int)$row['id'] : 0;
            $collectionId = isset($row['collection_id']) ? (int)$row['collection_id'] : 0;
            $documentId = isset($row['document_id']) ? trim((string)$row['document_id']) : '';
            $inboxDocumentId = isset($row['inbox_document_id']) ? trim((string)$row['inbox_document_id']) : '';
            $balance = isset($row['expected_balance']) && is_numeric($row['expected_balance']) ? round((float)$row['expected_balance'], 2) : -1;
            $dueDate = isset($row['due_date']) && trim((string)$row['due_date']) !== '' ? trim((string)$row['due_date']) : '9999-12-31';
            if ($id <= 0 || $collectionId <= 0 || $documentId === '' || $inboxDocumentId === '' || $balance <= 0.01 || !$this->normalizeDate($dueDate)) {
                throw new Exception('El snapshot de una cuenta por pagar está incompleto. Actualiza la pantalla.');
            }
            if (isset($seenIds[$id]) || isset($seenCollections[$collectionId])) {
                throw new Exception('La selección masiva contiene documentos duplicados.');
            }
            $seenIds[$id] = true;
            $seenCollections[$collectionId] = true;
            $normalized[] = array(
                'id' => $id,
                'collection_id' => $collectionId,
                'document_id' => $documentId,
                'inbox_document_id' => $inboxDocumentId,
                'expected_balance' => $balance,
                'due_date' => $dueDate,
                'folio' => isset($row['folio']) ? trim((string)$row['folio']) : '',
            );
        }
        return $normalized;
    }

    private function buildPayableBulkAllocationPlan(array $entries, $amountTotal)
    {
        usort($entries, function ($a, $b) {
            if ((string)$a['sort_date'] === (string)$b['sort_date']) return (int)$a['local_id'] <=> (int)$b['local_id'];
            return strcmp((string)$a['sort_date'], (string)$b['sort_date']);
        });
        $remaining = round((float)$amountTotal, 2);
        $plan = array();
        foreach ($entries as $entry) {
            if ($remaining <= 0.01) break;
            $apply = round(min($remaining, (float)$entry['balance']), 2);
            if ($apply <= 0.01) continue;
            $plan[] = array('entry' => $entry, 'amount' => $apply);
            $remaining = round($remaining - $apply, 2);
        }
        if ($remaining > 0.01) throw new Exception('No fue posible distribuir íntegramente el pago masivo entre los documentos seleccionados.');
        return $plan;
    }

    private function bulkPayablePlanSignature(array $plan)
    {
        $rows = array();
        foreach ($plan as $row) $rows[] = (int)$row['entry']['collection']['id'] . ':' . number_format((float)$row['amount'], 2, '.', '');
        return implode('|', $rows);
    }

    private function findPayableBulkReplay($environment, $idempotencyKey, $paymentDate, $methodId, $cashAccountId, $amountTotal, $reference, array $plan)
    {
        if ($idempotencyKey === null || $idempotencyKey === '') return null;
        $stmt = $this->pdo->prepare("SELECT * FROM billing_payment_batches WHERE BINARY environment=BINARY ? AND BINARY direction=BINARY 'outgoing' AND BINARY idempotency_key=BINARY ? LIMIT 1");
        $stmt->execute(array($environment, $idempotencyKey));
        $batch = $stmt->fetch(PDO::FETCH_ASSOC);
        if (!$batch) return null;
        if ((string)$batch['payment_date'] !== (string)$paymentDate
            || (int)$batch['payment_method_id'] !== (int)$methodId
            || (int)$batch['cash_account_id'] !== (int)$cashAccountId
            || abs((float)$batch['amount_total'] - (float)$amountTotal) > 0.01
            || trim((string)$batch['external_reference']) !== trim((string)$reference)) {
            throw new Exception('La clave de idempotencia ya fue utilizada por otra operación masiva. Actualiza la pantalla.');
        }
        $stmt = $this->pdo->prepare("SELECT id,collection_id,facto_document_id,inbox_document_id,amount FROM billing_payments WHERE payment_batch_id=? AND BINARY direction=BINARY 'paid' ORDER BY collection_id");
        $stmt->execute(array((int)$batch['id']));
        $payments = $stmt->fetchAll(PDO::FETCH_ASSOC);
        if (count($payments) !== count($plan)) throw new Exception('El lote idempotente existente está incompleto y requiere revisión.');
        $paymentByCollection = array();
        foreach ($payments as $payment) $paymentByCollection[(int)$payment['collection_id']] = $payment;
        $stmt = $this->pdo->prepare("SELECT collection_id,source_type,source_id,amount_applied FROM billing_payment_allocations WHERE payment_batch_id=? ORDER BY collection_id");
        $stmt->execute(array((int)$batch['id']));
        $allocations = $stmt->fetchAll(PDO::FETCH_ASSOC);
        if (count($allocations) !== count($plan)) throw new Exception('Las asignaciones del lote idempotente existente están incompletas.');
        $allocationByCollection = array();
        foreach ($allocations as $allocation) $allocationByCollection[(int)$allocation['collection_id']] = $allocation;
        $processed = array();
        foreach ($plan as $planRow) {
            $entry = $planRow['entry'];
            $collectionId = (int)$entry['collection']['id'];
            if (!isset($paymentByCollection[$collectionId], $allocationByCollection[$collectionId])) throw new Exception('El lote idempotente existente no coincide con la selección original.');
            $payment = $paymentByCollection[$collectionId];
            $allocation = $allocationByCollection[$collectionId];
            if ((string)$payment['facto_document_id'] !== (string)$entry['document']['document_id']
                || (string)$payment['inbox_document_id'] !== (string)$entry['document']['inbox_document_id']
                || abs((float)$payment['amount'] - (float)$planRow['amount']) > 0.01
                || (string)$allocation['source_type'] !== 'inbox_document'
                || (string)$allocation['source_id'] !== (string)$entry['document']['inbox_document_id']
                || abs((float)$allocation['amount_applied'] - (float)$planRow['amount']) > 0.01) {
                throw new Exception('El lote idempotente existente contiene documentos o montos diferentes.');
            }
            $current = $this->getCollectionById($collectionId);
            $processed[] = array(
                'collection_id' => $collectionId,
                'id' => (int)$entry['document']['id'],
                'folio' => $entry['document']['document_number'],
                'supplier' => $entry['document']['supplier_name'] ?: $entry['document']['issuer_name'],
                'amount_applied' => (float)$planRow['amount'],
                'balance_after' => $current ? (float)$current['balance_amount'] : 0.0,
                'status_after' => $current ? $current['collection_status'] : 'pagado',
            );
        }
        return array(
            'batch_id' => (int)$batch['id'],
            'amount_total' => (float)$batch['amount_total'],
            'amount_allocated' => (float)$batch['amount_allocated'],
            'unallocated' => max(round((float)$batch['amount_total'] - (float)$batch['amount_allocated'], 2), 0),
            'processed' => $processed,
            'selected_balance' => array_sum(array_map(function ($row) { return (float)$row['entry']['expected_balance']; }, $plan)),
            'idempotent_replay' => true,
        );
    }

    public function ensurePayableCollection($inboxDocumentId, $environment = 'sandbox', array $overrides = array())
    {
        $environment = $this->normalizeEnvironment($environment);
        billing_assert_runtime_operation('finance', $environment, 'Inicialización de cuenta por pagar');
        $doc = $this->getDocumentByInboxId($inboxDocumentId, $environment);
        if (!$this->isPayableDocumentType($doc['document_type_id'])) { throw new Exception('Solo las facturas recibidas generan cuentas por pagar.'); }
        if (empty($doc['supplier_customer_id']) || empty($doc['payment_due_date']) || empty($doc['payment_terms_id'])) {
            try {
                $resolver = new BillingSupplierResolverService($this->pdo);
                $resolver->resolveDocumentByLocalId((int)$doc['id'], $environment, true);
                $doc = $this->getDocumentByInboxId($inboxDocumentId, $environment);
            } catch (Throwable $e) {
                // Continuar con datos disponibles.
            }
        }

        $stmt = $this->pdo->prepare(" 
            SELECT * FROM billing_collections
            WHERE environment = ? AND direction = 'payable' AND inbox_document_id = ?
            LIMIT 1
        ");
        $stmt->execute(array($environment, $doc['inbox_document_id']));
        $existing = $stmt->fetch(PDO::FETCH_ASSOC);

        if (!$existing && !$this->isApprovedPayableDocument($doc)) {
            throw new Exception('La factura recibida no está aprobada; no corresponde crear una cuenta por pagar.');
        }

        $dueDate = array_key_exists('due_date', $overrides) ? $overrides['due_date'] : $this->resolveCalculatedDueDate($doc);
        $financeCenterId = array_key_exists('finance_center_id', $overrides) ? $overrides['finance_center_id'] : ($doc['finance_center_id'] ?: (isset($doc['supplier_default_cost_center_id']) ? $doc['supplier_default_cost_center_id'] : null));
        $status = array_key_exists('collection_status', $overrides) ? $overrides['collection_status'] : ($doc['payment_status'] ?: 'por_pagar');
        $customerId = array_key_exists('customer_id', $overrides) ? $overrides['customer_id'] : ($doc['supplier_customer_id'] ?: null);
        $notes = array_key_exists('notes', $overrides) ? $overrides['notes'] : (isset($doc['collection_notes']) ? $doc['collection_notes'] : null);
        $paymentTerms = isset($doc['supplier_payment_term_label']) && $doc['supplier_payment_term_label'] ? $doc['supplier_payment_term_label'] : null;

        if ($status === 'por_pagar' && $dueDate && strtotime($dueDate) < strtotime(date('Y-m-d'))) {
            $status = 'vencido';
        }

        if ($existing) {
            $stmt = $this->pdo->prepare(" 
                UPDATE billing_collections
                SET facto_document_id = ?,
                    customer_id = COALESCE(?, customer_id),
                    document_number = ?,
                    document_type_id = ?,
                    issue_date = ?,
                    due_date = ?,
                    total_amount = ?,
                    balance_amount = GREATEST(? - paid_amount, 0),
                    collection_status = CASE
                        WHEN paid_amount >= ? THEN 'pagado'
                        WHEN paid_amount > 0 AND ? > paid_amount THEN 'parcial'
                        ELSE ?
                    END,
                    finance_center_id = ?,
                    payment_terms = COALESCE(?, payment_terms),
                    notes = COALESCE(?, notes),
                    updated_at = CURRENT_TIMESTAMP
                WHERE id = ?
            ");
            $stmt->execute(array(
                $doc['document_id'],
                $customerId,
                $doc['document_number'],
                $doc['document_type_id'],
                $doc['document_date'],
                $dueDate,
                $doc['total_amount'],
                $doc['total_amount'],
                $doc['total_amount'],
                $doc['total_amount'],
                $status,
                $financeCenterId,
                $paymentTerms,
                $notes,
                $existing['id']
            ));
            $this->recalculateCollection((int)$existing['id']);
            return $this->getCollectionById((int)$existing['id']);
        }

        $stmt = $this->pdo->prepare(" 
            INSERT INTO billing_collections
            (environment, direction, source_table, facto_document_id, inbox_document_id, customer_id, document_number, document_type_id, issue_date, due_date, total_amount, paid_amount, balance_amount, collection_status, payment_terms, finance_center_id, notes)
            VALUES (?, 'payable', 'facto_inbox_documents', ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?)
        ");
        $stmt->execute(array(
            $environment,
            $doc['document_id'],
            $doc['inbox_document_id'],
            $customerId,
            $doc['document_number'],
            $doc['document_type_id'],
            $doc['document_date'],
            $dueDate,
            $doc['total_amount'],
            $doc['total_amount'],
            $status,
            $paymentTerms,
            $financeCenterId,
            $notes
        ));

        $id = (int)$this->pdo->lastInsertId();
        return $this->getCollectionById($id);
    }

    private function refreshCalculatedPayableStatuses($environment = 'sandbox', $localDocumentId = null)
    {
        $environment = $this->normalizeEnvironment($environment);
        billing_assert_runtime_operation('finance', $environment, 'Recalculo financiero de cuentas por pagar');
        $today = date('Y-m-d');

        $where = "d.environment = ?";
        $params = array($environment);
        if ($localDocumentId !== null) {
            $where .= " AND d.id = ?";
            $params[] = (int)$localDocumentId;
        }

        $sql = "
            SELECT
                d.id,
                d.inbox_document_id,
                d.document_date,
                d.receive_date,
                d.total_amount,
                d.payment_status,
                c.id AS collection_id,
                c.paid_amount,
                c.collection_status,
                c.last_payment_date,
                pt.days AS term_days
            FROM facto_inbox_documents d
            LEFT JOIN crm_customers s ON s.id = d.supplier_customer_id
            LEFT JOIN billing_payment_terms pt ON pt.id = COALESCE(d.payment_terms_id, s.supplier_payment_terms_id)
            LEFT JOIN billing_collections c ON c.environment = d.environment
                AND c.direction = 'payable'
                AND c.inbox_document_id = d.inbox_document_id
            WHERE " . $where . "
        ";
        $stmt = $this->pdo->prepare($sql);
        $stmt->execute($params);
        $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);

        foreach ($rows as $row) {
            $currentStatus = $row['collection_status'] ?: $row['payment_status'];
            if (in_array($currentStatus, array('rechazado','anulado'), true)) {
                continue;
            }

            $dueDate = $this->calculateDueDateFromTermsRow($row);
            $paid = isset($row['paid_amount']) ? (float)$row['paid_amount'] : 0.0;
            $total = isset($row['total_amount']) ? (float)$row['total_amount'] : 0.0;
            $balance = max($total - $paid, 0);
            $status = $this->statusFromPaidBalanceDue($paid, $balance, $dueDate, $today);

            $stmtDoc = $this->pdo->prepare("
                UPDATE facto_inbox_documents
                SET payment_due_date = ?,
                    payment_status = ?,
                    payment_paid_date = CASE WHEN ? = 'pagado' THEN COALESCE(payment_paid_date, ?) ELSE payment_paid_date END,
                    updated_at = CURRENT_TIMESTAMP
                WHERE id = ?
            ");
            $stmtDoc->execute(array($dueDate, $status, $status, $row['last_payment_date'], (int)$row['id']));

            if (!empty($row['collection_id'])) {
                $stmtCol = $this->pdo->prepare("
                    UPDATE billing_collections
                    SET due_date = ?,
                        balance_amount = ?,
                        collection_status = ?,
                        updated_at = CURRENT_TIMESTAMP
                    WHERE id = ?
                ");
                $stmtCol->execute(array($dueDate, $balance, $status, (int)$row['collection_id']));
            }
        }
    }

    private function calculateDueDateFromTermsRow(array $row)
    {
        $baseDate = $this->normalizeDate(isset($row['document_date']) ? $row['document_date'] : null);
        if (!$baseDate) {
            $baseDate = $this->normalizeDate(isset($row['receive_date']) ? $row['receive_date'] : null);
        }
        if (!$baseDate) {
            return date('Y-m-d', strtotime('-1 day'));
        }

        $days = 0;
        if (isset($row['term_days']) && $row['term_days'] !== null && $row['term_days'] !== '') {
            $days = (int)$row['term_days'];
        }
        $ts = strtotime($baseDate . ' +' . max(0, $days) . ' days');
        return $ts ? date('Y-m-d', $ts) : $baseDate;
    }

    private function statusFromPaidBalanceDue($paid, $balance, $dueDate, $today = null)
    {
        $today = $today ?: date('Y-m-d');
        $isOverdue = $dueDate && strtotime($dueDate) < strtotime($today);
        if ($paid > 0 && $balance <= 0.5) {
            return 'pagado';
        }
        if ($isOverdue) {
            return 'vencido';
        }
        if ($paid > 0) {
            return 'parcial';
        }
        return 'por_pagar';
    }

    public function refreshOverdue($environment = 'sandbox')
    {
        $environment = $this->normalizeEnvironment($environment);
        billing_assert_runtime_operation('finance', $environment, 'Actualización de vencimientos de CxP');
        $today = date('Y-m-d');
        $stmt = $this->pdo->prepare(" 
            UPDATE billing_collections
            SET collection_status = 'vencido', updated_at = CURRENT_TIMESTAMP
            WHERE environment = ?
              AND direction = 'payable'
              AND collection_status IN ('por_pagar','parcial')
              AND due_date IS NOT NULL
              AND due_date < ?
              AND balance_amount > 0
        ");
        $stmt->execute(array($environment, $today));

        $stmt = $this->pdo->prepare(" 
            UPDATE facto_inbox_documents d
            JOIN billing_collections c ON c.environment = d.environment
                AND c.direction = 'payable'
                AND c.inbox_document_id = d.inbox_document_id
            SET d.payment_status = c.collection_status,
                d.payment_due_date = c.due_date,
                d.payment_paid_date = c.last_payment_date,
                d.updated_at = CURRENT_TIMESTAMP
            WHERE d.environment = ?
        ");
        $stmt->execute(array($environment));
    }

    public function listFinanceCenters()
    {
        if (!$this->tableExists('finance_centers')) {
            return array();
        }
        $stmt = $this->pdo->query(" 
            SELECT id, center_code, center_name, center_type, is_default
            FROM finance_centers
            WHERE is_active = 1 AND center_type IN ('cost','both')
            ORDER BY is_default DESC, center_name ASC
        ");
        return $stmt ? $stmt->fetchAll(PDO::FETCH_ASSOC) : array();
    }

    public function filePathForDocument($idOrInboxId, $type, $environment = 'sandbox')
    {
        $doc = $this->getDocument($idOrInboxId, $environment);
        $path = $type === 'xml' ? $doc['xml_file_path'] : $doc['pdf_file_path'];
        if (!$path) {
            throw new Exception('Archivo no disponible para este documento.');
        }
        $absolute = dirname(__DIR__, 2) . '/' . ltrim($path, '/');
        $storageRoot = realpath(dirname(__DIR__, 2) . '/storage/billing');
        $real = realpath($absolute);
        if (!$real || !$storageRoot || strpos($real, $storageRoot) !== 0 || !is_file($real)) {
            throw new Exception('Ruta de archivo no autorizada o inexistente.');
        }
        return $real;
    }

    private function validatePaymentHeader($paymentDate, $amount, $methodId, $cashAccountId)
    {
        if (!$paymentDate) {
            throw new Exception('Fecha de pago requerida.');
        }
        if ((float)$amount <= 0) {
            throw new Exception('Monto de pago requerido.');
        }
        if (!$methodId) {
            throw new Exception('Metodo de pago requerido.');
        }
        if (!$cashAccountId) {
            throw new Exception('Caja/cuenta requerida.');
        }
    }

    private function createPaymentBatch($environment, $paymentDate, $methodId, $cashAccountId, $amount, $reference, $notes, $userId, $idempotencyKey = null)
    {
        $stmt = $this->pdo->prepare(" 
            INSERT INTO billing_payment_batches
            (environment, direction, payment_date, payment_method_id, cash_account_id, amount_total, amount_allocated, external_reference, notes, idempotency_key, created_by)
            VALUES (?, 'outgoing', ?, ?, ?, ?, 0, ?, ?, ?, ?)
        ");
        $stmt->execute(array($environment, $paymentDate, $methodId, $cashAccountId, $amount, $reference, $notes, $idempotencyKey, $userId));
        return (int)$this->pdo->lastInsertId();
    }

    private function insertPaymentForCollection(array $collection, array $doc, $environment, $paymentDate, $amount, array $method, $methodId, $cashAccountId, $reference, $notes, $userId, $batchId)
    {
        $stmt = $this->pdo->prepare(" 
            INSERT INTO billing_payments
            (payment_batch_id, collection_id, environment, direction, facto_document_id, inbox_document_id, customer_id, payment_date, amount, payment_method, payment_method_id, cash_account_id, external_reference, finance_center_id, notes, created_by)
            VALUES (?, ?, ?, 'paid', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
        ");
        $stmt->execute(array(
            $batchId,
            $collection['id'],
            $environment,
            $doc['document_id'],
            $doc['inbox_document_id'],
            $doc['supplier_customer_id'],
            $paymentDate,
            $amount,
            $method['method_name'],
            $methodId,
            $cashAccountId,
            $reference,
            $doc['finance_center_id'] ?: (isset($doc['supplier_default_cost_center_id']) ? $doc['supplier_default_cost_center_id'] : null),
            $notes,
            $userId
        ));
        return (int)$this->pdo->lastInsertId();
    }

    private function insertAllocation($batchId, $paymentId, $collectionId, $inboxDocumentId, $amount)
    {
        $stmt = $this->pdo->prepare(" 
            INSERT INTO billing_payment_allocations
            (payment_batch_id, payment_id, collection_id, source_type, source_id, amount_applied)
            VALUES (?, ?, ?, 'inbox_document', ?, ?)
        ");
        $stmt->execute(array($batchId, $paymentId, $collectionId, $inboxDocumentId, $amount));
    }

    private function updateBatchAllocated($batchId)
    {
        $stmt = $this->pdo->prepare(" 
            UPDATE billing_payment_batches b
            SET amount_allocated = (
                SELECT COALESCE(SUM(amount_applied), 0)
                FROM billing_payment_allocations a
                WHERE a.payment_batch_id = b.id
            )
            WHERE b.id = ?
        ");
        $stmt->execute(array($batchId));
    }

    private function recalculateCollection($collectionId)
    {
        $collection = $this->getCollectionById($collectionId);
        $stmt = $this->pdo->prepare(" 
            SELECT COALESCE(SUM(amount), 0) AS paid_amount, MAX(payment_date) AS last_payment_date
            FROM billing_payments
            WHERE collection_id = ? AND direction = 'paid' AND COALESCE(is_voided,0)=0
        ");
        $stmt->execute(array($collectionId));
        $p = $stmt->fetch(PDO::FETCH_ASSOC) ?: array('paid_amount' => 0, 'last_payment_date' => null);
        $paid = (float)$p['paid_amount'];
        $total = (float)$collection['total_amount'];
        $balance = max($total - $paid, 0);
        $status = $collection['collection_status'];
        if ($paid <= 0) {
            $status = $collection['due_date'] && strtotime($collection['due_date']) < strtotime(date('Y-m-d')) ? 'vencido' : 'por_pagar';
        } elseif ($balance <= 0.5) {
            $status = 'pagado';
        } else {
            $status = $collection['due_date'] && strtotime($collection['due_date']) < strtotime(date('Y-m-d')) ? 'vencido' : 'parcial';
        }

        $stmt = $this->pdo->prepare(" 
            UPDATE billing_collections
            SET paid_amount = ?, balance_amount = ?, collection_status = ?, last_payment_date = ?, updated_at = CURRENT_TIMESTAMP
            WHERE id = ?
        ");
        $stmt->execute(array($paid, $balance, $status, $p['last_payment_date'], $collectionId));

        $stmt = $this->pdo->prepare(" 
            UPDATE facto_inbox_documents
            SET payment_status = ?, payment_paid_date = ?, updated_at = CURRENT_TIMESTAMP
            WHERE environment = ? AND inbox_document_id = ?
        ");
        $stmt->execute(array($status, $p['last_payment_date'], $collection['environment'], $collection['inbox_document_id']));
    }


    private function lockAndRefreshPayableCollection($collectionId)
    {
        $stmt = $this->pdo->prepare('SELECT * FROM billing_collections WHERE id = ? FOR UPDATE');
        $stmt->execute(array((int)$collectionId));
        $collection = $stmt->fetch(PDO::FETCH_ASSOC);
        if (!$collection || $collection['direction'] !== 'payable') {
            throw new Exception('Cuenta por pagar no encontrada.');
        }
        $this->recalculateCollection((int)$collectionId);
        return $this->getCollectionById((int)$collectionId);
    }

    private function lockPaymentAccount($cashAccountId)
    {
        $stmt = $this->pdo->prepare('SELECT id FROM billing_cash_accounts WHERE id = ? AND is_active = 1 FOR UPDATE');
        $stmt->execute(array((int)$cashAccountId));
        if (!$stmt->fetchColumn()) {
            throw new Exception('La caja o cuenta seleccionada ya no está disponible.');
        }
    }

    private function normalizePaymentIdempotencyKey($value, $environment)
    {
        $key = trim((string)$value);
        if ($key === '') {
            if ($environment === 'production') {
                throw new Exception('La operación no incluye una clave de idempotencia válida. Actualiza la pantalla y vuelve a intentar.');
            }
            return null;
        }
        if (strlen($key) < 16 || strlen($key) > 64 || !preg_match('/^[A-Za-z0-9._:-]+$/', $key)) {
            throw new Exception('Clave de idempotencia inválida. Actualiza la pantalla y vuelve a intentar.');
        }
        return $key;
    }

    private function normalizeExpectedBalance($value, $environment)
    {
        if ($value === null || $value === '') {
            if ($environment === 'production') {
                throw new Exception('Falta el saldo esperado de la cuenta. Actualiza la pantalla y vuelve a intentar.');
            }
            return null;
        }
        if (!is_numeric($value)) {
            throw new Exception('Saldo esperado inválido.');
        }
        return round((float)$value, 2);
    }

    private function assertProductionPaymentAuditFields($environment, $reference, $idempotencyKey)
    {
        if ($environment !== 'production') {
            return;
        }
        if ($idempotencyKey === null) {
            throw new Exception('Clave de idempotencia requerida para pagos productivos.');
        }
        $reference = trim((string)$reference);
        if ($reference === '') {
            throw new Exception('Ingresa una referencia verificable para el pago productivo.');
        }
        if (strlen($reference) > 120) {
            throw new Exception('La referencia del pago no puede superar 120 caracteres.');
        }
        $normalizedReference = strtolower(preg_replace('/\s+/', ' ', $reference));
        $weakReferences = array('na', 'n/a', 'n.a.', 'no aplica', 'sin referencia', 'sin comprobante', 's/r', 'sr', 'none', 'null', '-', '--');
        if (strlen($reference) < 6 || in_array($normalizedReference, $weakReferences, true)) {
            throw new Exception('La referencia productiva debe identificar el movimiento. Usa el número de operación o una referencia interna única de al menos 6 caracteres.');
        }
    }

    private function assertPaymentReferenceAvailable($environment, $batchDirection, $cashAccountId, $reference, $idempotencyKey)
    {
        if ($environment !== 'production') {
            return;
        }
        $stmt = $this->pdo->prepare("SELECT id, idempotency_key FROM billing_payment_batches WHERE BINARY environment = BINARY ? AND BINARY direction = BINARY ? AND cash_account_id = ? AND BINARY external_reference = BINARY ? LIMIT 1");
        $stmt->execute(array($environment, $batchDirection, (int)$cashAccountId, trim((string)$reference)));
        $existing = $stmt->fetch(PDO::FETCH_ASSOC);
        if ($existing && (string)$existing['idempotency_key'] !== (string)$idempotencyKey) {
            throw new Exception('La referencia ya fue utilizada en otra operación de la misma caja o cuenta. Verifica el movimiento antes de continuar.');
        }
    }

    private function findPaymentReplay($environment, $batchDirection, $idempotencyKey, $collectionId, $paymentDirection, $factoDocumentId, $inboxDocumentId, $paymentDate, $amount, $methodId, $cashAccountId, $reference)
    {
        if ($idempotencyKey === null || $idempotencyKey === '') {
            return null;
        }
        $stmt = $this->pdo->prepare("SELECT * FROM billing_payment_batches WHERE BINARY environment = BINARY ? AND BINARY direction = BINARY ? AND BINARY idempotency_key = BINARY ? LIMIT 1");
        $stmt->execute(array($environment, $batchDirection, $idempotencyKey));
        $batch = $stmt->fetch(PDO::FETCH_ASSOC);
        if (!$batch) {
            return null;
        }
        $batchMatches = (string)$batch['payment_date'] === (string)$paymentDate
            && (int)$batch['payment_method_id'] === (int)$methodId
            && (int)$batch['cash_account_id'] === (int)$cashAccountId
            && abs((float)$batch['amount_total'] - (float)$amount) <= 0.01
            && trim((string)$batch['external_reference']) === trim((string)$reference);
        if (!$batchMatches) {
            throw new Exception('La clave de idempotencia ya fue utilizada por otra operación. Actualiza la pantalla.');
        }
        $stmt = $this->pdo->prepare("SELECT id, collection_id, facto_document_id, inbox_document_id, amount FROM billing_payments WHERE payment_batch_id = ? AND BINARY direction = BINARY ? ORDER BY id");
        $stmt->execute(array((int)$batch['id'], $paymentDirection));
        $payments = $stmt->fetchAll(PDO::FETCH_ASSOC);
        if (count($payments) !== 1) {
            throw new Exception('La operación idempotente existente está incompleta o no corresponde a un pago individual. Requiere revisión.');
        }
        $payment = $payments[0];
        $paymentMatches = (int)$payment['collection_id'] === (int)$collectionId
            && abs((float)$payment['amount'] - (float)$amount) <= 0.01
            && ($factoDocumentId === null || (string)$payment['facto_document_id'] === (string)$factoDocumentId)
            && ($inboxDocumentId === null || (string)$payment['inbox_document_id'] === (string)$inboxDocumentId);
        if (!$paymentMatches) {
            throw new Exception('La clave de idempotencia ya fue utilizada para otro documento o monto. Actualiza la pantalla.');
        }
        return array('batch_id' => (int)$batch['id'], 'payment_id' => (int)$payment['id']);
    }

    private function getPayableCollectionByInboxId($inboxDocumentId, $environment = 'sandbox')
    {
        $stmt = $this->pdo->prepare("
            SELECT *
            FROM billing_collections
            WHERE BINARY environment = BINARY ?
              AND BINARY direction = BINARY 'payable'
              AND BINARY inbox_document_id = BINARY ?
            LIMIT 1
        ");
        $stmt->execute(array($environment, (string)$inboxDocumentId));
        return $stmt->fetch(PDO::FETCH_ASSOC) ?: null;
    }

    private function getCollectionById($id)
    {
        $stmt = $this->pdo->prepare('SELECT * FROM billing_collections WHERE id = ? LIMIT 1');
        $stmt->execute(array((int)$id));
        return $stmt->fetch(PDO::FETCH_ASSOC) ?: null;
    }


    private function resolveSupplierDefaults($environment, $limit = 200)
    {
        billing_assert_runtime_operation('finance', $environment, 'Resolución automática de proveedor y condiciones de pago');
        try {
            $resolver = new BillingSupplierResolverService($this->pdo);
            $resolver->resolveEnvironment($environment, $limit);
        } catch (Throwable $e) {
            // La bandeja de cuentas por pagar no debe fallar por una resolucion automatica de proveedor.
        }
    }

    private function resolveCalculatedDueDate(array $doc)
    {
        // La fecha de vencimiento se recalcula desde la condicion de pago del proveedor.
        // No se usa un valor manual previo como fuente principal para evitar estados obsoletos.
        $baseDate = $this->normalizeDate(isset($doc['document_date']) ? $doc['document_date'] : null);
        if (!$baseDate) {
            $baseDate = $this->normalizeDate(isset($doc['receive_date']) ? $doc['receive_date'] : null);
        }
        if (!$baseDate) {
            return date('Y-m-d', strtotime('-1 day'));
        }

        $days = 0;
        if (isset($doc['supplier_payment_term_days']) && $doc['supplier_payment_term_days'] !== null && $doc['supplier_payment_term_days'] !== '') {
            $days = (int)$doc['supplier_payment_term_days'];
        }

        $ts = strtotime($baseDate . ' +' . max(0, $days) . ' days');
        return $ts ? date('Y-m-d', $ts) : $baseDate;
    }

    private function calculateStatusFromAmounts(array $doc, $dueDate = null)
    {
        $paid = isset($doc['paid_amount']) ? (float)$doc['paid_amount'] : 0.0;
        $total = isset($doc['total_amount']) ? (float)$doc['total_amount'] : 0.0;
        $balance = max($total - $paid, 0);
        return $this->statusFromPaidBalanceDue($paid, $balance, $dueDate);
    }

    private function getDefaultCostCenterId()
    {
        if (!$this->tableExists('finance_centers')) {
            return null;
        }
        $stmt = $this->pdo->prepare("SELECT id FROM finance_centers WHERE center_code = 'NO_COST_CENTER' LIMIT 1");
        $stmt->execute();
        $id = $stmt->fetchColumn();
        return $id ? (int)$id : null;
    }

    private function tableExists($table)
    {
        $stmt = $this->pdo->prepare('SHOW TABLES LIKE ?');
        $stmt->execute(array($table));
        return (bool)$stmt->fetchColumn();
    }

    /**
     * Estado efectivo de CxP calculado sin escritura. Se usa en listados y KPI
     * para mostrar vencimientos correctos sin actualizar 520 filas al abrir UI.
     */
    private function effectivePayableStatusSql()
    {
        return "CASE
            WHEN c.collection_status IN ('rechazado','anulado') THEN c.collection_status
            WHEN c.id IS NOT NULL AND COALESCE(c.balance_amount,0) <= 0.5 THEN 'pagado'
            WHEN c.id IS NOT NULL AND c.due_date IS NOT NULL AND c.due_date < CURRENT_DATE THEN 'vencido'
            WHEN c.id IS NOT NULL AND COALESCE(c.paid_amount,0) > 0 THEN 'parcial'
            WHEN d.payment_status IN ('rechazado','anulado','pagado') THEN d.payment_status
            WHEN d.payment_due_date IS NOT NULL AND d.payment_due_date < CURRENT_DATE THEN 'vencido'
            ELSE COALESCE(d.payment_status,'por_pagar')
        END";
    }

    private function deriveEffectivePayableStatus(array $row)
    {
        $collectionStatus = isset($row['collection_status']) ? (string)$row['collection_status'] : '';
        if (in_array($collectionStatus, array('rechazado','anulado'), true)) {
            return $collectionStatus;
        }

        $hasCollection = !empty($row['collection_id']);
        if (!$hasCollection) {
            return $this->isApprovedPayableDocument($row) ? 'pendiente_cxp' : 'pendiente_aprobacion';
        }

        $paid = isset($row['paid_amount']) ? (float)$row['paid_amount'] : 0.0;
        $balance = isset($row['balance_amount']) ? (float)$row['balance_amount'] : 0.0;
        $dueDate = !empty($row['due_date'])
            ? $row['due_date']
            : (isset($row['payment_due_date']) ? $row['payment_due_date'] : null);

        if ($balance <= 0.5) {
            return 'pagado';
        }
        if ($dueDate && strtotime($dueDate) < strtotime(date('Y-m-d'))) {
            return 'vencido';
        }
        if ($hasCollection && $paid > 0) {
            return 'parcial';
        }

        $stored = isset($row['payment_status']) ? (string)$row['payment_status'] : '';
        if (in_array($stored, array('rechazado','anulado','pagado'), true)) {
            return $stored;
        }
        return $stored !== '' ? $stored : 'por_pagar';
    }

    private function approvedPayableStatusSql($alias = 'd')
    {
        $alias = preg_replace('/[^a-zA-Z0-9_]/', '', (string)$alias);
        if ($alias === '') { $alias = 'd'; }
        return "(LOWER(TRIM(CAST(COALESCE(" . $alias . ".icylab_reception_status,'') AS CHAR))) IN ('approved','approved_with_observations')
            OR LOWER(TRIM(CAST(COALESCE(" . $alias . ".document_approval_status,'') AS CHAR))) IN ('1','accepted','approved','approved_with_observations')
            OR TRIM(CAST(COALESCE(" . $alias . ".inbox_document_status,'') AS CHAR)) IN ('2','3'))";
    }

    private function isApprovedPayableDocument(array $doc)
    {
        $internal = strtolower(trim((string)(isset($doc['icylab_reception_status']) ? $doc['icylab_reception_status'] : '')));
        if (in_array($internal, array('approved','approved_with_observations'), true)) {
            return true;
        }
        $approval = strtolower(trim((string)(isset($doc['document_approval_status']) ? $doc['document_approval_status'] : '')));
        if (in_array($approval, array('1','accepted','approved','approved_with_observations'), true)) {
            return true;
        }
        $inboxStatus = trim((string)(isset($doc['inbox_document_status']) ? $doc['inbox_document_status'] : ''));
        return in_array($inboxStatus, array('2','3'), true);
    }

    private function normalizeEnvironment($environment)
    {
        return $environment === 'production' ? 'production' : 'sandbox';
    }

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

    private function nullableInt($value)
    {
        if ($value === null || $value === '') {
            return null;
        }
        $value = (int)$value;
        return $value > 0 ? $value : null;
    }

    private function formatMoney($amount)
    {
        return '$' . number_format((float)$amount, 0, ',', '.');
    }
}
