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

class BillingComplementaryDocumentService
{
    private $pdo;
    private $types;
    private $factory;
    private $storage;
    private $exchangeRates;

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

    public function previewAdjustment($sourceLocalId, array $data, $userId = null)
    {
        $environment = $this->normalizeEnvironment($data['environment'] ?? 'sandbox');
        billing_assert_runtime_operation('preview', $environment, 'Preview de nota tributaria');
        $context = $this->prepareContext((int)$sourceLocalId, $data, $environment);
        $payload = $context['payload'];
        if (!isset($payload['options']) || !is_array($payload['options'])) {
            $payload['options'] = array();
        }
        $payload['options']['draft_preview'] = true;

        $operation = $this->createOrResetOperation($context, $payload, $userId);

        try {
            $client = new FactoClient($environment);
            $response = $client->createDocumentDraftPreview($payload, array('generate_stamp' => 1));
            $parser = new FactoDocumentResponseParser(is_array($response) ? $response : array());
            $parser->assertPreviewReady();

            $previewDocumentId = $parser->documentId();
            $previewDocumentNumber = $parser->documentNumber();
            $saved = $this->storage->saveElectronicDocumentFiles(
                'phase8_preview_' . (int)$operation['id'] . '_' . $previewDocumentId,
                $parser->electronicDocument(),
                $environment
            );

            $stmt = $this->pdo->prepare("\n                UPDATE billing_tax_operations\n                SET status='preview_ready', preview_response_payload=?, preview_facto_document_id=?,\n                    preview_document_number=?, preview_pdf_file_path=?, preview_xml_file_path=?,\n                    error_code=NULL, error_message=NULL, updated_at=NOW()\n                WHERE id=?\n            ");
            $stmt->execute(array(
                $this->jsonEncode($parser->safeResponse()),
                $previewDocumentId,
                $previewDocumentNumber,
                $saved['pdf_file_path'] ?? null,
                $saved['xml_file_path'] ?? null,
                (int)$operation['id']
            ));

            return array(
                'operation_id' => (int)$operation['id'],
                'status' => 'preview_ready',
                'environment' => $environment,
                'operation_type' => $context['operation_type'],
                'source' => $context['source_summary'],
                'amounts' => $context['amounts'],
                'reference' => array(
                    'code' => $context['reference_code'],
                    'date' => $context['reference_date'],
                    'source_document_id' => $context['source']['document_id'],
                    'source_document_number' => $context['source']['document_number']
                ),
                'preview' => array(
                    'document_id' => $previewDocumentId,
                    'document_number' => $previewDocumentNumber,
                    'pdf_available' => !empty($saved['pdf_file_path']),
                    'xml_available' => !empty($saved['xml_file_path'])
                )
            );
        } catch (Throwable $e) {
            $this->markError((int)$operation['id'], 'preview_error', $e->getMessage(), isset($response) && is_array($response) ? $response : null, 'preview');
            throw $e;
        }
    }

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

        $this->pdo->beginTransaction();
        try {
            $operation = $this->getOperationForUpdate($operationId);
            billing_assert_runtime_operation('issue', $operation['environment'] ?? 'sandbox', 'Emisión de nota tributaria');
            billing_assert_issue_confirmation($confirmation, $operation['environment'] ?? 'sandbox');
            if ($operation['status'] === 'issued') {
                $this->pdo->commit();
                return $this->operationResult($operation);
            }
            if ($operation['status'] === 'remote_uncertain') {
                throw new RuntimeException('Esta operación quedó con resultado remoto incierto. No se puede reenviar para evitar una nota duplicada.');
            }
            if ($operation['status'] !== 'preview_ready') {
                throw new RuntimeException('La operación debe tener una previsualización válida antes de emitir.');
            }

            $this->revalidateCollectionBeforeIssue($operation);
            $payload = json_decode((string)$operation['issue_request_payload'], true);
            if (!is_array($payload)) {
                throw new RuntimeException('No se encontró el payload de emisión validado.');
            }

            $this->pdo->prepare("UPDATE billing_tax_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($operation['environment']);
            $response = $client->createDocument($payload, array('generate_stamp' => 1));
            $parser = new FactoDocumentResponseParser(is_array($response) ? $response : array());
            $parser->assertIssuedReady();
            $remoteDocumentId = $parser->documentId();

            $detailResponse = $client->getDocument($remoteDocumentId, true);
            $detailParser = new FactoDocumentResponseParser(is_array($detailResponse) ? $detailResponse : array());
            $detailParser->assertSuccess('consulta del documento emitido');
            $this->validateIssuedDocumentAgainstOperation($operation, $detailParser);

            $effectiveParser = $detailParser->documentId() !== null ? $detailParser : $parser;
            $electronic = $effectiveParser->electronicDocument();
            if (!$effectiveParser->hasPdf() && !$parser->hasPdf()) {
                throw new RuntimeException('FACTO emitió el documento, pero no devolvió PDF verificable. La operación quedó pendiente de cierre local.');
            }
            if (empty($electronic)) {
                $electronic = $parser->electronicDocument();
            }
            $saved = $this->storage->saveElectronicDocumentFiles(
                'phase8_issued_' . $operationId . '_' . $remoteDocumentId,
                $electronic,
                $operation['environment']
            );
            $documentNumber = $effectiveParser->documentNumber() ?: $parser->documentNumber();

            $this->finalizeLocalIssue(
                $operationId,
                $payload,
                is_array($detailResponse) ? $detailResponse : $response,
                $remoteDocumentId,
                $documentNumber,
                $saved,
                $userId
            );

            return $this->operationResult($this->getOperation($operationId));
        } catch (Throwable $e) {
            if ($remoteDocumentId !== null) {
                $this->markPendingLocalCommit($operationId, $remoteDocumentId, $response, $e->getMessage());
            } else {
                $parser = is_array($response) ? new FactoDocumentResponseParser($response) : null;
                $status = $parser ? $parser->resultStatus() : null;
                if ($status === 0) {
                    $this->markRemoteUncertain($operationId, $response, $e->getMessage());
                } else {
                    $this->markError($operationId, 'issue_error', $e->getMessage(), is_array($response) ? $response : null, 'issue');
                }
            }
            throw $e;
        }
    }

