<?php
/**
 * Application service for issuance, immutable record generation, queueing,
 * transmission, rectification, correction and cancellation.
 */

if (!defined('ABSPATH')) {
    exit;
}

class VerifactuService
{
    private $module;
    private $repository;
    private $transport;

    public function __construct($module, ?VerifactuRepository $repository = null, ?VerifactuTransport $transport = null)
    {
        $this->module = $module;
        $this->repository = $repository ?: new VerifactuRepository();
        $this->transport = $transport ?: new VerifactuTransport();
    }

    /**
     * Manual order-level entry point. It deliberately refuses to guess when an
     * order has more than one invoice.
     */
    public function sendOrderInvoice($idOrder, $idOrderInvoice = 0)
    {
        $order = wc_get_order((int) $idOrder);
        if (!($order instanceof WC_Order)) {
            throw new Exception('El pedido ' . (int) $idOrder . ' no existe.');
        }
        $invoice = VerifactuDocumentIdentity::getInvoice(
            $order,
            (int) $idOrderInvoice > 0 ? (int) $idOrderInvoice : null
        );

        return $this->sendInvoice((int) $invoice->id);
    }

    public function sendInvoice($idOrderInvoice)
    {
        $record = $this->prepareInvoice((int) $idOrderInvoice);
        $this->processQueue(VerifactuConfig::getQueueBatchSize());

        return $this->recordResult($this->repository->getById((int) $record['id_record']));
    }

    public function prepareInvoice($idOrderInvoice)
    {
        $invoice = VerifactuDocumentIdentity::getInvoiceById((int) $idOrderInvoice);
        $order = wc_get_order((int) $invoice->id_order);
        if (!($order instanceof WC_Order)) {
            throw new Exception('No se ha podido cargar el pedido de la factura.');
        }
        $this->repository->completeWaitingInvoiceTasks((int) $order->get_id());
        VerifactuConfig::validateRecordConfiguration($this->module->version);
        $existing = $this->repository->getLatestAltaByInvoice((int) $invoice->id);
        if ($existing) {
            if ($existing['local_status'] === VerifactuRepository::STATUS_REJECTED) {
                throw new Exception(
                    'El alta original de esta factura fue rechazada. Debe crear una subsanacion; nunca se generara una segunda alta original.'
                );
            }
            $this->repository->enqueueRecordForTransmission($existing, 0);
            return $existing;
        }
        $document = VerifactuInvoiceData::fromInvoice($order, $invoice);
        $this->assertNoAmbiguousLegacyDuplicate($document['identity']);

        return $this->processAlta($document, 'ORIGINAL', null, '', '');
    }

    /**
     * Automatic/order-status entry point. Every exact OrderInvoice is handled;
     * if none exists, a non-fiscal WAITING_INVOICE task is created.
     */
    public function prepareAllOrderInvoices($idOrder)
    {
        $order = wc_get_order((int) $idOrder);
        if (!($order instanceof WC_Order)) {
            throw new Exception('El pedido ' . (int) $idOrder . ' no existe.');
        }
        $invoices = VerifactuDocumentIdentity::getInvoices($order);
        if (empty($invoices)) {
            $idQueue = $this->enqueueWaitingInvoice($order);
            return array(
                'waiting_invoice' => true,
                'id_queue' => $idQueue,
                'message' => 'El pedido no tiene factura emitida: no se ha generado XML, huella, QR ni envio.',
            );
        }

        $this->repository->completeWaitingInvoiceTasks((int) $order->get_id());
        $records = array();
        foreach ($invoices as $invoice) {
            $records[] = $this->prepareInvoice((int) $invoice->id);
        }

        return array('waiting_invoice' => false, 'records' => $records);
    }

    public function subsanateInvoice($idOrderInvoice, array $options = array())
    {
        $source = !empty($options['source_record_id'])
            ? $this->repository->getById((int) $options['source_record_id'])
            : $this->repository->getLatestAltaByInvoice((int) $idOrderInvoice);
        $this->assertSubsanationSource($source, 'factura');

        $invoice = VerifactuDocumentIdentity::getInvoiceById((int) $idOrderInvoice);
        $order = wc_get_order((int) $invoice->id_order);
        VerifactuConfig::validateRecordConfiguration($this->module->version);
        $document = VerifactuInvoiceData::fromInvoice($order, $invoice);
        list($subsanacion, $rechazoPrevio) = $this->subsanationFlags($source, $options);
        $record = $this->processAlta($document, 'SUBSANACION', $source, $subsanacion, $rechazoPrevio);
        if (!empty($options['send_now'])) {
            $this->processQueue(VerifactuConfig::getQueueBatchSize());
        }

        return $this->recordResult($this->repository->getById((int) $record['id_record']));
    }

    public function sendCreditSlip($idOrderSlip, array $options = array())
    {
        $record = $this->prepareCreditSlip((int) $idOrderSlip, $options);
        $this->processQueue(VerifactuConfig::getQueueBatchSize());

        return $this->recordResult($this->repository->getById((int) $record['id_record']));
    }

