<?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__ . '/BillingDocumentTypeService.php';
require_once __DIR__ . '/BillingQuoteCalculator.php';
require_once __DIR__ . '/BillingDraftPayloadFactory.php';
require_once __DIR__ . '/BillingTaxDocumentFactory.php';
require_once __DIR__ . '/FactoDocumentResponseParser.php';
require_once __DIR__ . '/../facto/FactoClient.php';
require_once __DIR__ . '/../facto/FactoFileStorageService.php';
require_once __DIR__ . '/../costs/ProductCostSnapshotService.php';

class BillingManualDocumentService
{
    private $pdo;
    private $types;
    private $calculator;
    private $payloadFactory;
    private $taxFactory;
    private $storage;

    private $supportedKeys = array(
        'invoice_electronic',
        'invoice_exempt_electronic',
        'receipt_electronic',
        'receipt_exempt_electronic',
        'dispatch_guide_electronic'
    );

    public function __construct(?PDO $pdo = null)
    {
        $this->pdo = $pdo ?: db();
        $this->types = new BillingDocumentTypeService($this->pdo);
        $this->calculator = new BillingQuoteCalculator($this->pdo);
        $this->payloadFactory = new BillingDraftPayloadFactory($this->pdo);
        $this->taxFactory = new BillingTaxDocumentFactory();
        $this->storage = new FactoFileStorageService();
    }

    public function catalogs()
    {
        $terms = $this->pdo->query("SELECT id,term_key,term_label,days,is_default FROM billing_payment_terms WHERE is_active=1 ORDER BY days,id")->fetchAll(PDO::FETCH_ASSOC);
        $centers = $this->pdo->query("SELECT id,center_code,center_name,center_type,is_default FROM finance_centers WHERE is_active=1 AND center_type IN ('income','both') ORDER BY is_default DESC,center_name")->fetchAll(PDO::FETCH_ASSOC);
        return array('payment_terms' => $terms, 'income_centers' => $centers);
    }

    public function searchCustomers($query = '', $limit = 25)
    {
        $query = trim((string)$query);
        $limit = max(1, min(50, (int)$limit));
        $sql = "SELECT id,customer_name,commercial_name,legal_name,customer_tax_id,tax_id_type,email,email_dte,phone,address,district,city,country,country_id,export_country_id,export_tax_id_code,export_tax_id_type,business_activity,payment_terms,default_income_center_id
                FROM crm_customers
                WHERE deleted_at IS NULL AND counterparty_type IN ('customer','both')";
        $params = array();
        if ($query !== '') {
            $sql .= " AND (customer_name LIKE ? OR commercial_name LIKE ? OR legal_name LIKE ? OR customer_tax_id LIKE ? OR export_tax_id_code LIKE ?)";
            $like = '%' . $query . '%';
            $params = array($like, $like, $like, $like, $like);
        }
        $sql .= " ORDER BY COALESCE(NULLIF(legal_name,''),NULLIF(commercial_name,''),customer_name) LIMIT " . $limit;
        $stmt = $this->pdo->prepare($sql);
        $stmt->execute($params);
        return $stmt->fetchAll(PDO::FETCH_ASSOC);
    }

    public function searchProducts($query = '', $limit = 30)
    {
        $query = trim((string)$query);
        $limit = max(1, min(60, (int)$limit));
        $sql = "SELECT id,sku,name,description,price,unit,tax_treatment,tax_rate,dte_unit_code,default_income_center_id,export_name,export_description
                FROM products WHERE is_active=1";
        $params = array();
        if ($query !== '') {
            $sql .= " AND (sku LIKE ? OR name LIKE ? OR description LIKE ?)";
            $like = '%' . $query . '%';
            $params = array($like, $like, $like);
        }
        $sql .= " ORDER BY name LIMIT " . $limit;
        $stmt = $this->pdo->prepare($sql);
        $stmt->execute($params);
        return $stmt->fetchAll(PDO::FETCH_ASSOC);
    }