    public function repairLocalCommit($operationId, $userId = null)
    {
        $operation = $this->getOperation((int)$operationId);
        billing_assert_runtime_operation('finance', $operation['environment'] ?? 'sandbox', 'Reparación del cierre local de nota tributaria');
        if (!in_array($operation['status'], array('pending_local_commit'), true)) {
            throw new RuntimeException('La operación no requiere reparación local.');
        }
        if (empty($operation['facto_document_id'])) {
            throw new RuntimeException('La operación no tiene document_id remoto para reparar.');
        }
        $payload = json_decode((string)$operation['issue_request_payload'], true);
        if (!is_array($payload)) {
            throw new RuntimeException('No se encontró el payload original.');
        }

        $client = new FactoClient($operation['environment']);
        $response = $client->getDocument($operation['facto_document_id'], true);
        $parser = new FactoDocumentResponseParser(is_array($response) ? $response : array());
        $parser->assertSuccess('reparación local');
        $this->validateIssuedDocumentAgainstOperation($operation, $parser);
        $saved = $this->storage->saveElectronicDocumentFiles(
            'phase8_issued_' . (int)$operation['id'] . '_' . $operation['facto_document_id'],
            $parser->electronicDocument(),
            $operation['environment']
        );
        $this->finalizeLocalIssue(
            (int)$operation['id'],
            $payload,
            $response,
            $operation['facto_document_id'],
            $parser->documentNumber(),
            $saved,
            $userId
        );
        return $this->operationResult($this->getOperation((int)$operation['id']));
    }

    public function getOperation($operationId)
    {
        $stmt = $this->pdo->prepare("SELECT * FROM billing_tax_operations WHERE id=? LIMIT 1");
        $stmt->execute(array((int)$operationId));
        $row = $stmt->fetch(PDO::FETCH_ASSOC);
        if (!$row) {
            throw new RuntimeException('Operación tributaria no encontrada.');
        }
        return $row;
    }