    public function prepareCreditSlip($idOrderSlip, array $options = array())
    {
        $creditSlip = wc_get_order((int) $idOrderSlip);
        if (!($creditSlip instanceof WC_Order_Refund)) {
            throw new Exception('El abono ' . (int) $idOrderSlip . ' no existe.');
        }
        $order = wc_get_order((int) $creditSlip->get_parent_id());
        if (!($order instanceof WC_Order)) {
            throw new Exception('No se ha podido cargar el pedido del abono.');
        }
        VerifactuConfig::validateRecordConfiguration($this->module->version);

        $sourceInvoices = VerifactuDocumentIdentity::getCreditSlipSourceInvoices($order, $creditSlip);
        $sourceTypes = array();
        foreach ($sourceInvoices as $sourceInvoice) {
            $identity = VerifactuDocumentIdentity::getInvoiceIdentity($order, $sourceInvoice);
            $this->assertOriginalNotCancelled($identity);
            $sourceRecord = $this->repository->getLatestAcceptedByInvoice((int) $sourceInvoice->id);
            if ($sourceRecord) {
                if (!in_array($sourceRecord['invoice_type'], array('F1', 'F2'), true)) {
                    throw new Exception('El alta aceptada de la factura original no tiene un tipo F1/F2 utilizable.');
                }
                $sourceTypes[(int) $sourceInvoice->id] = (string) $sourceRecord['invoice_type'];
                continue;
            }

            $legacy = $this->repository->findLegacyForIdentity(
                VerifactuConfig::getIssuerNif(),
                $identity['number'],
                $identity['issue_date']
            );
            if (!$legacy || VerifactuConfig::getLegacyChainDecision() !== 'use_latest') {
                throw new Exception(
                    'La factura original ' . $identity['number']
                    . ' no consta aceptada en el diario VeriFactu. No se enviara una rectificativa huerfana.'
                );
            }
            $legacyType = isset($legacy['tipo_factura']) ? strtoupper((string) $legacy['tipo_factura']) : '';
            if (!in_array($legacyType, array('F1', 'F2'), true)) {
                throw new Exception(
                    'El registro historico de la factura ' . $identity['number']
                    . ' no conserva un tipo F1/F2 verificable. Revise la migracion antes de rectificar.'
                );
            }
            $sourceTypes[(int) $sourceInvoice->id] = $legacyType;
        }

        $existing = $this->repository->getLatestAltaBySlip((int) $creditSlip->get_id());
        if ($existing) {
            if ($existing['local_status'] === VerifactuRepository::STATUS_REJECTED) {
                throw new Exception(
                    'El alta original de esta rectificativa fue rechazada. Debe subsanarse; no se generara otra alta original.'
                );
            }
            $this->repository->enqueueRecordForTransmission($existing, 0);
            return $existing;
        }
        $options['source_invoice_types'] = $sourceTypes;
        $document = VerifactuInvoiceData::fromCreditSlip($order, $creditSlip, $options);
        $this->assertNoAmbiguousLegacyDuplicate($document['identity']);

        return $this->processAlta($document, 'ORIGINAL', null, '', '');
    }

    public function subsanateCreditSlip($idOrderSlip, array $options = array())
    {
        $source = !empty($options['source_record_id'])
            ? $this->repository->getById((int) $options['source_record_id'])
            : $this->repository->getLatestAltaBySlip((int) $idOrderSlip);
        $this->assertSubsanationSource($source, 'rectificativa');

        $creditSlip = wc_get_order((int) $idOrderSlip);
        if (!($creditSlip instanceof WC_Order_Refund)) {
            throw new Exception('El abono ' . (int) $idOrderSlip . ' no existe.');
        }
        $order = wc_get_order((int) $creditSlip->get_parent_id());
        $sourceInvoices = VerifactuDocumentIdentity::getCreditSlipSourceInvoices($order, $creditSlip);
        $sourceTypes = array();
        foreach ($sourceInvoices as $sourceInvoice) {
            $identity = VerifactuDocumentIdentity::getInvoiceIdentity($order, $sourceInvoice);
            $this->assertOriginalNotCancelled($identity);
            $accepted = $this->repository->getLatestAcceptedByInvoice((int) $sourceInvoice->id);
            if (!$accepted || !in_array($accepted['invoice_type'], array('F1', 'F2'), true)) {
                throw new Exception('No consta el alta aceptada y tipificada de una factura original rectificada.');
            }
            $sourceTypes[(int) $sourceInvoice->id] = $accepted['invoice_type'];
        }
        $options['source_invoice_types'] = $sourceTypes;
        VerifactuConfig::validateRecordConfiguration($this->module->version);
        $document = VerifactuInvoiceData::fromCreditSlip($order, $creditSlip, $options);
        list($subsanacion, $rechazoPrevio) = $this->subsanationFlags($source, $options);
        $record = $this->processAlta($document, 'SUBSANACION', $source, $subsanacion, $rechazoPrevio);
        if (!empty($options['send_now'])) {
            $this->processQueue(VerifactuConfig::getQueueBatchSize());
        }

        return $this->recordResult($this->repository->getById((int) $record['id_record']));
    }

    public function cancelInvoice($idOrderInvoice, array $options = array())
    {
        $invoice = VerifactuDocumentIdentity::getInvoiceById((int) $idOrderInvoice);
        $order = wc_get_order((int) $invoice->id_order);
        $identity = VerifactuDocumentIdentity::getInvoiceIdentity($order, $invoice);

        return $this->cancelIdentity(
            $identity,
            (function_exists('get_current_blog_id') ? (int) get_current_blog_id() : 1),
            (int) $order->get_id(),
            (int) $invoice->id,
            0,
            'INVOICE',
            $options
        );
    }

    public function cancelCreditSlip($idOrderSlip, array $options = array())
    {
        $creditSlip = wc_get_order((int) $idOrderSlip);
        if (!($creditSlip instanceof WC_Order_Refund)) {
            throw new Exception('El abono ' . (int) $idOrderSlip . ' no existe.');
        }
        $order = wc_get_order((int) $creditSlip->get_parent_id());
        $identity = VerifactuDocumentIdentity::getCreditSlipIdentity($order, (int) $creditSlip->get_id());

        return $this->cancelIdentity(
            $identity,
            (function_exists('get_current_blog_id') ? (int) get_current_blog_id() : 1),
            (int) $order->get_id(),
            0,
            (int) $creditSlip->get_id(),
            'CREDIT_SLIP',
            $options
        );
    }