    public function preview(array $input, $userId = null)
    {
        $data = $this->normalizeInput($input);
        billing_assert_runtime_operation('preview', $data['environment'] ?? 'sandbox', 'Preview de documento manual');
        $context = $this->prepareContext($data);
        // Persiste los valores resueltos desde el cliente (centro, condición y receptor),
        // no sólo los datos crudos recibidos desde el formulario.
        $data = $context['data'];
        $payload = $context['payload'];
        if (!isset($payload['options']) || !is_array($payload['options'])) $payload['options'] = array();
        $payload['options']['draft_preview'] = true;
        if ($data['document_key'] === 'dispatch_guide_electronic') {
            $payload['options']['pdf_transfer_copy'] = true;
        }

        $operationId = $this->createOrResetOperation($data, $context, $payload, $userId);
        $response = null;
        try {
            $client = new FactoClient($data['environment']);
            $response = $client->createDocumentDraftPreview($payload, array('generate_stamp' => 1));
            $parser = new FactoDocumentResponseParser(is_array($response) ? $response : array());
            $parser->assertPreviewReady();
            $saved = $this->storage->saveElectronicDocumentFiles(
                'manual_preview_' . $operationId . '_' . $parser->documentId(),
                $parser->electronicDocument(),
                $data['environment']
            );

            $stmt = $this->pdo->prepare("UPDATE billing_manual_operations
                SET status='preview_ready',preview_response_payload=?,preview_facto_document_id=?,preview_document_number=?,
                    preview_pdf_file_path=?,preview_xml_file_path=?,preview_transfer_copy_pdf_file_path=?,
                    error_code=NULL,error_message=NULL,updated_at=NOW()
                WHERE id=?");
            $stmt->execute(array(
                $this->json($parser->safeResponse()),
                $parser->documentId(),
                $parser->documentNumber(),
                $saved['pdf_file_path'] ?? null,
                $saved['xml_file_path'] ?? null,
                $saved['transfer_copy_pdf_file_path'] ?? null,
                $operationId
            ));

            return array(
                'driver' => 'manual',
                'operation_id' => $operationId,
                'document_key' => $data['document_key'],
                'document_label' => $context['document_type']['document_label'],
                'message' => 'Vista previa FACTO generada. Es un borrador sin validez tributaria.',
                'amounts' => $context['calculation'],
                'summary' => array(
                    'receiver_legal_name' => $data['receiver']['legal_name'],
                    'receiver_tax_id_code' => $data['receiver']['tax_id'],
                    'currency_code' => 'CLP',
                    'net_amount' => $context['calculation']['net_amount'] ?? 0,
                    'tax_amount' => $context['calculation']['taxes_amount'] ?? 0,
                    'total_amount' => $context['calculation']['total_amount'] ?? 0,
                    'due_date' => $this->dueDate($data['issue_date'], $data['payment_terms_days']),
                    'guide_type' => $data['document_key'] === 'dispatch_guide_electronic' ? $data['guide_type'] : null,
                    'transfer_type' => $data['document_key'] === 'dispatch_guide_electronic' ? $data['transfer_type'] : null,
                ),
                'preview' => array(
                    'facto_document_id' => $parser->documentId(),
                    'document_number' => $parser->documentNumber(),
                    'has_pdf' => !empty($saved['pdf_file_path']),
                    'has_xml' => !empty($saved['xml_file_path']),
                    'has_transfer_copy' => !empty($saved['transfer_copy_pdf_file_path']),
                    'pdf_url' => !empty($saved['pdf_file_path']) ? '/modules/billing/manual_document_file.php?operation_id=' . $operationId . '&stage=preview&kind=pdf' : null
                )
            );
        } catch (Throwable $e) {
            $this->markError($operationId, 'PREVIEW_ERROR', $e->getMessage(), $response);
            throw $e;
        }
    }

    public function issue($operationId, $confirmation, $userId = null)
    {
        $operationId = (int)$operationId;
        if ($operationId <= 0) throw new InvalidArgumentException('Operación manual inválida.');

        $this->pdo->beginTransaction();
        try {
            $stmt = $this->pdo->prepare('SELECT * FROM billing_manual_operations WHERE id=? FOR UPDATE');
            $stmt->execute(array($operationId));
            $op = $stmt->fetch(PDO::FETCH_ASSOC);
            if (!$op) throw new RuntimeException('Operación manual no encontrada.');
            billing_assert_runtime_operation('issue', $op['environment'] ?? 'sandbox', 'Emisión de documento manual');
            billing_assert_issue_confirmation($confirmation, $op['environment'] ?? 'sandbox');
            if ($op['status'] === 'issued') { $this->pdo->commit(); return $this->operationResult($op); }
            if (in_array($op['status'], array('remote_uncertain','pending_local_commit'), true)) {
                throw new RuntimeException('La operación requiere revisión o reparación. No se puede reenviar para evitar duplicados.');
            }
            if ($op['status'] !== 'preview_ready') throw new RuntimeException('Debes generar un preview verificable antes de emitir.');
            $payload = json_decode((string)$op['issue_request_payload'], true);
            if (!is_array($payload)) throw new RuntimeException('No fue posible recuperar el payload validado.');
            $this->pdo->prepare("UPDATE billing_manual_operations SET status='issuing',issued_by=?,updated_at=NOW() WHERE id=?")
                ->execute(array($userId ?: null, $operationId));
            $this->pdo->commit();
        } catch (Throwable $e) {
            if ($this->pdo->inTransaction()) $this->pdo->rollBack();
            throw $e;
        }

        $response = null;
        $remoteDocumentId = null;
        try {
            $client = new FactoClient($op['environment']);
            $response = $client->createDocument($payload, array('generate_stamp' => 1));
            $parser = new FactoDocumentResponseParser(is_array($response) ? $response : array());
            $parser->assertIssuedReady();
            $remoteDocumentId = $parser->documentId();

            $remote = $client->getDocument($remoteDocumentId, true);
            $remoteParser = new FactoDocumentResponseParser(is_array($remote) ? $remote : array());
            $remoteParser->assertIssuedReady();
            $this->validateRemoteDocument($op, $remoteParser);
            $effective = $remoteParser->documentId() ? $remoteParser : $parser;
            $electronic = $effective->electronicDocument();
            if (empty($electronic)) $electronic = $parser->electronicDocument();
            if (!$effective->hasPdf() && !$parser->hasPdf()) {
                throw new RuntimeException('FACTO emitió el documento, pero no devolvió PDF verificable.');
            }
            $saved = $this->storage->saveElectronicDocumentFiles(
                'manual_issued_' . $operationId . '_' . $remoteDocumentId,
                $electronic,
                $op['environment']
            );
            $documentNumber = $effective->documentNumber() ?: $parser->documentNumber();
            $this->finalizeLocalIssue($op, $payload, is_array($remote) ? $remote : $response, $remoteDocumentId, $documentNumber, $saved, $userId);
            return $this->operationResult($this->getOperation($operationId));
        } catch (Throwable $e) {
            if ($remoteDocumentId !== null) {
                $this->pdo->prepare("UPDATE billing_manual_operations SET status='pending_local_commit',facto_document_id=?,issue_response_payload=?,error_code='REMOTE_ISSUED_LOCAL_PENDING',error_message=?,updated_at=NOW() WHERE id=?")
                    ->execute(array($remoteDocumentId, $this->json($response), $e->getMessage(), $operationId));
            } else {
                $parser = is_array($response) ? new FactoDocumentResponseParser($response) : null;
                $status = $parser ? $parser->resultStatus() : null;
                $newStatus = $status === 0 ? 'remote_uncertain' : 'error';
                $this->pdo->prepare("UPDATE billing_manual_operations SET status=?,issue_response_payload=?,error_code=?,error_message=?,updated_at=NOW() WHERE id=?")
                    ->execute(array($newStatus, $this->json($response), $newStatus === 'remote_uncertain' ? 'REMOTE_UNCERTAIN' : 'ISSUE_ERROR', $e->getMessage(), $operationId));
            }
            throw $e;
        }
    }

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

    private function normalizeInput(array $input)
    {
        $documentKey = trim((string)($input['document_key'] ?? ''));
        if (!in_array($documentKey, $this->supportedKeys, true)) {
            throw new InvalidArgumentException('Tipo documental manual no soportado por este servicio.');
        }
        $environment = strtolower(trim((string)($input['environment'] ?? 'sandbox')));
        $environment = $environment === 'production' ? 'production' : 'sandbox';
        $issueDate = $this->date($input['issue_date'] ?? date('Y-m-d'));
        $customerId = (int)($input['customer_id'] ?? 0);
        $receiver = array(
            'legal_name' => trim((string)($input['receiver_legal_name'] ?? '')),
            'tax_id' => billing_clean_rut($input['receiver_tax_id_code'] ?? ''),
            'tax_id_type' => 'CL-RUT',
            'country_id' => 253,
            'address' => trim((string)($input['receiver_address'] ?? '')),
            'district' => trim((string)($input['receiver_district'] ?? '')),
            'city' => trim((string)($input['receiver_city'] ?? '')),
            'phone' => trim((string)($input['receiver_phone'] ?? '')),
            'activity' => trim((string)($input['receiver_activity'] ?? '')),
            'email' => trim((string)($input['receiver_email'] ?? ''))
        );
        $createCustomer = !empty($input['create_customer']);
        $paymentTermsDays = max(0, (int)($input['payment_terms_days'] ?? 0));
        $financeCenterId = (int)($input['finance_center_id'] ?? 0);
        $globalDiscountType = ($input['global_discount_type'] ?? 'amount') === 'percent' ? 'percent' : 'amount';
        $globalDiscountValue = max(0, (float)($input['global_discount_value'] ?? 0));
        $linesRaw = $input['lines'] ?? array();
        if (is_string($linesRaw)) $linesRaw = json_decode($linesRaw, true);
        if (!is_array($linesRaw)) throw new InvalidArgumentException('Detalle de documento inválido.');
        $lines = array();
        foreach ($linesRaw as $idx => $line) {
            if (!is_array($line)) continue;
            $productId = (int)($line['product_id'] ?? 0);
            $quantity = (float)($line['quantity'] ?? 0);
            $unitPrice = (float)($line['unit_price'] ?? 0);
            $discountType = ($line['discount_type'] ?? 'amount') === 'percent' ? 'percent' : 'amount';
            $discountValue = max(0, (float)($line['discount_value'] ?? 0));
            $description = trim((string)($line['description'] ?? ''));
            if ($description === '' && $productId <= 0) throw new InvalidArgumentException('La línea ' . ($idx + 1) . ' no tiene producto ni glosa.');
            if ($quantity <= 0) throw new InvalidArgumentException('La cantidad de la línea ' . ($idx + 1) . ' debe ser mayor a cero.');
            if ($unitPrice < 0) throw new InvalidArgumentException('El precio de la línea ' . ($idx + 1) . ' no puede ser negativo.');
            $manualSku = trim((string)($line['sku'] ?? ''));
            if ($manualSku === '' && $productId <= 0) {
                $manualSku = 'MAN-' . str_pad((string)($idx + 1), 3, '0', STR_PAD_LEFT);
            }
            $lines[] = array(
                'id' => null,
                'product_id' => $productId ?: null,
                'sku' => $manualSku,
                'name' => $description,
                'description' => trim((string)($line['long_description'] ?? '')),
                'quantity' => $quantity,
                'unit' => trim((string)($line['unit_measure'] ?? 'UN')),
                'price_unit' => $unitPrice,
                'discount_amount' => $discountType === 'amount' ? $discountValue : 0,
                'discount_percent' => $discountType === 'percent' ? $discountValue : 0,
                'tax_treatment' => in_array(($line['tax_treatment'] ?? ''), array('afecto','exento'), true) ? $line['tax_treatment'] : (in_array($documentKey,array('invoice_exempt_electronic','receipt_exempt_electronic'),true) ? 'exento' : 'afecto'),
                'tax_rate' => in_array($documentKey,array('invoice_exempt_electronic','receipt_exempt_electronic'),true) ? 0 : 19,
                'finance_center_id' => (int)($line['finance_center_id'] ?? 0) ?: null,
                '_discount_type' => $discountType,
                '_discount_value' => $discountValue
            );
        }
        if (!$lines) throw new InvalidArgumentException('Debes agregar al menos una línea.');
        $guideType = (int)($input['guide_type'] ?? 1);
        $transferType = (int)($input['transfer_type'] ?? 2);
        if ($documentKey === 'dispatch_guide_electronic') {
            if ($guideType < 1 || $guideType > 9) throw new InvalidArgumentException('Tipo de guía inválido.');
            if ($guideType !== 5 && !in_array($transferType, array(1,2,3), true)) throw new InvalidArgumentException('Tipo de traslado inválido.');
            if ($guideType === 5) $transferType = 0;
        }

        return array(
            'environment' => $environment,
            'manual_key' => trim((string)($input['manual_key'] ?? '')),
            'document_key' => $documentKey,
            'issue_date' => $issueDate,
            'payment_terms_days' => $paymentTermsDays,
            'customer_id' => $customerId,
            'create_customer' => $createCustomer,
            'receiver' => $receiver,
            'finance_center_id' => $financeCenterId,
            'global_discount_type' => $globalDiscountType,
            'global_discount_value' => $globalDiscountValue,
            'guide_type' => $guideType,
            'transfer_type' => $transferType,
            'dispatch_address' => trim((string)($input['dispatch_address'] ?? $receiver['address'])),
            'dispatch_district' => trim((string)($input['dispatch_district'] ?? $receiver['district'])),
            'dispatch_city' => trim((string)($input['dispatch_city'] ?? $receiver['city'])),
            'purchase_order_number' => billing_safe_substr(trim((string)($input['purchase_order_number'] ?? '')), 60),
            'purchase_order_date' => $this->date($input['purchase_order_date'] ?? $issueDate),
            'observations' => trim((string)($input['observations'] ?? 'Emisión manual ICYLAB OS')),
            'lines' => $lines
        );
    }

    private function prepareContext(array $data)
    {
        $type = $this->types->getIssuedType($data['document_key'], $data['environment']);
        if (empty($type['environment_enabled'])) throw new RuntimeException('El tipo documental no está habilitado en ' . $data['environment'] . '.');

        // Defensa adicional: una guía manual nunca puede heredar metadatos de factura
        // desde una fila de catálogo desactualizada. El tipo 54 / SII 52 y la familia
        // dispatch_guide se fuerzan desde la clave funcional seleccionada.
        if ($data['document_key'] === 'dispatch_guide_electronic') {
            $type['facto_document_type_id'] = 54;
            $type['sii_external_code'] = '52';
            $type['document_family'] = 'dispatch_guide';
            $type['affects_ar'] = 0;
            $type['requires_reference'] = 0;
        }
        $customer = $data['customer_id'] > 0 ? $this->loadCustomer($data['customer_id']) : array();
        if ($customer) {
            // Al seleccionar un cliente existente, sus datos maestros son la
            // fuente de verdad del receptor. El destino de una guía continúa
            // siendo editable en los campos de despacho separados.
            $isExportDocument = $data['document_key'] === 'invoice_export_electronic';
            $data['receiver'] = array(
                'legal_name' => trim((string)($customer['legal_name'] ?: ($customer['commercial_name'] ?: $customer['customer_name']))),
                'tax_id' => $isExportDocument
                    ? trim((string)(!empty($customer['export_tax_id_code']) ? $customer['export_tax_id_code'] : ($customer['customer_tax_id'] ?? '')))
                    : billing_clean_rut($customer['customer_tax_id']),
                'tax_id_type' => $isExportDocument
                    ? trim((string)(!empty($customer['export_tax_id_type']) ? $customer['export_tax_id_type'] : ($customer['tax_id_type'] ?? '')))
                    : 'CL-RUT',
                'country_id' => $isExportDocument
                    ? (int)(!empty($customer['export_country_id']) ? $customer['export_country_id'] : (!empty($customer['country_id']) ? $customer['country_id'] : 253))
                    : 253,
                'address' => trim((string)($customer['address'] ?? '')),
                'district' => trim((string)(!empty($customer['district']) ? $customer['district'] : ($customer['region'] ?? ''))),
                'city' => trim((string)($customer['city'] ?? '')),
                'phone' => trim((string)($customer['phone'] ?? '')),
                'activity' => trim((string)($customer['business_activity'] ?? '')),
                'email' => trim((string)(!empty($customer['email_dte']) ? $customer['email_dte'] : ($customer['email'] ?? '')))
            );
            if ($data['finance_center_id'] <= 0) $data['finance_center_id'] = (int)($customer['default_income_center_id'] ?? 0);
            if ($data['payment_terms_days'] <= 0 && preg_match('/(\d+)/', (string)($customer['payment_terms'] ?? ''), $m)) {
                $data['payment_terms_days'] = (int)$m[1];
            }
        }
        $this->validateReceiver($data['receiver'], $type['document_family'] === 'receipt');
        if ($data['document_key'] === 'dispatch_guide_electronic') {
            if ($data['dispatch_address'] === '' || $data['dispatch_city'] === '') throw new InvalidArgumentException('La guía requiere dirección y ciudad de despacho.');
        }

        $quote = array(
            'id' => 0,
            'quote_number' => 'MANUAL-' . date('Ymd-His'),
            'discount_amount' => $data['global_discount_type'] === 'amount' ? $data['global_discount_value'] : 0,
            'discount_percent' => $data['global_discount_type'] === 'percent' ? $data['global_discount_value'] : 0
        );
        $calculation = $this->calculator->calculate($quote, $data['lines'], $type, array());
        if (!empty($calculation['errors'])) throw new RuntimeException(implode(' ', $calculation['errors']));
        if ($calculation['total_amount'] <= 0 && $data['document_key'] !== 'dispatch_guide_electronic') throw new RuntimeException('El total del documento debe ser mayor a cero.');
        $customerPayload = array(
            'legal_name' => $data['receiver']['legal_name'],
            'commercial_name' => $data['receiver']['legal_name'],
            'customer_name' => $data['receiver']['legal_name'],
            'customer_tax_id' => $data['receiver']['tax_id'],
            'tax_id_type' => 'CL-RUT',
            'address' => $data['receiver']['address'],
            'district' => $data['receiver']['district'],
            'city' => $data['receiver']['city'],
            'phone' => $data['receiver']['phone'],
            'business_activity' => $data['receiver']['activity'],
            'email' => $data['receiver']['email'],
            'email_dte' => $data['receiver']['email'],
            'country_id' => 253,
            'default_income_center_id' => $data['finance_center_id'] ?: null
        );
        $options = array(
            'environment' => $data['environment'],
            'issue_date' => $data['issue_date'],
            'payment_terms' => (string)$data['payment_terms_days'],
            'receiver_legal_name' => $data['receiver']['legal_name'],
            'receiver_address' => $data['receiver']['address'],
            'receiver_district' => $data['receiver']['district'],
            'receiver_city' => $data['receiver']['city'],
            'receiver_phone' => $data['receiver']['phone'],
            'receiver_activity' => $data['receiver']['activity'],
            'receiver_email' => $data['receiver']['email'],
            'guide_type' => $data['guide_type'],
            'transfer_type' => $data['transfer_type'],
            'shipping_address' => $data['dispatch_address'],
            'purchase_order_number' => $data['purchase_order_number'],
            'purchase_order_date' => $data['purchase_order_date']
        );
        $payload = $this->payloadFactory->build($quote, $customerPayload, $type, $calculation, $options);
        if ($type['document_family'] === 'receipt') {
            // FACTO no espera giro del receptor en boletas nominativas.
            unset($payload['header']['receiver_activity']);
        }
        $payload['header']['observations'] = 'ICYLAB OS - ' . billing_safe_substr($data['observations'], 220);
        if ($data['document_key'] === 'dispatch_guide_electronic') {
            $payload['header']['receiver_address'] = $data['dispatch_address'];
            $payload['header']['receiver_district'] = $data['dispatch_district'];
            $payload['header']['receiver_city'] = $data['dispatch_city'];
            $payload['options']['pdf_transfer_copy'] = true;
        }
        if (isset($payload['options']['draft_preview'])) unset($payload['options']['draft_preview']);

        return array('data' => $data, 'document_type' => $type, 'customer' => $customer, 'calculation' => $calculation, 'payload' => $payload);
    }

    private function createOrResetOperation(array $data, array $context, array $previewPayload, $userId)
    {
        $issuePayload = $context['payload'];
        $idempotency = $this->idempotency($data, $context['calculation']);
        $existingStmt = $this->pdo->prepare('SELECT * FROM billing_manual_operations WHERE idempotency_key=? LIMIT 1');
        $existingStmt->execute(array($idempotency));
        $existing = $existingStmt->fetch(PDO::FETCH_ASSOC);
        if ($existing && in_array($existing['status'], array('issued','issuing','pending_local_commit','remote_uncertain'), true)) {
            throw new RuntimeException('Ya existe una operación no reintentable para este mismo documento manual.');
        }

        $dueDate = $this->dueDate($data['issue_date'], $data['payment_terms_days']);
        $params = array(
            $data['environment'],$data['document_key'],(int)$context['document_type']['facto_document_type_id'],$context['document_type']['document_family'],
            $data['customer_id'] ?: null,$data['create_customer'] ? 1 : 0,$data['issue_date'],(string)$data['payment_terms_days'],$data['payment_terms_days'],$dueDate,$data['finance_center_id'] ?: null,
            $data['receiver']['legal_name'],$data['receiver']['tax_id'],'CL-RUT',253,$data['receiver']['address'],$data['receiver']['district'],$data['receiver']['city'],$data['receiver']['phone'],$data['receiver']['activity'],$data['receiver']['email'],
            $data['document_key'] === 'dispatch_guide_electronic' ? $data['guide_type'] : null,$data['document_key'] === 'dispatch_guide_electronic' ? $data['transfer_type'] : null,
            $data['dispatch_address'],$data['dispatch_district'],$data['dispatch_city'],$data['global_discount_type'],$data['global_discount_value'],$context['calculation']['global_discount_amount'],
            $context['calculation']['net_amount'],$context['calculation']['exempt_amount'] ?? 0,$context['calculation']['taxes_amount'],$context['calculation']['total_amount'],$data['observations'],'draft',$idempotency,
            $this->json($previewPayload),$this->json($issuePayload),$userId ?: null
        );

        $this->pdo->beginTransaction();
        try {
            if ($existing) {
                $sql = "UPDATE billing_manual_operations SET environment=?,document_key=?,document_type_id=?,document_family=?,customer_id=?,create_customer=?,issue_date=?,payment_terms=?,payment_terms_days=?,due_date=?,finance_center_id=?,receiver_legal_name=?,receiver_tax_id_code=?,receiver_tax_id_type=?,receiver_country_id=?,receiver_address=?,receiver_district=?,receiver_city=?,receiver_phone=?,receiver_activity=?,receiver_email=?,guide_type=?,transfer_type=?,dispatch_address=?,dispatch_district=?,dispatch_city=?,global_discount_type=?,global_discount_value=?,global_discount_amount=?,net_amount=?,exempt_amount=?,tax_amount=?,total_amount=?,observations=?,status=?,idempotency_key=?,preview_request_payload=?,issue_request_payload=?,preview_response_payload=NULL,preview_facto_document_id=NULL,preview_document_number=NULL,preview_pdf_file_path=NULL,preview_xml_file_path=NULL,preview_transfer_copy_pdf_file_path=NULL,error_code=NULL,error_message=NULL,created_by=?,updated_at=NOW() WHERE id=?";
                $params[] = (int)$existing['id'];
                $this->pdo->prepare($sql)->execute($params);
                $operationId = (int)$existing['id'];
                $this->pdo->prepare('DELETE FROM billing_manual_operation_lines WHERE operation_id=?')->execute(array($operationId));
            } else {
                $sql = "INSERT INTO billing_manual_operations (environment,document_key,document_type_id,document_family,customer_id,create_customer,issue_date,payment_terms,payment_terms_days,due_date,finance_center_id,receiver_legal_name,receiver_tax_id_code,receiver_tax_id_type,receiver_country_id,receiver_address,receiver_district,receiver_city,receiver_phone,receiver_activity,receiver_email,guide_type,transfer_type,dispatch_address,dispatch_district,dispatch_city,global_discount_type,global_discount_value,global_discount_amount,net_amount,exempt_amount,tax_amount,total_amount,observations,status,idempotency_key,preview_request_payload,issue_request_payload,created_by) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)";
                $this->pdo->prepare($sql)->execute($params);
                $operationId = (int)$this->pdo->lastInsertId();
            }
            $this->insertLines($operationId, $context['calculation']['lines'], $data['lines']);
            $this->pdo->commit();
            return $operationId;
        } catch (Throwable $e) {
            if ($this->pdo->inTransaction()) $this->pdo->rollBack();
            throw $e;
        }
    }

    private function insertLines($operationId, array $calculated, array $inputLines)
    {
        $stmt = $this->pdo->prepare("INSERT INTO billing_manual_operation_lines (operation_id,product_id,sku,description,long_description,quantity,unit_measure,unit_price,discount_type,discount_value,discount_amount,net_amount,tax_amount,total_amount,tax_treatment,finance_center_id) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)");
        foreach ($calculated as $i => $line) {
            $original = $inputLines[$i] ?? array();
            $stmt->execute(array(
                $operationId,$line['product_id'] ?: null,$line['sku'],$line['name'],$line['description'],$line['quantity'],$line['unit_measure'],$line['unit_price'],
                $original['_discount_type'] ?? 'amount',$original['_discount_value'] ?? 0,$line['line_discount_amount'],$line['net_amount'],$line['tax_amount'],$line['gross_amount'],$line['tax_treatment'],$line['finance_center_id'] ?: null
            ));
        }
    }

    private function finalizeLocalIssue(array $op, array $payload, array $response, $documentId, $documentNumber, array $saved, $userId)
    {
        $parser = new FactoDocumentResponseParser(is_array($response) ? $response : array());
        $header = $parser->header();
        $totals = $parser->totals();
        $isGuide = $this->isGuideOperation($op);
        $canonicalDocumentTypeId = $isGuide ? 54 : (int)$op['document_type_id'];
        $canonicalDocumentFamily = $isGuide ? 'dispatch_guide' : (string)$op['document_family'];
        $customerId = (int)$op['customer_id'];
        if ($customerId <= 0 && (int)$op['create_customer'] === 1) {
            $customerId = $this->findOrCreateCustomer($op);
        }
        $this->pdo->beginTransaction();
        try {
            $this->pdo->prepare("UPDATE billing_manual_operations SET document_type_id=?,document_family=?,status='issued',issue_response_payload=?,facto_document_id=?,document_number=?,pdf_file_path=?,xml_file_path=?,transfer_copy_pdf_file_path=?,customer_id=?,error_code=NULL,error_message=NULL,issued_by=?,issued_at=NOW(),updated_at=NOW() WHERE id=?")
                ->execute(array($canonicalDocumentTypeId,$canonicalDocumentFamily,$this->json($parser->safeResponse()),$documentId,$documentNumber,$saved['pdf_file_path'] ?? null,$saved['xml_file_path'] ?? null,$saved['transfer_copy_pdf_file_path'] ?? null,$customerId ?: null,$userId ?: null,$op['id']));

            $raw = $this->json($parser->safeResponse());
            $stmt = $this->pdo->prepare("INSERT INTO facto_documents
                (document_id,environment,account_id,issue_date,document_number,document_type_id,issuer_tax_id_code,
                 receiver_legal_name,receiver_tax_id_code,crm_customer_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,document_type_taxbureau,pdf_file_path,xml_file_path,
                 raw_payload,payload_hash,last_synced_at,source_mode)
                VALUES (:document_id,:environment,:account_id,:issue_date,:document_number,:document_type_id,:issuer_tax_id_code,
                        :receiver_legal_name,:receiver_tax_id_code,:crm_customer_id,:finance_center_id,:link_status,:link_confidence,
                        :linked_by_user_id,NOW(),:document_status,:currency_id,1,:net_amount,:taxes_amount,:total_amount,
                        :payment_status,:payment_due_date,:document_type_taxbureau,:pdf_file_path,:xml_file_path,
                        :raw_payload,SHA2(:payload_hash_source,256),NOW(),'manual')
                ON DUPLICATE KEY UPDATE
                    document_number=VALUES(document_number),document_type_id=VALUES(document_type_id),
                    document_type_taxbureau=VALUES(document_type_taxbureau),crm_customer_id=VALUES(crm_customer_id),
                    finance_center_id=VALUES(finance_center_id),net_amount=VALUES(net_amount),taxes_amount=VALUES(taxes_amount),
                    total_amount=VALUES(total_amount),payment_status=VALUES(payment_status),payment_due_date=VALUES(payment_due_date),
                    payment_paid_date=VALUES(payment_paid_date),pdf_file_path=VALUES(pdf_file_path),xml_file_path=VALUES(xml_file_path),
                    raw_payload=VALUES(raw_payload),payload_hash=VALUES(payload_hash),source_mode='manual',last_synced_at=NOW()");
            $stmt->execute(array(
                ':document_id' => $documentId,
                ':environment' => $op['environment'],
                ':account_id' => $header['account_id'] ?? null,
                ':issue_date' => $header['issue_date'] ?? $op['issue_date'],
                ':document_number' => $documentNumber,
                ':document_type_id' => $canonicalDocumentTypeId,
                ':issuer_tax_id_code' => $header['issuer_tax_id_code'] ?? null,
                ':receiver_legal_name' => $op['receiver_legal_name'],
                ':receiver_tax_id_code' => $op['receiver_tax_id_code'],
                ':crm_customer_id' => $customerId ?: null,
                ':finance_center_id' => $op['finance_center_id'] ?: null,
                ':link_status' => $customerId ? 'manual_linked' : 'unlinked',
                ':link_confidence' => $customerId ? 100 : null,
                ':linked_by_user_id' => $userId ?: null,
                ':document_status' => $header['document_status'] ?? 1,
                ':currency_id' => $header['currency_id'] ?? 39,
                ':net_amount' => $totals['net_amount'] ?? $op['net_amount'],
                ':taxes_amount' => $totals['taxes_amount'] ?? $op['tax_amount'],
                ':total_amount' => $totals['total_amount'] ?? $op['total_amount'],
                ':payment_status' => $isGuide ? 'no_facturado' : 'por_cobrar',
                ':payment_due_date' => $isGuide ? null : $op['due_date'],
                ':document_type_taxbureau' => $this->siiCode($canonicalDocumentTypeId),
                ':pdf_file_path' => $saved['pdf_file_path'] ?? null,
                ':xml_file_path' => $saved['xml_file_path'] ?? null,
                ':raw_payload' => $raw,
                ':payload_hash_source' => $raw
            ));

            $this->pdo->prepare("DELETE FROM billing_document_lines WHERE environment=? AND facto_document_id=?")->execute(array($op['environment'], $documentId));
            $lines = $this->pdo->prepare('SELECT * FROM billing_manual_operation_lines WHERE operation_id=? ORDER BY id');
            $lines->execute(array($op['id']));
            $insertLine = $this->pdo->prepare("INSERT INTO billing_document_lines (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) VALUES (?,?,NULL,NULL,?,?,?,?,?, ?,0,?,?,?,?,?,?,?)");
            foreach ($lines->fetchAll(PDO::FETCH_ASSOC) as $line) {
                $insertLine->execute(array($op['environment'],$documentId,$line['product_id'],$line['sku'],$line['description'],$line['quantity'],$line['unit_measure'],$line['unit_price'],$line['discount_amount'],$line['net_amount'],$line['tax_amount'],$line['total_amount'],$line['tax_treatment'],$line['finance_center_id'],$this->json($line)));
            }

            if ($isGuide) {
                // La guía es logística: no crea ni conserva una cuenta por cobrar.
                $this->removeErroneousGuideReceivable($documentId, $op['environment']);
                $this->persistManualGuide($op, $documentId, $documentNumber, $saved, $customerId, $userId);
            } else {
                $this->persistReceivable($op, $documentId, $documentNumber, $customerId);
            }
            $this->pdo->commit();
            if (!$isGuide) {
                (new ProductCostSnapshotService($this->pdo))->snapshotDocumentSafe($op['environment'], $documentId, $userId);
            }
        } catch (Throwable $e) {
            if ($this->pdo->inTransaction()) $this->pdo->rollBack();
            throw $e;
        }
    }

    private function persistReceivable(array $op, $documentId, $documentNumber, $customerId)
    {
        if ($this->isGuideOperation($op)) {
            throw new LogicException('Una guía de despacho no puede crear una cuenta por cobrar.');
        }
        $stmt = $this->pdo->prepare("INSERT INTO billing_collections (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) VALUES (?,'receivable','facto_documents',?,NULL,NULL,?,?,?,?,?,?,0,?,'por_cobrar',?,NULL,?,?) ON DUPLICATE KEY UPDATE customer_id=VALUES(customer_id),document_number=VALUES(document_number),due_date=VALUES(due_date),total_amount=VALUES(total_amount),balance_amount=VALUES(balance_amount),collection_status=VALUES(collection_status),payment_terms=VALUES(payment_terms),finance_center_id=VALUES(finance_center_id),updated_at=CURRENT_TIMESTAMP");
        $stmt->execute(array($op['environment'],$documentId,$customerId ?: null,$documentNumber,$op['document_type_id'],$op['issue_date'],$op['due_date'],$op['total_amount'],$op['total_amount'],(string)$op['payment_terms_days'],$op['finance_center_id'] ?: null,'Documento manual emitido desde ICYLAB OS.'));
    }

    private function persistManualGuide(array $op, $documentId, $documentNumber, array $saved, $customerId, $userId)
    {
        $stmt = $this->pdo->prepare("INSERT INTO billing_dispatch_guides
            (environment,source_type,source_document_local_id,source_document_number,quote_id,opportunity_id,customer_id,finance_center_id,
             facto_document_id,related_invoice_facto_document_id,document_number,issue_date,guide_type,transfer_type,
             dispatch_address,dispatch_district,dispatch_city,observations,idempotency_key,
             preview_request_payload,preview_response_payload,preview_facto_document_id,preview_document_number,
             preview_pdf_file_path,preview_xml_file_path,preview_transfer_copy_pdf_file_path,
             issue_request_payload,issue_response_payload,pdf_file_path,xml_file_path,transfer_copy_pdf_file_path,
             stock_effect,status,error_code,error_message,created_by,issued_by,issued_at,created_at,updated_at)
            VALUES (?,'manual',NULL,NULL,NULL,NULL,?,?, ?,NULL,?, ?,?,?, ?,?,?,?, ?, ?,?,?,?,?,?,?, ?,?,?,?,?, 'none','issued',NULL,NULL,?,?,NOW(),NOW(),NOW())
            ON DUPLICATE KEY UPDATE
                customer_id=VALUES(customer_id),finance_center_id=VALUES(finance_center_id),facto_document_id=VALUES(facto_document_id),document_number=VALUES(document_number),
                issue_date=VALUES(issue_date),guide_type=VALUES(guide_type),transfer_type=VALUES(transfer_type),
                dispatch_address=VALUES(dispatch_address),dispatch_district=VALUES(dispatch_district),dispatch_city=VALUES(dispatch_city),
                observations=VALUES(observations),preview_response_payload=VALUES(preview_response_payload),
                preview_facto_document_id=VALUES(preview_facto_document_id),preview_document_number=VALUES(preview_document_number),
                preview_pdf_file_path=VALUES(preview_pdf_file_path),preview_xml_file_path=VALUES(preview_xml_file_path),
                preview_transfer_copy_pdf_file_path=VALUES(preview_transfer_copy_pdf_file_path),
                issue_request_payload=VALUES(issue_request_payload),issue_response_payload=VALUES(issue_response_payload),
                pdf_file_path=VALUES(pdf_file_path),xml_file_path=VALUES(xml_file_path),
                transfer_copy_pdf_file_path=VALUES(transfer_copy_pdf_file_path),stock_effect='none',status='issued',
                error_code=NULL,error_message=NULL,issued_by=VALUES(issued_by),issued_at=NOW(),updated_at=NOW()");
        $stmt->execute(array(
            $op['environment'],
            $customerId ?: null,
            $op['finance_center_id'] ?: null,
            $documentId,
            $documentNumber,
            $op['issue_date'],
            $op['guide_type'],
            $op['transfer_type'],
            $op['dispatch_address'],
            $op['dispatch_district'],
            $op['dispatch_city'],
            $op['observations'],
            $op['idempotency_key'],
            $op['preview_request_payload'],
            $op['preview_response_payload'],
            $op['preview_facto_document_id'],
            $op['preview_document_number'],
            $op['preview_pdf_file_path'],
            $op['preview_xml_file_path'],
            $op['preview_transfer_copy_pdf_file_path'],
            $op['issue_request_payload'],
            $op['issue_response_payload'],
            $saved['pdf_file_path'] ?? null,
            $saved['xml_file_path'] ?? null,
            $saved['transfer_copy_pdf_file_path'] ?? null,
            $userId ?: null,
            $userId ?: null
        ));

        $find = $this->pdo->prepare('SELECT id FROM billing_dispatch_guides WHERE idempotency_key=? LIMIT 1');
        $find->execute(array($op['idempotency_key']));
        $guideId = (int)$find->fetchColumn();
        if ($guideId <= 0) throw new RuntimeException('No fue posible recuperar la guía manual registrada.');

        $this->pdo->prepare('DELETE FROM billing_dispatch_guide_lines WHERE dispatch_guide_id=?')->execute(array($guideId));
        $lines = $this->pdo->prepare('SELECT * FROM billing_manual_operation_lines WHERE operation_id=? ORDER BY id');
        $lines->execute(array($op['id']));
        $insert = $this->pdo->prepare("INSERT INTO billing_dispatch_guide_lines
            (dispatch_guide_id,source_line_id,product_id,sku,description,long_description,source_quantity,dispatch_quantity,
             unit_measure,unit_price,net_amount,tax_amount,total_amount,tax_treatment,raw_payload,created_at,updated_at)
            VALUES (?,NULL,?,?,?,?,?,?,?,?,?,?,?,?,?,NOW(),NOW())");
        foreach ($lines->fetchAll(PDO::FETCH_ASSOC) as $line) {
            $insert->execute(array(
                $guideId,
                $line['product_id'],
                $line['sku'],
                $line['description'],
                $line['long_description'],
                $line['quantity'],
                $line['quantity'],
                $line['unit_measure'],
                $line['unit_price'],
                $line['net_amount'],
                $line['tax_amount'],
                $line['total_amount'],
                $line['tax_treatment'],
                $this->json($line)
            ));
        }
    }

    private function isGuideOperation(array $op)
    {
        return (string)($op['document_key'] ?? '') === 'dispatch_guide_electronic'
            || (int)($op['document_type_id'] ?? 0) === 54;
    }

    private function expectedDocumentTypeId(array $op)
    {
        if ($this->isGuideOperation($op)) return 54;
        $key = (string)($op['document_key'] ?? '');
        if ($key === 'invoice_electronic') return 2;
        if ($key === 'invoice_exempt_electronic') return 32;
        if ($key === 'receipt_electronic') return 37;
        if ($key === 'receipt_exempt_electronic') return 41;
        return (int)($op['document_type_id'] ?? 0);
    }

    private function removeErroneousGuideReceivable($documentId, $environment)
    {
        // Refuerza el estado logístico antes de limpiar cualquier colección
        // creada por una rutina histórica de cobranza.
        $this->pdo->prepare("UPDATE facto_documents SET document_type_id=54,document_type_taxbureau='52',payment_status='no_facturado',payment_due_date=NULL,payment_paid_date=NULL,updated_at=CURRENT_TIMESTAMP WHERE environment=? AND document_id=?")
            ->execute(array($environment, (string)$documentId));

        $stmt = $this->pdo->prepare("SELECT id,notes FROM billing_collections WHERE environment=? AND direction='receivable' AND facto_document_id=?");
        $stmt->execute(array($environment, (string)$documentId));
        foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $collection) {
            $payments = $this->pdo->prepare("SELECT COUNT(*) FROM billing_payments WHERE COALESCE(is_voided,0)=0 AND (collection_id=? OR (environment=? AND facto_document_id=?))");
            $payments->execute(array((int)$collection['id'], $environment, (string)$documentId));
            if ((int)$payments->fetchColumn() > 0) {
                $notes = trim((string)($collection['notes'] ?? ''));
                if (strpos($notes, '[REVISAR GUIA MANUAL]') === false) {
                    $notes .= ($notes !== '' ? "
" : '') . '[REVISAR GUIA MANUAL] La guía posee pagos asociados y requiere revisión financiera.';
                    $this->pdo->prepare('UPDATE billing_collections SET notes=?,updated_at=CURRENT_TIMESTAMP WHERE id=?')
                        ->execute(array($notes, (int)$collection['id']));
                }
                continue;
            }
            $this->pdo->prepare('DELETE FROM billing_collections WHERE id=?')->execute(array((int)$collection['id']));
        }
    }

    private function validateRemoteDocument(array $op, FactoDocumentResponseParser $parser)
    {
        $header = $parser->header();
        $totals = $parser->totals();
        if ((int)($header['document_type_id'] ?? 0) !== $this->expectedDocumentTypeId($op)) throw new RuntimeException('FACTO devolvió un tipo documental distinto al solicitado.');
        if (billing_clean_rut($header['receiver_tax_id_code'] ?? '') !== billing_clean_rut($op['receiver_tax_id_code'])) throw new RuntimeException('El receptor remoto no coincide con la operación.');
        if (abs((float)($totals['total_amount'] ?? 0) - (float)$op['total_amount']) > 0.51) throw new RuntimeException('El total remoto no coincide con la operación manual.');
    }

    private function validateReceiver(array $receiver, $isReceipt = false)
    {
        if (!$this->taxFactory->isValidChileRut($receiver['tax_id'])) throw new InvalidArgumentException('El RUT del receptor no es válido.');
        $required = array('legal_name'=>'razón social','address'=>'dirección','district'=>'comuna','city'=>'ciudad');
        if (!$isReceipt) $required['activity'] = 'giro';
        foreach ($required as $key=>$label) {
            if (trim((string)$receiver[$key]) === '') throw new InvalidArgumentException('Falta ' . $label . ' del receptor.');
        }
    }

    private function loadCustomer($id)
    {
        $stmt = $this->pdo->prepare("SELECT * FROM crm_customers WHERE id=? AND deleted_at IS NULL LIMIT 1");
        $stmt->execute(array((int)$id));
        $row = $stmt->fetch(PDO::FETCH_ASSOC);
        if (!$row) throw new RuntimeException('Cliente seleccionado no encontrado.');
        return $row;
    }

    private function findOrCreateCustomer(array $op)
    {
        $rut = billing_clean_rut($op['receiver_tax_id_code']);
        $stmt = $this->pdo->prepare("SELECT id FROM crm_customers WHERE REPLACE(REPLACE(UPPER(customer_tax_id),'.',''),' ','')=REPLACE(REPLACE(UPPER(?),'.',''),' ','') AND deleted_at IS NULL LIMIT 1");
        $stmt->execute(array($rut));
        $id = (int)$stmt->fetchColumn();
        if ($id > 0) return $id;
        $stmt = $this->pdo->prepare("INSERT INTO crm_customers (counterparty_type,customer_name,commercial_name,legal_name,customer_tax_id,tax_id_type,email,email_dte,phone,address,payment_terms,default_income_center_id,district,city,country,country_id,business_activity,source,is_supplier,supplier_status,created_at,updated_at) VALUES ('customer',?,?,?,?, 'CL-RUT',?,?,?,?,?,?,?,?,'Chile',253,?,'manual_billing',0,'active',NOW(),NOW())");
        $stmt->execute(array($op['receiver_legal_name'],$op['receiver_legal_name'],$op['receiver_legal_name'],$rut,$op['receiver_email'],$op['receiver_email'],$op['receiver_phone'],$op['receiver_address'],$op['payment_terms_days'] > 0 ? $op['payment_terms_days'] . ' dias' : 'Contado',$op['finance_center_id'] ?: null,$op['receiver_district'],$op['receiver_city'],$op['receiver_activity']));
        return (int)$this->pdo->lastInsertId();
    }

    private function operationResult(array $op)
    {
        return array(
            'driver' => 'manual',
            'operation_id' => (int)$op['id'],
            'document_key' => $op['document_key'],
            'status' => $op['status'],
            'issue' => array(
                'facto_document_id' => $op['facto_document_id'],
                'document_number' => $op['document_number'],
                'pdf_url' => $op['pdf_file_path'] ? '/modules/billing/manual_document_file.php?operation_id=' . (int)$op['id'] . '&stage=issued&kind=pdf' : null,
                'xml_url' => $op['xml_file_path'] ? '/modules/billing/manual_document_file.php?operation_id=' . (int)$op['id'] . '&stage=issued&kind=xml' : null,
                'transfer_copy_url' => $op['transfer_copy_pdf_file_path'] ? '/modules/billing/manual_document_file.php?operation_id=' . (int)$op['id'] . '&stage=issued&kind=transfer_pdf' : null
            )
        );
    }

    private function markError($operationId, $code, $message, $response = null)
    {
        $this->pdo->prepare("UPDATE billing_manual_operations SET status='error',error_code=?,error_message=?,preview_response_payload=COALESCE(?,preview_response_payload),updated_at=NOW() WHERE id=?")
            ->execute(array($code,$message,$response !== null ? $this->json($response) : null,(int)$operationId));
    }

    private function idempotency(array $data, array $calc)
    {
        $manualKey = $data['manual_key'] !== '' ? $data['manual_key'] : hash('sha256', $this->json(array($data['document_key'],$data['issue_date'],$data['receiver']['tax_id'],$data['lines'])));
        $fingerprint = array($manualKey,$data['document_key'],$data['issue_date'],$data['receiver']['tax_id'],$calc['total_amount'],$data['guide_type'],$data['transfer_type'],$data['lines']);
        return 'manual:' . $data['environment'] . ':' . hash('sha256', $this->json($fingerprint));
    }

    private function dueDate($issueDate, $days)
    {
        $d = new DateTime($issueDate);
        if ((int)$days > 0) $d->modify('+' . (int)$days . ' days');
        return $d->format('Y-m-d');
    }

    private function siiCode($typeId)
    {
        $map = array(2=>'33',32=>'34',37=>'39',41=>'41',54=>'52');
        return $map[(int)$typeId] ?? null;
    }

    private function date($value)
    {
        $raw = substr(trim((string)$value), 0, 10);
        $d = DateTime::createFromFormat('Y-m-d', $raw);
        if (!$d || $d->format('Y-m-d') !== $raw) throw new InvalidArgumentException('Fecha de emisión inválida.');
        return $raw;
    }

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