    private function prepareContext($sourceLocalId, array $data, $environment)
    {
        $source = $this->loadSourceDocument($sourceLocalId, $environment);
        $sourceTypeId = (int)$source['document_type_id'];
        $isExport = $sourceTypeId === 46;
        if (!in_array($sourceTypeId, array(2, 32, 46), true)) {
            throw new RuntimeException('Sólo se pueden emitir notas desde facturas afectas, exentas o de exportación.');
        }

        $operationType = trim((string)($data['operation_type'] ?? ''));
        if (!in_array($operationType, array('credit_note', 'debit_note'), true)) {
            throw new InvalidArgumentException('Tipo de operación no válido.');
        }
        $mode = trim((string)($data['mode'] ?? 'partial'));
        if (!in_array($mode, array('partial', 'full'), true)) $mode = 'partial';
        if ($operationType === 'debit_note') $mode = 'partial';
        $reason = trim((string)($data['reason'] ?? ''));
        if ($reason === '') throw new InvalidArgumentException('Debes informar el motivo tributario.');

        $collection = $this->ensureSourceCollection($source);
        $exchangeRate = max(0.000001, (float)($source['exchange_rate_value'] ?? $collection['exchange_rate_value'] ?? 1));
        if ($isExport) {
            $sourceTotal = round((float)$source['total_amount'], 4);
            $paid = $collection['foreign_paid_amount'] !== null
                ? round((float)$collection['foreign_paid_amount'], 4)
                : round((float)$collection['paid_amount'] / $exchangeRate, 4);
            $balance = $collection['foreign_balance_amount'] !== null
                ? round((float)$collection['foreign_balance_amount'], 4)
                : max(0, round($sourceTotal - $paid, 4));
            $collectionTotal = $collection['foreign_total_amount'] !== null
                ? round((float)$collection['foreign_total_amount'], 4)
                : $sourceTotal;
        } else {
            $sourceTotal = billing_money($source['total_amount']);
            $paid = billing_money($collection['paid_amount']);
            $balance = billing_money($collection['balance_amount']);
            $collectionTotal = billing_money($collection['total_amount']);
        }

        if ($operationType === 'credit_note' && $mode === 'full') {
            if ($paid > 0.005) throw new RuntimeException('No se puede anular completamente una factura con pagos registrados.');
            $tolerance = $isExport ? 0.02 : 0.5;
            if (abs($collectionTotal - $sourceTotal) > $tolerance) {
                throw new RuntimeException('La factura ya tiene ajustes. Usa una nota de crédito parcial por el saldo vigente.');
            }
            $totalAmount = $sourceTotal;
            $referenceCode = 1;
        } else {
            $totalAmount = $isExport ? round((float)($data['amount'] ?? 0), 4) : billing_money($data['amount'] ?? 0);
            $referenceCode = 3;
        }
        if ($totalAmount <= 0) throw new InvalidArgumentException('El monto del ajuste debe ser mayor a cero.');
        $tolerance = $isExport ? 0.02 : 0.5;
        if ($operationType === 'credit_note' && $totalAmount > $balance + $tolerance) {
            $currency = $isExport ? ($source['currency_code'] ?: 'MON') : 'CLP';
            throw new RuntimeException('La nota de crédito supera el saldo ajustable: ' . $currency . ' ' . number_format($balance, $isExport ? 2 : 0, ',', '.'));
        }

        if ($isExport) {
            $amounts = $this->factory->splitExportAmount($totalAmount);
            $targetKey = $operationType === 'credit_note' ? 'credit_note_export_electronic' : 'debit_note_export_electronic';
        } else {
            $taxable = $sourceTypeId === 2 && billing_money($source['taxes_amount']) > 0;
            $amounts = $this->factory->splitChileGrossAmount($totalAmount, $taxable);
            $targetKey = $operationType === 'credit_note' ? 'credit_note_electronic' : 'debit_note_electronic';
        }
        $targetType = $this->types->getIssuedType($targetKey, $environment);
        if (empty($targetType['environment_enabled'])) throw new RuntimeException('El tipo documental no está habilitado en ' . $environment . '.');

        $remote = $this->loadRemoteSourceDetail($source, $environment);
        $remoteParser = new FactoDocumentResponseParser(is_array($remote) ? $remote : array());
        $remoteParser->assertSuccess('consulta de factura origen');
        $remoteHeader = $remoteParser->header();
        if ($remoteParser->documentId() !== null && $remoteParser->documentId() !== (string)$source['document_id']) {
            throw new RuntimeException('La factura local y la respuesta FACTO no coinciden.');
        }

        $companySettings = $this->loadCompanySettings();
        $factoConfig = icylab_facto_config($environment);
        $receiver = array(
            'tax_id' => $isExport
                ? ($source['export_tax_id_code'] ?: ($source['receiver_tax_id_code'] ?: ($remoteHeader['receiver_tax_id_code'] ?? '')))
                : ($source['receiver_tax_id_code'] ?: ($remoteHeader['receiver_tax_id_code'] ?? '')),
            'tax_id_type' => $isExport
                ? ($source['export_tax_id_type'] ?: ($source['receiver_tax_id_type'] ?: ($remoteHeader['receiver_tax_id_type'] ?? '')))
                : 'CL-RUT',
            'country_id' => $isExport
                ? (int)($source['export_country_id'] ?: ($source['receiver_country_id'] ?: ($remoteHeader['receiver_country_id'] ?? 0)))
                : 253,
            'legal_name' => $source['legal_name'] ?: ($source['customer_name'] ?: ($source['receiver_legal_name'] ?: ($remoteHeader['receiver_legal_name'] ?? ''))),
            'address' => $source['address'] ?: ($remoteHeader['receiver_address'] ?? ''),
            'district' => $source['district'] ?: ($remoteHeader['receiver_district'] ?? ''),
            'city' => $source['city'] ?: ($remoteHeader['receiver_city'] ?? ''),
            'phone' => $source['phone'] ?: ($remoteHeader['receiver_phone'] ?? ''),
            'activity' => $source['business_activity'] ?: ($remoteHeader['receiver_activity'] ?? ''),
            'email' => $source['email_dte'] ?: ($source['email'] ?: ($remoteHeader['receiver_email'] ?? ''))
        );
        $company = array(
            'tax_id' => $companySettings['company_tax_id'] ?? ($remoteHeader['issuer_tax_id_code'] ?? ''),
            'legal_name' => $companySettings['company_name'] ?? ($remoteHeader['issuer_legal_name'] ?? ''),
            'address' => $companySettings['company_address'] ?? ($remoteHeader['issuer_address'] ?? ''),
            'district' => $companySettings['company_comuna'] ?? ($remoteHeader['issuer_district'] ?? ''),
            'city' => $companySettings['company_ciudad'] ?? ($remoteHeader['issuer_city'] ?? ''),
            'phone' => $companySettings['company_phone'] ?? ($remoteHeader['issuer_phone'] ?? ''),
            'activity' => $companySettings['company_giro'] ?? ($remoteHeader['issuer_activity'] ?? '')
        );
        $issueDate = $this->normalizeDate($data['issue_date'] ?? date('Y-m-d')) ?: date('Y-m-d');

        $payload = $this->factory->buildAdjustmentPayload(array(
            'operation_type' => $operationType,
            'source' => $source,
            'remote_header' => $remoteHeader,
            'company' => $company,
            'receiver' => $receiver,
            'amounts' => $amounts,
            'reason' => $reason,
            'reference_code' => $referenceCode,
            'issue_date' => $issueDate,
            'account_id' => (int)($factoConfig['account_id'] ?? 0)
        ));

        return array(
            'environment' => $environment,
            'operation_type' => $operationType,
            'mode' => $mode,
            'source' => $source,
            'collection' => $collection,
            'target_type' => $targetType,
            'reference_code' => $referenceCode,
            'reference_date' => $source['issue_date'],
            'reason' => $reason,
            'issue_date' => $issueDate,
            'amounts' => $amounts,
            'payload' => $payload,
            'idempotency_key' => $this->makeIdempotencyKey($environment, $sourceLocalId, $operationType, $referenceCode, $amounts['total_amount'], $reason, $issueDate),
            'source_summary' => array(
                'local_id' => (int)$source['id'],
                'facto_document_id' => $source['document_id'],
                'document_number' => $source['document_number'],
                'customer' => $receiver['legal_name'],
                'total_amount' => $sourceTotal,
                'collection_total' => $collectionTotal,
                'paid_amount' => $paid,
                'balance_amount' => $balance,
                'currency_code' => $isExport ? ($source['currency_code'] ?: 'USD') : 'CLP',
                'exchange_rate_value' => $isExport ? $exchangeRate : 1
            )
        );
    }