    /**
     * Exceptional administrative cancellation for a historically wrong
     * identity (for example, an order reference sent as NumSerieFactura).
     * The caller must provide the exact identity accepted by AEAT.
     */
    public function cancelExternalIdentity($number, $issueDate, array $options = array())
    {
        if (empty($options['confirm_exact_remote_identity'])) {
            throw new Exception('Debe confirmar expresamente que numero y fecha coinciden exactamente con el alta que figura en la AEAT.');
        }
        $identity = array(
            'number' => trim((string) $number),
            'issue_date' => trim((string) $issueDate),
            'file_token' => VerifactuDocumentIdentity::safeFileToken($number),
        );
        return $this->cancelIdentity($identity, 0, 0, 0, 0, 'EXTERNAL_IDENTITY', $options);
    }

    public function retryRecord($idRecord)
    {
        $record = $this->repository->getById((int) $idRecord);
        if (!$record) {
            throw new Exception('El registro local ' . (int) $idRecord . ' no existe.');
        }
        if (in_array($record['local_status'], array(
            VerifactuRepository::STATUS_ACCEPTED,
            VerifactuRepository::STATUS_ACCEPTED_WITH_ERRORS,
        ), true)) {
            return $this->recordResult($record);
        }
        if ($record['local_status'] === VerifactuRepository::STATUS_REJECTED) {
            throw new Exception('Un registro rechazado debe subsanarse; no se regenera ni se reenvia como si fuera una incidencia de transporte.');
        }
        $this->repository->enqueueRecordForTransmission($record, 0);
        $this->processQueue(VerifactuConfig::getQueueBatchSize());

        return $this->recordResult($this->repository->getById((int) $idRecord));
    }

    public function processQueue($limit = null)
    {
        // Keep the historical queue repair idempotent for migrated installations;
        // it is safe to execute on every queue pass because matching is exact.
        $this->repository->repairLegacyCodigo102Queue(VerifactuConfig::getEnvironment());
        $limit = $limit === null ? VerifactuConfig::getQueueBatchSize() : max(1, min(100, (int) $limit));
        $summary = array('processed' => 0, 'accepted' => 0, 'rejected' => 0, 'retried' => 0, 'errors' => array());
        for ($i = 0; $i < $limit; $i++) {
            $task = $this->repository->claimNextQueue();
            if (!$task) {
                break;
            }
            try {
                if ($task['action'] !== 'SEND_RECORD' || empty($task['id_record'])) {
                    throw new Exception('La tarea no contiene un registro transmisible valido.');
                }
                $record = $this->repository->getById((int) $task['id_record']);
                if (!$record) {
                    throw new Exception('El registro asociado a la tarea ya no existe.');
                }
                $result = $this->sendStoredRecord($record);
                $summary['processed']++;
                if (!empty($result['accepted'])) {
                    $summary['accepted']++;
                } elseif (!empty($result['queued'])) {
                    $delay = max(1, isset($result['retry_after']) ? (int) $result['retry_after'] : 60);
                    $this->repository->retryQueue((int) $task['id_queue'], $result['message'], $delay, (int) $record['id_record']);
                    $summary['retried']++;
                    break;
                } else {
                    $summary['rejected']++;
                }
                $this->repository->completeQueue((int) $task['id_queue'], (int) $record['id_record']);
            } catch (VerifactuTransportException $e) {
                if ($e->isRetryable()) {
                    $delay = VerifactuConfig::getRetryDelaySeconds((int) $task['attempts']);
                    $this->repository->retryQueue((int) $task['id_queue'], $e->getMessage(), $delay, (int) $task['id_record']);
                    $summary['retried']++;
                } else {
                    $this->repository->errorQueue((int) $task['id_queue'], $e->getMessage(), (int) $task['id_record']);
                }
                $summary['errors'][] = $e->getMessage();
                // Preserve chain order: neither a remotely-unknown record nor a
                // protocol-invalid predecessor may be overtaken.
                break;
            } catch (Throwable $e) {
                $this->repository->errorQueue((int) $task['id_queue'], $e->getMessage(), (int) $task['id_record']);
                $summary['errors'][] = $e->getMessage();
                $this->repository->logEvent('ERROR', 'QUEUE_ERROR', array(
                    'id_shop' => (int) $task['id_shop'],
                    'id_order' => (int) $task['id_order'],
                    'id_order_invoice' => !empty($task['id_order_invoice']) ? (int) $task['id_order_invoice'] : null,
                    'id_order_slip' => !empty($task['id_order_slip']) ? (int) $task['id_order_slip'] : null,
                    'id_record' => !empty($task['id_record']) ? (int) $task['id_record'] : null,
                    'message' => $e->getMessage(),
                ));
                // A configuration/integrity error is usually shared by later
                // tasks; stop instead of flooding the log.
                break;
            }
        }

        return $summary;
    }

    public function getRepository()
    {
        return $this->repository;
    }

    private function processAlta(array $document, $mode, $sourceRecord, $subsanacion, $rechazoPrevio)
    {
        $environment = VerifactuConfig::getEnvironment();
        $issuerNif = VerifactuConfig::getIssuerNif();
        $installationId = VerifactuConfig::getInstallationId();
        $chainKey = VerifactuRepository::buildChainKey($environment, $issuerNif, $installationId);
        $sourceId = $sourceRecord ? (int) $sourceRecord['id_record'] : 0;
        $revision = hash('sha256', json_encode($this->revisionData($document)));
        $documentId = !empty($document['id_order_slip'])
            ? 'S' . (int) $document['id_order_slip']
            : 'I' . (int) $document['id_order_invoice'];
        $intent = substr(
            'ALTA:' . $mode . ':' . $document['document_kind'] . ':' . $documentId
            . ':SRC' . $sourceId . ':' . $revision,
            0,
            191
        );
        $existing = $this->repository->findLatestByIntent($intent, $environment);
        if ($existing) {
            $this->repository->enqueueRecordForTransmission($existing, 0);
            return $existing;
        }

        $this->repository->acquireChainLock($chainKey, 20);
        try {
            $existing = $this->repository->findLatestByIntent($intent, $environment);
            if ($existing) {
                $this->repository->enqueueRecordForTransmission($existing, 0);
                return $existing;
            }
            $previous = $this->repository->getLastGeneratedRecord($chainKey, $environment);
            $builderData = array(
                'issuer_nif' => $issuerNif,
                'issuer_name' => VerifactuConfig::getIssuerName(),
                'document_number' => $document['identity']['number'],
                'issue_date' => $document['identity']['issue_date'],
                'invoice_type' => $document['invoice_type'],
                'external_reference' => $document['commercial_reference'],
                'description' => $document['description'],
                'recipient' => $document['recipient'],
                'tax' => array(
                    'details' => $this->mapTaxRows($document['tax_rows']),
                    'tax_total' => $document['tax_total'],
                    'gross_total' => $document['total'],
                ),
                'rectification_type' => $document['rectification_type'],
                'rectified_invoices' => $this->mapOriginalIdentities($document['original_identities'], $issuerNif),
                'subsanacion' => $subsanacion,
                'rechazo_previo' => $rechazoPrevio,
                'previous' => $previous,
                'system' => VerifactuConfig::getSystemInfo($this->module->version),
                'record_timestamp' => VerifactuConfig::now(),
            );
            $built = VerifactuXmlBuilder::buildAlta($builderData);
            $recordId = $this->repository->insertGeneratedRecord(array(
                'id_shop' => (int) $document['id_shop'],
                'chain_key' => $chainKey,
                'installation_id' => $installationId,
                'id_order' => (int) $document['id_order'],
                'id_order_invoice' => (int) $document['id_order_invoice'],
                'id_order_slip' => (int) $document['id_order_slip'],
                'operation' => 'ALTA',
                'record_mode' => $mode,
                'document_kind' => $document['document_kind'],
                'source_record_id' => $sourceId,
                'issuer_nif' => $issuerNif,
                'document_number' => $document['identity']['number'],
                'issue_date' => $document['identity']['issue_date'],
                'invoice_type' => $document['invoice_type'],
                'rectification_type' => $document['rectification_type'],
                'subsanacion' => $subsanacion,
                'rechazo_previo' => $rechazoPrevio,
                'sin_registro_previo' => null,
                'intent_key' => $intent,
                'record_timestamp' => $built['record_timestamp'],
                'fingerprint' => $built['fingerprint'],
                'fingerprint_input' => $built['fingerprint_input'],
                'previous_record_id' => $previous && !empty($previous['id_record']) ? (int) $previous['id_record'] : null,
                'previous_issuer_nif' => $previous ? $previous['issuer_nif'] : null,
                'previous_document_number' => $previous ? $previous['document_number'] : null,
                'previous_issue_date' => $previous ? $previous['issue_date'] : null,
                'previous_fingerprint' => $previous ? $previous['fingerprint'] : null,
                'request_xml' => $built['xml'],
                'request_sha256' => hash('sha256', $built['xml']),
                'environment' => $environment,
            ));
            $record = $this->repository->getById($recordId);
            $this->saveXml($record);
            $this->repository->enqueueRecordForTransmission($record, 0);

            return $record;
        } finally {
            $this->repository->releaseChainLock($chainKey);
        }
    }