    private function createOrResetOperation(array $context, array $previewPayload, $userId)
    {
        $issuePayload = $context['payload'];
        unset($issuePayload['options']['draft_preview']);
        $stmt = $this->pdo->prepare("SELECT * FROM billing_tax_operations WHERE idempotency_key=? LIMIT 1");
        $stmt->execute(array($context['idempotency_key']));
        $existing = $stmt->fetch(PDO::FETCH_ASSOC);
        if ($existing) {
            if (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 ajuste.');
            }
            $update = $this->pdo->prepare("\n                UPDATE billing_tax_operations\n                SET status='draft', preview_request_payload=?, preview_response_payload=NULL,\n                    issue_request_payload=?, issue_response_payload=NULL, preview_facto_document_id=NULL,\n                    preview_document_number=NULL, preview_pdf_file_path=NULL, preview_xml_file_path=NULL,\n                    facto_document_id=NULL, document_number=NULL, pdf_file_path=NULL, xml_file_path=NULL,\n                    error_code=NULL, error_message=NULL, created_by=?, updated_at=NOW()\n                WHERE id=?\n            ");
            $update->execute(array(
                $this->jsonEncode($previewPayload),
                $this->jsonEncode($issuePayload),
                $userId ?: null,
                (int)$existing['id']
            ));
            return $this->getOperation((int)$existing['id']);
        }

        $insert = $this->pdo->prepare("\n            INSERT INTO billing_tax_operations\n            (environment, operation_type, source_document_local_id, source_facto_document_id, source_collection_id,\n             source_quote_id, target_document_type_id, reference_code, reference_date, issue_date, reason,\n             net_amount, tax_amount, total_amount, source_total_snapshot, source_paid_snapshot, source_balance_snapshot,\n             status, idempotency_key, preview_request_payload, issue_request_payload, created_by, created_at, updated_at)\n            VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'draft', ?, ?, ?, ?, NOW(), NOW())\n        ");
        $insert->execute(array(
            $context['environment'],
            $context['operation_type'],
            (int)$context['source']['id'],
            $context['source']['document_id'],
            (int)$context['collection']['id'],
            $context['source']['crm_quote_id'] ?: null,
            (int)$context['target_type']['facto_document_type_id'],
            (int)$context['reference_code'],
            $context['reference_date'],
            $context['issue_date'],
            $context['reason'],
            $context['amounts']['net_amount'],
            $context['amounts']['tax_amount'],
            $context['amounts']['total_amount'],
            $context['source_summary']['collection_total'],
            $context['source_summary']['paid_amount'],
            $context['source_summary']['balance_amount'],
            $context['idempotency_key'],
            $this->jsonEncode($previewPayload),
            $this->jsonEncode($issuePayload),
            $userId ?: null
        ));
        return $this->getOperation((int)$this->pdo->lastInsertId());
    }

    private function loadSourceDocument($localId, $environment)
    {
        if ($localId <= 0) throw new InvalidArgumentException('Documento origen inválido.');
        $stmt = $this->pdo->prepare("\n            SELECT fd.*, bdt.document_label, bdt.document_key,\n                   c.customer_name, c.legal_name, c.email, c.email_dte, c.phone, c.address, c.district, c.city, c.business_activity,\n                   bc.id AS collection_id, bc.total_amount AS collection_total, bc.paid_amount, bc.balance_amount,\n                   bc.collection_status, bc.due_date\n            FROM facto_documents fd\n            LEFT JOIN billing_document_types bdt\n              ON bdt.direction='issued' AND bdt.facto_document_type_id=CAST(fd.document_type_id AS UNSIGNED)\n            LEFT JOIN crm_customers c ON c.id=fd.crm_customer_id\n            LEFT JOIN billing_collections bc\n              ON bc.id=(SELECT MAX(bc2.id) FROM billing_collections bc2\n                        WHERE BINARY bc2.environment=BINARY fd.environment\n                          AND bc2.direction='receivable' AND bc2.facto_document_id=fd.document_id)\n            WHERE fd.id=? AND fd.environment=?\n            LIMIT 1\n        ");
        $stmt->execute(array($localId, $environment));
        $row = $stmt->fetch(PDO::FETCH_ASSOC);
        if (!$row) throw new RuntimeException('Factura de origen no encontrada.');
        if (empty($row['document_id'])) throw new RuntimeException('La factura origen no tiene document_id FACTO.');
        return $row;
    }

    private function ensureSourceCollection(array $source)
    {
        if (!empty($source['collection_id'])) {
            return array(
                'id' => (int)$source['collection_id'],
                'total_amount' => billing_money($source['collection_total']),
                'paid_amount' => billing_money($source['paid_amount']),
                'balance_amount' => billing_money($source['balance_amount']),
                'collection_status' => $source['collection_status'],
                'due_date' => $source['due_date'],
                'currency_code' => $source['collection_currency_code'] ?? $source['currency_code'] ?? 'CLP',
                'currency_id' => $source['collection_currency_id'] ?? $source['currency_id'] ?? null,
                'exchange_rate_value' => $source['collection_exchange_rate_value'] ?? $source['exchange_rate_value'] ?? 1,
                'foreign_total_amount' => $source['foreign_total_amount'] !== null ? (float)$source['foreign_total_amount'] : null,
                'foreign_paid_amount' => $source['foreign_paid_amount'] !== null ? (float)$source['foreign_paid_amount'] : null,
                'foreign_balance_amount' => $source['foreign_balance_amount'] !== null ? (float)$source['foreign_balance_amount'] : null
            );
        }
        $isExport = (int)$source['document_type_id'] === 46;
        $exchange = $isExport ? max(0.000001, (float)($source['exchange_rate_value'] ?? 1)) : 1;
        $foreignTotal = $isExport ? round((float)$source['total_amount'], 4) : null;
        $total = $isExport ? billing_money($foreignTotal * $exchange) : billing_money($source['total_amount']);
        $stmt = $this->pdo->prepare("\n            INSERT INTO billing_collections\n            (environment,direction,source_table,facto_document_id,quote_id,opportunity_id,customer_id,document_number,\n             document_type_id,issue_date,due_date,total_amount,paid_amount,balance_amount,collection_status,finance_center_id,\n             currency_code,currency_id,exchange_rate_value,foreign_total_amount,foreign_paid_amount,foreign_balance_amount,created_at,updated_at)\n            VALUES (?,'receivable','facto_documents',?,?,?,?,?,?,?,?,?,0,?,'por_cobrar',?,?,?,?,?,0,?,NOW(),NOW())\n        ");
        $stmt->execute(array(
            $source['environment'], $source['document_id'], $source['crm_quote_id'] ?: null,
            $source['crm_opportunity_id'] ?: null, $source['crm_customer_id'] ?: null,
            $source['document_number'], (int)$source['document_type_id'], $source['issue_date'],
            $source['payment_due_date'] ?: $source['issue_date'], $total, $total, $source['finance_center_id'] ?: null,
            $isExport ? ($source['currency_code'] ?: 'USD') : 'CLP', $source['currency_id'] ?: null, $exchange,
            $foreignTotal, $foreignTotal
        ));
        return array(
            'id'=>(int)$this->pdo->lastInsertId(),'total_amount'=>$total,'paid_amount'=>0.0,'balance_amount'=>$total,
            'collection_status'=>'por_cobrar','due_date'=>$source['payment_due_date'] ?: $source['issue_date'],
            'currency_code'=>$isExport ? ($source['currency_code'] ?: 'USD') : 'CLP','currency_id'=>$source['currency_id'] ?: null,
            'exchange_rate_value'=>$exchange,'foreign_total_amount'=>$foreignTotal,'foreign_paid_amount'=>$isExport ? 0.0 : null,
            'foreign_balance_amount'=>$foreignTotal
        );
    }