    private function cancelIdentity(
        array $identity,
        $idShop,
        $idOrder,
        $idOrderInvoice,
        $idOrderSlip,
        $kind,
        array $options
    ) {
        VerifactuConfig::validateRecordConfiguration($this->module->version);
        $environment = VerifactuConfig::getEnvironment();
        $issuerNif = VerifactuConfig::getIssuerNif();
        $installationId = VerifactuConfig::getInstallationId();
        $chainKey = VerifactuRepository::buildChainKey($environment, $issuerNif, $installationId);
        $sinRegistroPrevio = !empty($options['sin_registro_previo']) ? 'S' : '';

        $accepted = $this->repository->findAcceptedForIdentity(
            $issuerNif,
            $identity['number'],
            $identity['issue_date'],
            'ALTA',
            $environment
        );
        if (!$accepted && $sinRegistroPrevio !== 'S') {
            $legacy = $this->repository->findLegacyForIdentity($issuerNif, $identity['number'], $identity['issue_date']);
            if (!$legacy || VerifactuConfig::getLegacyChainDecision() !== 'use_latest') {
                throw new Exception(
                    'No consta un alta aceptada de esta identidad. Use SinRegistroPrevio solo para el supuesto excepcional documentado por la AEAT.'
                );
            }
        }

        $baseIntent = substr(
            'ANULACION:' . $kind . ':' . $identity['number'] . ':' . $identity['issue_date'],
            0,
            170
        );
        $latest = $this->repository->findLatestForIdentity(
            $issuerNif,
            $identity['number'],
            $identity['issue_date'],
            'ANULACION',
            $environment
        );
        if ($latest && in_array($latest['local_status'], array(
            VerifactuRepository::STATUS_GENERATED,
            VerifactuRepository::STATUS_SENDING,
            VerifactuRepository::STATUS_TRANSPORT_UNKNOWN,
            VerifactuRepository::STATUS_ACCEPTED,
            VerifactuRepository::STATUS_ACCEPTED_WITH_ERRORS,
        ), true)) {
            $this->repository->enqueueRecordForTransmission($latest, 0);
            $this->processQueue(VerifactuConfig::getQueueBatchSize());
            return $this->recordResult($this->repository->getById((int) $latest['id_record']));
        }
        $rechazoPrevio = $latest && $latest['local_status'] === VerifactuRepository::STATUS_REJECTED ? 'S' : '';
        $intent = $latest && $rechazoPrevio === 'S'
            ? substr($baseIntent . ':RECHAZO:' . (int) $latest['id_record'], 0, 191)
            : $baseIntent;

        $this->repository->acquireChainLock($chainKey, 20);
        try {
            $previous = $this->repository->getLastGeneratedRecord($chainKey, $environment);
            $built = VerifactuXmlBuilder::buildAnulacion(array(
                'issuer_nif' => $issuerNif,
                'issuer_name' => VerifactuConfig::getIssuerName(),
                'document_number' => $identity['number'],
                'issue_date' => $identity['issue_date'],
                'external_reference' => $idOrder > 0 ? 'WC-O' . (int) $idOrder : 'REGULARIZACION-HISTORICA',
                'sin_registro_previo' => $sinRegistroPrevio,
                'rechazo_previo' => $rechazoPrevio,
                'previous' => $previous,
                'system' => VerifactuConfig::getSystemInfo($this->module->version),
                'record_timestamp' => VerifactuConfig::now(),
            ));
            $recordId = $this->repository->insertGeneratedRecord(array(
                'id_shop' => (int) $idShop,
                'chain_key' => $chainKey,
                'installation_id' => $installationId,
                'id_order' => (int) $idOrder,
                'id_order_invoice' => (int) $idOrderInvoice,
                'id_order_slip' => (int) $idOrderSlip,
                'operation' => 'ANULACION',
                'record_mode' => 'ANULACION',
                'document_kind' => $kind,
                'source_record_id' => $accepted && !empty($accepted['id_record']) ? (int) $accepted['id_record'] : 0,
                'issuer_nif' => $issuerNif,
                'document_number' => $identity['number'],
                'issue_date' => $identity['issue_date'],
                'invoice_type' => null,
                'rectification_type' => null,
                'subsanacion' => null,
                'rechazo_previo' => $rechazoPrevio,
                'sin_registro_previo' => $sinRegistroPrevio,
                'intent_key' => $intent,
                'record_timestamp' => $built['record_timestamp'],
                'fingerprint' => $built['fingerprint'],
                'fingerprint_input' => $built['fingerprint_input'],
                'previous_record_id' => $previous && !empty($previous['id_record']) ? (int) $previous['id_record'] : null,
                'previous_issuer_nif' => $previous ? $previous['issuer_nif'] : null,
                'previous_document_number' => $previous ? $previous['document_number'] : null,
                'previous_issue_date' => $previous ? $previous['issue_date'] : null,
                'previous_fingerprint' => $previous ? $previous['fingerprint'] : null,
                'request_xml' => $built['xml'],
                'request_sha256' => hash('sha256', $built['xml']),
                'environment' => $environment,
            ));
            $record = $this->repository->getById($recordId);
            $this->saveXml($record);
            $this->repository->enqueueRecordForTransmission($record, 0);
        } finally {
            $this->repository->releaseChainLock($chainKey);
        }

        $this->processQueue(VerifactuConfig::getQueueBatchSize());
        return $this->recordResult($this->repository->getById($recordId));
    }

    private function sendStoredRecord(array $record)
    {
        if (in_array($record['local_status'], array(
            VerifactuRepository::STATUS_ACCEPTED,
            VerifactuRepository::STATUS_ACCEPTED_WITH_ERRORS,
        ), true)) {
            return $this->recordResult($record);
        }
        if ($record['local_status'] === VerifactuRepository::STATUS_REJECTED) {
            return $this->recordResult($record);
        }
        VerifactuConfig::validateTransmissionConfiguration($this->module->version);
        if (is_object($this->module) && method_exists($this->module, 'assertPdfCompliance')) {
            $this->module->assertPdfCompliance();
        }
        $this->verifyRecordIntegrity($record);

        if ($this->repository->hasEarlierPendingTransmission($record)) {
            return array(
                'queued' => true,
                'retry_after' => 60,
                'message' => 'Existe un registro anterior de la misma cadena pendiente de resolver.',
            );
        }
        $wait = $this->repository->getWaitRemainingSeconds($record['chain_key'], $record['environment']);
        if ($wait > 0) {
            return array(
                'queued' => true,
                'retry_after' => $wait,
                'message' => 'La AEAT ha solicitado esperar ' . $wait . ' segundos antes del siguiente envio.',
            );
        }

        // Incidencia=S is transmission metadata and is only appropriate after a
        // genuinely ambiguous/failed transport. Local queue delays or protocol
        // construction errors must not turn a first valid remittance into an incident.
        $incident = $record['local_status'] === VerifactuRepository::STATUS_TRANSPORT_UNKNOWN;
        $requestXml = $incident
            ? VerifactuXmlBuilder::withIncidentFlag($record['request_xml'])
            : $record['request_xml'];
        if (!$this->repository->markSending((int) $record['id_record'])) {
            throw new Exception('No se ha podido marcar el registro como en envio.');
        }
        $idTransmission = $this->repository->startTransmission($record, $requestXml, $incident);

        try {
            $transport = $this->transport->send($requestXml, $this->module, array(
                'issuer_nif' => $record['issuer_nif'],
                'number' => $record['document_number'],
                'issue_date' => $record['issue_date'],
                'operation' => $record['operation'],
            ));
            $result = $transport['result'];
            if (!$this->repository->updateFromResponse(
                (int) $record['id_record'],
                $result,
                $transport['request'],
                $transport['response']
            )) {
                throw new Exception('La respuesta de la AEAT no se ha podido conservar en el diario local.');
            }
            $this->repository->finishTransmission(
                $idTransmission,
                !empty($result['accepted']) ? 'COMPLETED' : 'REJECTED',
                $transport['request'],
                $transport['response'],
                isset($result['record_status']) ? (string) $result['record_status'] : '',
                isset($result['error_description']) ? (string) $result['error_description'] : ''
            );
            $updated = $this->repository->getById((int) $record['id_record']);
            $this->repository->logEvent(
                !empty($result['accepted']) ? 'INFO' : 'ERROR',
                !empty($result['accepted']) ? 'AEAT_ACCEPTED' : 'AEAT_REJECTED',
                array(
                    'id_shop' => (int) $record['id_shop'],
                    'id_order' => (int) $record['id_order'],
                    'id_order_invoice' => !empty($record['id_order_invoice']) ? (int) $record['id_order_invoice'] : null,
                    'id_order_slip' => !empty($record['id_order_slip']) ? (int) $record['id_order_slip'] : null,
                    'id_record' => (int) $record['id_record'],
                    'message' => !empty($result['accepted'])
                        ? 'La AEAT ha aceptado ' . $record['document_number'] . '.'
                        : 'La AEAT ha rechazado ' . $record['document_number'] . ': ' . (string) $result['error_description'],
                )
            );
            return $this->recordResult($updated);
        } catch (VerifactuTransportException $e) {
            if ($e->isRetryable()) {
                $delay = VerifactuConfig::getRetryDelaySeconds((int) $record['retry_count'] + 1);
                $this->repository->markTransportUnknown(
                    (int) $record['id_record'],
                    $e->getMessage(),
                    $e->getSoapRequest(),
                    $e->getSoapResponse(),
                    $delay
                );
                $this->repository->finishTransmission(
                    $idTransmission,
                    'TRANSPORT_UNKNOWN',
                    $e->getSoapRequest(),
                    $e->getSoapResponse(),
                    $e->getFaultCode(),
                    $e->getMessage()
                );
                $this->repository->logEvent('WARNING', 'TRANSPORT_UNKNOWN', array(
                    'id_shop' => (int) $record['id_shop'],
                    'id_order' => (int) $record['id_order'],
                    'id_order_invoice' => !empty($record['id_order_invoice']) ? (int) $record['id_order_invoice'] : null,
                    'id_order_slip' => !empty($record['id_order_slip']) ? (int) $record['id_order_slip'] : null,
                    'id_record' => (int) $record['id_record'],
                    'message' => $e->getMessage(),
                    'context' => array(
                        'retry_after' => $delay,
                        'soap_faultcode' => $e->getFaultCode(),
                        'aeat_code' => $e->getAeatCode(),
                        'http_status' => $e->getHttpStatus(),
                    ),
                ));
            } else {
                // A soapenv:Client/protocol fault proves that AEAT did not accept
                // the remittance as a valid request. Keep the immutable fiscal
                // record generated, preserve evidence, and stop blind retries.
                $this->repository->markProtocolError(
                    (int) $record['id_record'],
                    $e->getMessage(),
                    $e->getSoapRequest(),
                    $e->getSoapResponse()
                );
                $this->repository->finishTransmission(
                    $idTransmission,
                    'PROTOCOL_ERROR',
                    $e->getSoapRequest(),
                    $e->getSoapResponse(),
                    $e->getFaultCode(),
                    $e->getMessage()
                );
                $this->repository->logEvent('ERROR', 'SOAP_PROTOCOL_ERROR', array(
                    'id_shop' => (int) $record['id_shop'],
                    'id_order' => (int) $record['id_order'],
                    'id_order_invoice' => !empty($record['id_order_invoice']) ? (int) $record['id_order_invoice'] : null,
                    'id_order_slip' => !empty($record['id_order_slip']) ? (int) $record['id_order_slip'] : null,
                    'id_record' => (int) $record['id_record'],
                    'message' => $e->getMessage(),
                    'context' => array(
                        'soap_faultcode' => $e->getFaultCode(),
                        'aeat_code' => $e->getAeatCode(),
                        'http_status' => $e->getHttpStatus(),
                    ),
                ));
            }
            throw $e;
        } catch (Throwable $e) {
            $delay = VerifactuConfig::getRetryDelaySeconds((int) $record['retry_count'] + 1);
            $this->repository->markTransportUnknown(
                (int) $record['id_record'],
                $e->getMessage(),
                '',
                '',
                $delay
            );
            $this->repository->finishTransmission($idTransmission, 'LOCAL_ERROR', '', '', '', $e->getMessage());
            throw $e;
        }
    }