    private function loadRemoteSourceDetail(array $source, $environment)
    {
        $client = new FactoClient($environment);
        return $client->getDocument($source['document_id'], true);
    }

    private function revalidateCollectionBeforeIssue(array $operation)
    {
        $stmt = $this->pdo->prepare("SELECT bc.*, fd.document_type_id, fd.exchange_rate_value FROM billing_collections bc JOIN facto_documents fd ON fd.id=? WHERE bc.id=? FOR UPDATE");
        $stmt->execute(array((int)$operation['source_document_local_id'], (int)$operation['source_collection_id']));
        $collection = $stmt->fetch(PDO::FETCH_ASSOC);
        if (!$collection) throw new RuntimeException('No se encontró la cuenta por cobrar de origen.');
        $isExport = (int)$collection['document_type_id'] === 46;
        $exchange = max(0.000001, (float)($collection['exchange_rate_value'] ?? 1));
        $balance = $isExport
            ? ($collection['foreign_balance_amount'] !== null ? (float)$collection['foreign_balance_amount'] : (float)$collection['balance_amount'] / $exchange)
            : billing_money($collection['balance_amount']);
        $paid = $isExport
            ? ($collection['foreign_paid_amount'] !== null ? (float)$collection['foreign_paid_amount'] : (float)$collection['paid_amount'] / $exchange)
            : billing_money($collection['paid_amount']);
        $tolerance = $isExport ? 0.02 : 0.5;
        if ($operation['operation_type'] === 'credit_note' && (float)$operation['total_amount'] > $balance + $tolerance) {
            throw new RuntimeException('El saldo cambió desde el preview. Genera una nueva previsualización.');
        }
        if ((int)$operation['reference_code'] === 1 && $paid > 0.005) {
            throw new RuntimeException('La factura recibió pagos después del preview y ya no puede anularse completamente.');
        }
    }

    private function validateIssuedDocumentAgainstOperation(array $operation, FactoDocumentResponseParser $parser)
    {
        $header = $parser->header();
        $totals = $parser->totals();
        if ((int)($header['document_type_id'] ?? 0) !== (int)$operation['target_document_type_id']) {
            throw new RuntimeException('El documento recuperado desde FACTO no corresponde al tipo esperado.');
        }
        $sourceType = $this->sourceDocumentTypeId($operation);
        $sourceTaxId = $this->sourceReceiverTaxId($operation);
        if ($sourceType === 46) {
            if ($this->normalizeForeignTaxId($header['receiver_tax_id_code'] ?? '') !== $this->normalizeForeignTaxId($sourceTaxId)) {
                throw new RuntimeException('El documento recuperado desde FACTO tiene otro receptor extranjero.');
            }
            $tolerance = 0.02;
        } else {
            if ($this->normalizeTaxId($header['receiver_tax_id_code'] ?? '') !== $this->normalizeTaxId($sourceTaxId)) {
                throw new RuntimeException('El documento recuperado desde FACTO tiene otro receptor.');
            }
            $tolerance = 0.5;
        }
        $remoteTotal = round((float)($totals['total_amount'] ?? 0), 4);
        if (abs($remoteTotal - round((float)$operation['total_amount'], 4)) > $tolerance) {
            throw new RuntimeException('El total del documento FACTO no coincide con la operación local.');
        }
        if (!$this->responseContainsSourceReference($parser->response(), $operation['source_facto_document_id'], $this->sourceDocumentNumber($operation))) {
            throw new RuntimeException('El documento FACTO no contiene la referencia a la factura origen.');
        }
    }

    private function finalizeLocalIssue($operationId, array $payload, array $response, $factoDocumentId, $documentNumber, array $saved, $userId)
    {
        $this->pdo->beginTransaction();
        try {
            $operation = $this->getOperationForUpdate($operationId);
            if ($operation['status'] === 'issued') {
                $this->pdo->commit();
                return;
            }
            $this->persistIssuedDocument($operation, $payload, $response, $factoDocumentId, $documentNumber, $saved, $userId);
            $fxApplied = $this->exchangeRates->applyToFactoDocumentByDocumentId($operation['environment'], $factoDocumentId, false);
            if (empty($fxApplied['ok'])) {
                throw new RuntimeException('No fue posible congelar el tipo de cambio de la nota emitida: ' . ($fxApplied['message'] ?? 'sin detalle'));
            }
            $this->persistDocumentLine($operation, $factoDocumentId, $payload);
            $this->persistReference($operation, $factoDocumentId, $payload);
            $this->applyReceivableAdjustment($operation, $documentNumber, $factoDocumentId);

            $parser = new FactoDocumentResponseParser($response);
            $stmt = $this->pdo->prepare("\n                UPDATE billing_tax_operations\n                SET status='issued', issue_response_payload=?, facto_document_id=?, document_number=?,\n                    pdf_file_path=?, xml_file_path=?, error_code=NULL, error_message=NULL, issued_by=?, issued_at=NOW(), updated_at=NOW()\n                WHERE id=?\n            ");
            $stmt->execute(array(
                $this->jsonEncode($parser->safeResponse()),
                $factoDocumentId,
                $documentNumber,
                $saved['pdf_file_path'] ?? null,
                $saved['xml_file_path'] ?? null,
                $userId ?: null,
                (int)$operationId
            ));
            $this->pdo->commit();
            (new ProductCostSnapshotService($this->pdo))->snapshotDocumentSafe($operation['environment'], $factoDocumentId, $userId);
        } catch (Throwable $e) {
            if ($this->pdo->inTransaction()) $this->pdo->rollBack();
            throw $e;
        }
    }

    private function persistIssuedDocument(array $operation, array $payload, array $response, $factoDocumentId, $documentNumber, array $saved, $userId)
    {
        $header = $payload['header'];
        $totals = $payload['totals'];
        $parser = new FactoDocumentResponseParser($response);
        $remoteHeader = $parser->header();
        $raw = $this->jsonEncode($parser->safeResponse());
        $stmt = $this->pdo->prepare("\n            INSERT INTO facto_documents\n            (environment,document_id,account_id,issue_date,document_number,document_type_id,issuer_tax_id_code,\n             receiver_legal_name,receiver_tax_id_code,crm_customer_id,crm_quote_id,crm_opportunity_id,finance_center_id,\n             link_status,linked_by_user_id,linked_at,document_status,currency_id,received_issued_flag,net_amount,taxes_amount,\n             total_amount,payment_status,document_type_taxbureau,taxbureau_sending_status,taxbureau_validation_status,\n             pdf_file_path,xml_file_path,raw_payload,payload_hash,last_synced_at,currency_code,exchange_rate_value,exchange_rate_date,exchange_rate_source,exchange_rate_status,\n             receiver_country_id,receiver_tax_id_type,sale_modality,sale_clause,source_mode,created_at,updated_at)\n            SELECT ?,?,?,?,?,?,?,?,?,src.crm_customer_id,src.crm_quote_id,src.crm_opportunity_id,src.finance_center_id,\n                   'manual_linked',?,NOW(),?,COALESCE(?,src.currency_id,39),1,?,?,?,'pagado',bdt.sii_external_code,?,?,\n                   ?,?,?,?,NOW(),src.currency_code,COALESCE(?,src.exchange_rate_value),src.exchange_rate_date,src.exchange_rate_source,src.exchange_rate_status,COALESCE(?,src.receiver_country_id),\n                   COALESCE(?,src.receiver_tax_id_type),COALESCE(?,src.sale_modality),COALESCE(?,src.sale_clause),'adjustment',NOW(),NOW()\n            FROM facto_documents src\n            JOIN billing_document_types bdt ON bdt.direction='issued' AND bdt.facto_document_type_id=?\n            WHERE src.id=?\n            ON DUPLICATE KEY UPDATE document_number=VALUES(document_number),document_status=VALUES(document_status),\n                taxbureau_sending_status=VALUES(taxbureau_sending_status),taxbureau_validation_status=VALUES(taxbureau_validation_status),\n                pdf_file_path=VALUES(pdf_file_path),xml_file_path=VALUES(xml_file_path),raw_payload=VALUES(raw_payload),\n                payload_hash=VALUES(payload_hash),last_synced_at=NOW(),currency_code=VALUES(currency_code),\n                exchange_rate_value=VALUES(exchange_rate_value),exchange_rate_date=VALUES(exchange_rate_date),exchange_rate_source=VALUES(exchange_rate_source),exchange_rate_status=VALUES(exchange_rate_status),receiver_country_id=VALUES(receiver_country_id),\n                receiver_tax_id_type=VALUES(receiver_tax_id_type),sale_modality=VALUES(sale_modality),sale_clause=VALUES(sale_clause),\n                source_mode=VALUES(source_mode),updated_at=NOW()\n        ");
        $stmt->execute(array(
            $operation['environment'], $factoDocumentId, $header['account_id'], $header['issue_date'], $documentNumber,
            (int)$operation['target_document_type_id'], $header['issuer_tax_id_code'], $header['receiver_legal_name'],
            $header['receiver_tax_id_code'], $userId ?: null,
            (int)($remoteHeader['document_status'] ?? 1), (int)($remoteHeader['currency_id'] ?? ($header['currency_id'] ?? 39)),
            $totals['net_amount'], $totals['taxes_amount'], $totals['total_amount'],
            isset($remoteHeader['taxbureau_sending_status']) ? (string)$remoteHeader['taxbureau_sending_status'] : null,
            isset($remoteHeader['taxbureau_validation_status']) ? (string)$remoteHeader['taxbureau_validation_status'] : null,
            $saved['pdf_file_path'] ?? null, $saved['xml_file_path'] ?? null,
            $raw, hash('sha256', $raw), $header['exchange_rate_value'] ?? null, $header['receiver_country_id'] ?? null,
            $header['receiver_tax_id_type'] ?? null, $header['sale_modality'] ?? null, $header['sale_clause'] ?? null,
            (int)$operation['target_document_type_id'], (int)$operation['source_document_local_id']
        ));
    }

    private function persistDocumentLine(array $operation, $factoDocumentId, array $payload)
    {
        $check = $this->pdo->prepare("SELECT id FROM billing_document_lines WHERE environment=? AND facto_document_id=? LIMIT 1");
        $check->execute(array($operation['environment'], $factoDocumentId));
        if ($check->fetchColumn()) return;
        $line = $payload['details'][0];
        $stmt = $this->pdo->prepare("\n            INSERT INTO billing_document_lines\n            (environment,facto_document_id,quote_id,sku,description,quantity,unit_measure,unit_price,net_amount,tax_amount,total_amount,tax_treatment,raw_payload,created_at)\n            VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,NOW())\n        ");
        $stmt->execute(array(
            $operation['environment'], $factoDocumentId, $operation['source_quote_id'] ?: null,
            $line['sku'] ?? null, $line['line_description'] ?? '', $line['quantity'] ?? 1,
            $line['unit_measure'] ?? 'UN', $line['unit_price'] ?? 0,
            $operation['net_amount'], $operation['tax_amount'], $operation['total_amount'],
            billing_money($operation['tax_amount']) > 0 ? 'afecto' : 'exento', $this->jsonEncode($line)
        ));
    }