    private function enqueueWaitingInvoice($order)
    {
        $environment = VerifactuConfig::getEnvironment();
        $issuer = VerifactuConfig::getIssuerNif();
        $installation = VerifactuConfig::getInstallationId();
        $chainKey = VerifactuRepository::buildChainKey($environment, $issuer, $installation);
        $idQueue = $this->repository->enqueue(array(
            'queue_key' => $environment . ':WAITING_INVOICE:' . (int) $order->get_id(),
            'environment' => $environment,
            'chain_key' => $chainKey,
            'id_shop' => (function_exists('get_current_blog_id') ? (int) get_current_blog_id() : 1),
            'id_order' => (int) $order->get_id(),
            'action' => 'WAIT_FOR_INVOICE',
            'payload' => array('reason' => 'Fiscal invoice document not generated'),
            'status' => VerifactuRepository::QUEUE_WAITING_INVOICE,
            'next_attempt_at' => null,
        ));
        $this->repository->logEvent('WARNING', 'WAITING_INVOICE', array(
            'id_shop' => (function_exists('get_current_blog_id') ? (int) get_current_blog_id() : 1),
            'id_order' => (int) $order->get_id(),
            'message' => 'Pedido sin documento fiscal emitido: se bloquean XML, huella, QR y remision hasta que exista una factura fiscal numerada.',
        ));
        return $idQueue;
    }

    private function assertOriginalNotCancelled(array $identity)
    {
        $cancellation = $this->repository->findAcceptedCancellationForIdentity(
            VerifactuConfig::getIssuerNif(),
            $identity['number'],
            $identity['issue_date'],
            VerifactuConfig::getEnvironment()
        );
        if ($cancellation) {
            throw new Exception(
                'La factura original ' . $identity['number']
                . ' consta anulada en el diario VeriFactu y no puede utilizarse como origen de una rectificativa.'
            );
        }
    }

    private function assertNoAmbiguousLegacyDuplicate(array $identity)
    {
        $legacy = $this->repository->findLegacyForIdentity(
            VerifactuConfig::getIssuerNif(),
            $identity['number'],
            $identity['issue_date']
        );
        if ($legacy) {
            throw new Exception(
                'Ya existe un registro historico para ' . $identity['number'] . ' de fecha ' . $identity['issue_date']
                . '. No se generara un alta duplicada. Revise/migre el registro historico desde el panel VeriFactu.'
            );
        }
    }

    private function assertSubsanationSource($source, $label)
    {
        if (!$source || $source['operation'] !== 'ALTA') {
            throw new Exception('No existe un alta local previa de la ' . $label . ' que pueda subsanarse.');
        }
        if (!in_array($source['local_status'], array(
            VerifactuRepository::STATUS_ACCEPTED,
            VerifactuRepository::STATUS_ACCEPTED_WITH_ERRORS,
            VerifactuRepository::STATUS_REJECTED,
        ), true)) {
            throw new Exception('El alta previa aun tiene estado remoto desconocido. Debe reintentarse, no subsanarse.');
        }
    }

    private function subsanationFlags(array $source, array $options)
    {
        if ($source['local_status'] === VerifactuRepository::STATUS_REJECTED) {
            // EstadoRegistro=Incorrecto means AEAT rejected that alta and did
            // not register it. For an initial/rejected chain with no accepted
            // alta for the same identity, AEAT documents the recovery as
            // Subsanacion=S + RechazoPrevio=X (alta tras rechazo). If an older
            // accepted alta for the identity does exist, the identity is still
            // registered remotely and a rejected correction is represented as
            // RechazoPrevio=S instead.
            $accepted = $this->repository->findAcceptedForIdentity(
                isset($source['issuer_nif']) ? (string) $source['issuer_nif'] : VerifactuConfig::getIssuerNif(),
                (string) $source['document_number'],
                (string) $source['issue_date'],
                'ALTA',
                isset($source['environment']) ? (string) $source['environment'] : VerifactuConfig::getEnvironment()
            );
            if (!$accepted || (int) $accepted['id_record'] >= (int) $source['id_record']) {
                return array('S', 'X');
            }
            return array('S', 'S');
        }

        if (!empty($options['rechazo_previo_x'])) {
            if (empty($options['confirm_exceptional_x'])) {
                throw new Exception('RechazoPrevio=X exige confirmacion expresa del supuesto excepcional documentado.');
            }
            return array('S', 'X');
        }

        return array('S', 'N');
    }

    private function mapTaxRows(array $rows)
    {
        $details = array();
        foreach ($rows as $row) {
            $operation = !empty($row['exenta']) ? $row['exenta'] : $row['calificacion'];
            $details[] = array(
                'tax_code' => $row['impuesto'],
                'regime' => $row['clave_regimen'],
                'operation' => $operation,
                'rate' => $row['rate'],
                'base' => $row['base'],
                'tax' => $row['tax'],
                'recargo_rate' => isset($row['recargo_rate']) ? $row['recargo_rate'] : 0,
                'recargo_tax' => isset($row['recargo_tax']) ? $row['recargo_tax'] : 0,
            );
        }
        return $details;
    }

    private function mapOriginalIdentities(array $identities, $issuerNif)
    {
        $result = array();
        foreach ($identities as $identity) {
            $result[] = array(
                'issuer_nif' => $issuerNif,
                'number' => $identity['number'],
                'issue_date' => $identity['issue_date'],
            );
        }
        return $result;
    }

    private function revisionData(array $document)
    {
        return array(
            'identity' => $document['identity']['number'] . '|' . $document['identity']['issue_date'],
            'invoice_type' => $document['invoice_type'],
            'rectification_type' => $document['rectification_type'],
            'originals' => array_map(function ($identity) {
                return $identity['number'] . '|' . $identity['issue_date'];
            }, $document['original_identities']),
            'recipient' => $document['recipient'],
            'tax_rows' => $document['tax_rows'],
            'tax_total' => $document['tax_total'],
            'total' => $document['total'],
            'description' => $document['description'],
        );
    }

    private function verifyRecordIntegrity(array $record)
    {
        if (!hash_equals(strtolower((string) $record['request_sha256']), hash('sha256', (string) $record['request_xml']))) {
            throw new Exception('El XML canonico del registro ha cambiado desde su generacion. Se bloquea la remision.');
        }
        if (!hash_equals(strtoupper((string) $record['fingerprint']), strtoupper(hash('sha256', (string) $record['fingerprint_input'])))) {
            throw new Exception('La entrada de huella conservada no produce la huella registrada. Se bloquea la remision.');
        }
        VerifactuXmlBuilder::validateXml($record['request_xml']);
        $dom = new DOMDocument();
        if (!$dom->loadXML($record['request_xml'], LIBXML_NONET | LIBXML_NOBLANKS)) {
            throw new Exception('El XML canonico no se puede interpretar.');
        }
        $xpath = new DOMXPath($dom);

        // A chained record legitimately contains two <Huella> elements:
        // - Encadenamiento/RegistroAnterior/Huella: fingerprint of n-1.
        // - RegistroAlta|RegistroAnulacion/Huella: fingerprint of this record.
        // Never use a document-wide //Huella selector here, otherwise every
        // non-first record is rejected as if its own fingerprint were wrong.
        $recordNodeName = strtoupper((string) $record['operation']) === 'ANULACION'
            ? 'RegistroAnulacion'
            : 'RegistroAlta';
        $ownNodes = $xpath->query(
            '//*[local-name()="' . $recordNodeName . '"]/*[local-name()="Huella"]'
        );
        if (!$ownNodes || $ownNodes->length !== 1
            || strtoupper(trim((string) $ownNodes->item(0)->textContent)) !== strtoupper((string) $record['fingerprint'])
        ) {
            throw new Exception('La huella propia embebida en el XML no coincide con el diario local.');
        }

        $previousFingerprint = strtoupper(trim((string) (isset($record['previous_fingerprint']) ? $record['previous_fingerprint'] : '')));
        $previousNodes = $xpath->query(
            '//*[local-name()="' . $recordNodeName . '"]/*[local-name()="Encadenamiento"]'
            . '/*[local-name()="RegistroAnterior"]/*[local-name()="Huella"]'
        );
        $firstNodes = $xpath->query(
            '//*[local-name()="' . $recordNodeName . '"]/*[local-name()="Encadenamiento"]'
            . '/*[local-name()="PrimerRegistro"]'
        );
        if ($previousFingerprint !== '') {
            if (!$previousNodes || $previousNodes->length !== 1
                || strtoupper(trim((string) $previousNodes->item(0)->textContent)) !== $previousFingerprint
            ) {
                throw new Exception('La huella de encadenamiento del registro anterior no coincide con el diario local.');
            }
            if ($firstNodes && $firstNodes->length > 0) {
                throw new Exception('El XML identifica como primer registro uno que tiene encadenamiento previo.');
            }
        } else {
            if ($previousNodes && $previousNodes->length > 0) {
                throw new Exception('El XML contiene un registro anterior que no consta en el diario local.');
            }
            if (!$firstNodes || $firstNodes->length !== 1
                || strtoupper(trim((string) $firstNodes->item(0)->textContent)) !== 'S'
            ) {
                throw new Exception('El primer registro de la cadena no esta identificado correctamente en el XML.');
            }
        }
    }

    private function saveXml(array $record)
    {
        $directory = DdvWpStorage::path('xml') . '/';
        if (!is_dir($directory) && !@mkdir($directory, 0750, true) && !is_dir($directory)) {
            throw new Exception('No se ha podido crear el directorio protegido de XML.');
        }

        $recordId = (int) $record['id_record'];
        if ($recordId <= 0) {
            throw new Exception('El identificador del registro no es valido para conservar el XML.');
        }

        // The local evidence filename is built exclusively from controlled
        // values. Fiscal document numbers/dates can originate in an admin form
        // for historical cancellations and must never become filesystem paths.
        $file = $directory . 'record_' . $recordId . '.xml';
        if (file_put_contents($file, $record['request_xml'], LOCK_EX) === false) {
            throw new Exception('No se ha podido conservar una copia de evidencia del XML.');
        }
        @chmod($file, 0640);
        return $file;
    }

    private function recordResult($record)
    {
        if (!$record) {
            throw new Exception('No se ha podido recuperar el registro local.');
        }
        $accepted = in_array($record['local_status'], array(
            VerifactuRepository::STATUS_ACCEPTED,
            VerifactuRepository::STATUS_ACCEPTED_WITH_ERRORS,
        ), true);
        return array(
            'accepted' => $accepted,
            'accepted_with_errors' => $record['local_status'] === VerifactuRepository::STATUS_ACCEPTED_WITH_ERRORS,
            'queued' => in_array($record['local_status'], array(
                VerifactuRepository::STATUS_GENERATED,
                VerifactuRepository::STATUS_SENDING,
                VerifactuRepository::STATUS_TRANSPORT_UNKNOWN,
            ), true),
            'estado' => (string) $record['aeat_submission_status'],
            'estado_registro' => (string) $record['aeat_record_status'],
            'codigo_error' => (string) $record['error_code'],
            'descripcion_error' => (string) $record['error_description'],
            'id_factura' => (string) $record['document_number'],
            'id_record' => (int) $record['id_record'],
            'local_status' => (string) $record['local_status'],
            'tiempo_espera' => (int) $record['wait_seconds'],
            'csv' => (string) $record['csv'],
        );
    }
}