    private function persistReference(array $operation, $factoDocumentId, array $payload)
    {
        $check = $this->pdo->prepare("SELECT id FROM billing_document_references WHERE environment=? AND source_facto_document_id=? AND reference_facto_document_id=? LIMIT 1");
        $check->execute(array($operation['environment'], $factoDocumentId, $operation['source_facto_document_id']));
        if ($check->fetchColumn()) return;
        $ref = $payload['references'][0];
        $stmt = $this->pdo->prepare("\n            INSERT INTO billing_document_references\n            (environment,source_facto_document_id,reference_facto_document_id,reference_document_number,reference_document_type_id,\n             reference_code,reference_reason,amount,raw_payload,created_by,created_at)\n            VALUES (?,?,?,?,?,?,?,?,?,?,NOW())\n        ");
        $stmt->execute(array(
            $operation['environment'], $factoDocumentId, $operation['source_facto_document_id'],
            $this->sourceDocumentNumber($operation), (int)$ref['document_type_id'], (int)$ref['reference_code'],
            $operation['reason'], $operation['total_amount'], $this->jsonEncode($ref), $operation['created_by'] ?: null
        ));
    }

    private function applyReceivableAdjustment(array $operation, $documentNumber, $factoDocumentId)
    {
        $stmt = $this->pdo->prepare("SELECT bc.*, fd.document_type_id, fd.currency_code AS source_currency_code, fd.exchange_rate_value AS source_exchange_rate FROM billing_collections bc JOIN facto_documents fd ON fd.id=? WHERE bc.id=? FOR UPDATE");
        $stmt->execute(array((int)$operation['source_document_local_id'], (int)$operation['source_collection_id']));
        $collection = $stmt->fetch(PDO::FETCH_ASSOC);
        if (!$collection) throw new RuntimeException('No se encontró la cuenta por cobrar.');
        $isExport = (int)$collection['document_type_id'] === 46;
        $sign = $operation['operation_type'] === 'credit_note' ? -1 : 1;
        if ($isExport) {
            $exchange = max(0.000001, (float)($collection['exchange_rate_value'] ?? $collection['source_exchange_rate'] ?? 1));
            $deltaForeign = $sign * round((float)$operation['total_amount'], 4);
            $currentForeignTotal = $collection['foreign_total_amount'] !== null ? (float)$collection['foreign_total_amount'] : (float)$collection['total_amount'] / $exchange;
            $foreignPaid = $collection['foreign_paid_amount'] !== null ? (float)$collection['foreign_paid_amount'] : (float)$collection['paid_amount'] / $exchange;
            $newForeignTotal = max(0, round($currentForeignTotal + $deltaForeign, 4));
            $newForeignBalance = max(0, round($newForeignTotal - $foreignPaid, 4));
            $newTotal = billing_money($newForeignTotal * $exchange);
            $paid = billing_money($collection['paid_amount']);
            $newBalance = billing_money($newForeignBalance * $exchange);
            $status = $this->calculateCollectionStatus($newTotal, $paid, $newBalance, $collection['due_date']);
            $currency = $collection['currency_code'] ?: ($collection['source_currency_code'] ?: 'USD');
            $note = strtoupper($operation['operation_type'] === 'credit_note' ? 'NC EXP' : 'ND EXP') . ' ' . ($documentNumber ?: $factoDocumentId)
                . ': ' . ($deltaForeign < 0 ? '-' : '+') . $currency . ' ' . number_format(abs($deltaForeign), 2, ',', '.') . ' · ' . $operation['reason'];
            $update = $this->pdo->prepare("UPDATE billing_collections SET total_amount=?,balance_amount=?,collection_status=?,foreign_total_amount=?,foreign_balance_amount=?,notes=CONCAT(COALESCE(notes,''),CASE WHEN COALESCE(notes,'')='' THEN '' ELSE '\n' END,?),updated_at=NOW() WHERE id=?");
            $update->execute(array($newTotal,$newBalance,$status,$newForeignTotal,$newForeignBalance,$note,(int)$collection['id']));
        } else {
            $delta = $sign * billing_money($operation['total_amount']);
            $newTotal = max(0, billing_money($collection['total_amount']) + $delta);
            $paid = billing_money($collection['paid_amount']);
            $newBalance = max(0, billing_money($newTotal - $paid));
            $status = $this->calculateCollectionStatus($newTotal, $paid, $newBalance, $collection['due_date']);
            $note = strtoupper($operation['operation_type'] === 'credit_note' ? 'NC' : 'ND') . ' ' . ($documentNumber ?: $factoDocumentId)
                . ': ' . ($delta < 0 ? '-' : '+') . '$' . number_format(abs($delta), 0, ',', '.') . ' · ' . $operation['reason'];
            $update = $this->pdo->prepare("UPDATE billing_collections SET total_amount=?,balance_amount=?,collection_status=?,notes=CONCAT(COALESCE(notes,''),CASE WHEN COALESCE(notes,'')='' THEN '' ELSE '\n' END,?),updated_at=NOW() WHERE id=?");
            $update->execute(array($newTotal,$newBalance,$status,$note,(int)$collection['id']));
        }
        $this->pdo->prepare("UPDATE facto_documents SET payment_status=?,updated_at=NOW() WHERE environment=? AND document_id=?")
            ->execute(array($status, $operation['environment'], $operation['source_facto_document_id']));
        if (!empty($collection['quote_id'])) {
            $quoteStatus = $status === 'parcial' ? 'por_cobrar' : $status;
            $this->pdo->prepare("UPDATE crm_quotes SET payment_status=? WHERE id=?")->execute(array($quoteStatus, (int)$collection['quote_id']));
        }
    }

    private function calculateCollectionStatus($total, $paid, $balance, $dueDate)
    {
        if ($total <= 0.005 && $paid <= 0.005) return 'anulado';
        if ($balance <= 0.005) return 'pagado';
        if ($dueDate && strtotime($dueDate) < strtotime(date('Y-m-d'))) return 'atrasado';
        if ($paid > 0.005) return 'parcial';
        return 'por_cobrar';
    }

    private function getOperationForUpdate($operationId)
    {
        $stmt = $this->pdo->prepare("SELECT * FROM billing_tax_operations WHERE id=? FOR UPDATE");
        $stmt->execute(array((int)$operationId));
        $row = $stmt->fetch(PDO::FETCH_ASSOC);
        if (!$row) throw new RuntimeException('Operación tributaria no encontrada.');
        return $row;
    }

    private function operationResult(array $operation)
    {
        return array(
            'operation_id' => (int)$operation['id'],
            'status' => $operation['status'],
            'operation_type' => $operation['operation_type'],
            'facto_document_id' => $operation['facto_document_id'],
            'document_number' => $operation['document_number'],
            'total_amount' => billing_money($operation['total_amount']),
            'pdf_available' => !empty($operation['pdf_file_path']),
            'xml_available' => !empty($operation['xml_file_path']),
            'error_message' => $operation['error_message']
        );
    }

    private function markError($operationId, $code, $message, $response = null, $phase = 'issue')
    {
        $column = $phase === 'preview' ? 'preview_response_payload' : 'issue_response_payload';
        $safe = null;
        if (is_array($response)) {
            $safe = $this->jsonEncode((new FactoDocumentResponseParser($response))->safeResponse());
        }
        $stmt = $this->pdo->prepare("UPDATE billing_tax_operations SET status='error', {$column}=?, error_code=?, error_message=?, updated_at=NOW() WHERE id=?");
        $stmt->execute(array($safe, $code, $message, (int)$operationId));
    }

    private function markPendingLocalCommit($operationId, $remoteDocumentId, $response, $message)
    {
        $safe = is_array($response) ? $this->jsonEncode((new FactoDocumentResponseParser($response))->safeResponse()) : null;
        $stmt = $this->pdo->prepare("UPDATE billing_tax_operations SET status='pending_local_commit',facto_document_id=?,issue_response_payload=?,error_code='local_commit_error',error_message=?,updated_at=NOW() WHERE id=?");
        $stmt->execute(array($remoteDocumentId, $safe, $message, (int)$operationId));
    }

    private function markRemoteUncertain($operationId, $response, $message)
    {
        $safe = is_array($response) ? $this->jsonEncode((new FactoDocumentResponseParser($response))->safeResponse()) : null;
        $stmt = $this->pdo->prepare("UPDATE billing_tax_operations SET status='remote_uncertain',issue_response_payload=?,error_code='missing_document_id',error_message=?,updated_at=NOW() WHERE id=?");
        $stmt->execute(array($safe, $message, (int)$operationId));
    }

    private function makeIdempotencyKey($environment, $sourceLocalId, $operationType, $referenceCode, $totalAmount, $reason, $issueDate)
    {
        $normalizedReason = function_exists('mb_strtolower') ? mb_strtolower(trim((string)$reason), 'UTF-8') : strtolower(trim((string)$reason));
        return 'phase8r1:' . $environment . ':source:' . (int)$sourceLocalId . ':' . $operationType
            . ':ref:' . (int)$referenceCode . ':amount:' . number_format((float)$totalAmount, 2, '.', '')
            . ':date:' . $issueDate . ':reason:' . substr(hash('sha256', $normalizedReason), 0, 24);
    }

    private function loadCompanySettings()
    {
        $stmt = $this->pdo->query("SELECT * FROM system_settings WHERE id=1 LIMIT 1");
        return $stmt->fetch(PDO::FETCH_ASSOC) ?: array();
    }

    private function sourceReceiverTaxId(array $operation)
    {
        $stmt = $this->pdo->prepare("SELECT receiver_tax_id_code FROM facto_documents WHERE id=? LIMIT 1");
        $stmt->execute(array((int)$operation['source_document_local_id']));
        return (string)$stmt->fetchColumn();
    }

    private function sourceDocumentNumber(array $operation)
    {
        $stmt = $this->pdo->prepare("SELECT document_number FROM facto_documents WHERE id=? LIMIT 1");
        $stmt->execute(array((int)$operation['source_document_local_id']));
        return (string)$stmt->fetchColumn();
    }

    private function responseContainsSourceReference($response, $sourceDocumentId, $sourceDocumentNumber)
    {
        $found = false;
        $walk = function ($value) use (&$walk, &$found, $sourceDocumentId, $sourceDocumentNumber) {
            if ($found || !is_array($value)) return;
            $id = isset($value['reference_document_id']) ? (string)$value['reference_document_id'] : null;
            $number = isset($value['reference_number']) ? (string)$value['reference_number'] : null;
            if (($id !== null && $id === (string)$sourceDocumentId) || ($number !== null && $number === (string)$sourceDocumentNumber)) {
                $found = true;
                return;
            }
            foreach ($value as $item) if (is_array($item)) $walk($item);
        };
        $walk($response);
        return $found;
    }

    private function sourceDocumentTypeId(array $operation)
    {
        $stmt = $this->pdo->prepare("SELECT document_type_id FROM facto_documents WHERE id=? LIMIT 1");
        $stmt->execute(array((int)$operation['source_document_local_id']));
        return (int)$stmt->fetchColumn();
    }

    private function normalizeForeignTaxId($value)
    {
        return strtoupper(preg_replace('/[^A-Z0-9]/i', '', (string)$value));
    }

    private function normalizeTaxId($value)
    {
        return strtoupper(preg_replace('/[^0-9K]/', '', (string)$value));
    }

    private function normalizeEnvironment($environment)
    {
        return billing_normalize_environment($environment);
    }

    private function normalizeDate($date)
    {
        $date = trim((string)$date);
        if ($date === '') return null;
        $parsed = DateTime::createFromFormat('Y-m-d', substr($date, 0, 10));
        return $parsed && $parsed->format('Y-m-d') === substr($date, 0, 10) ? $parsed->format('Y-m-d') : null;
    }

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