<?php
/**
 * Standards-oriented XML and SHA-256 fingerprint builder for AEAT VeriFactu.
 */

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

class VerifactuXmlBuilder
{
    const NS_LR = 'https://www2.agenciatributaria.gob.es/static_files/common/internet/dep/aplicaciones/es/aeat/tike/cont/ws/SuministroLR.xsd';
    const NS_SF = 'https://www2.agenciatributaria.gob.es/static_files/common/internet/dep/aplicaciones/es/aeat/tike/cont/ws/SuministroInformacion.xsd';

    public static function buildAlta(array $data)
    {
        self::requireFields($data, array(
            'issuer_nif', 'issuer_name', 'document_number', 'issue_date', 'invoice_type',
            'description', 'tax', 'system', 'record_timestamp'
        ));
        $issuerNif = VerifactuConfig::normaliseSpanishNif($data['issuer_nif'], 'NIF del emisor');
        $issuerName = VerifactuConfig::limitText($data['issuer_name'], 120, 'razon social del emisor');
        $number = self::documentNumber($data['document_number']);
        $issueDate = self::date($data['issue_date']);
        $invoiceType = strtoupper(trim((string) $data['invoice_type']));
        if (!in_array($invoiceType, array('F1', 'F2', 'R1', 'R2', 'R3', 'R4', 'R5'), true)) {
            throw new Exception('El tipo de factura del registro no es valido.');
        }

        $subsanacion = isset($data['subsanacion']) ? strtoupper(trim((string) $data['subsanacion'])) : '';
        $rechazoPrevio = isset($data['rechazo_previo']) ? strtoupper(trim((string) $data['rechazo_previo'])) : '';
        if ($subsanacion !== '' && $subsanacion !== 'S') {
            throw new Exception('Los registros ordinarios deben omitir Subsanacion; las subsanaciones deben indicar S.');
        }
        if ($subsanacion === 'S' && !in_array($rechazoPrevio, array('N', 'S', 'X'), true)) {
            throw new Exception(
                'Una subsanacion debe indicar un RechazoPrevio valido: N cuando el registro existe y fue aceptado, '
                . 'X cuando un alta inicial fue rechazada y no llego a registrarse en la AEAT, o S cuando existe un registro previo y la operacion de correccion anterior fue rechazada.'
            );
        }
        if ($subsanacion === '' && $rechazoPrevio !== '') {
            throw new Exception('RechazoPrevio solo puede informarse en un registro de subsanacion.');
        }

        $tax = self::normaliseTax($data['tax']);
        $previous = self::normalisePrevious(isset($data['previous']) ? $data['previous'] : null);
        $timestamp = self::timestamp($data['record_timestamp']);
        $fingerprintInput = self::buildAltaFingerprintInput(
            $issuerNif,
            $number,
            $issueDate,
            $invoiceType,
            $tax['tax_total'],
            $tax['gross_total'],
            $previous ? $previous['fingerprint'] : '',
            $timestamp
        );
        $fingerprint = strtoupper(hash('sha256', $fingerprintInput));

        $dom = self::newDocument();
        $root = $dom->createElementNS(self::NS_LR, 'sum:RegFactuSistemaFacturacion');
        $root->setAttributeNS('http://www.w3.org/2000/xmlns/', 'xmlns:sum1', self::NS_SF);
        $dom->appendChild($root);
        self::appendHeader($dom, $root, $issuerName, $issuerNif, !empty($data['incident']));

        $wrapper = self::add($dom, $root, self::NS_LR, 'sum:RegistroFactura');
        $alta = self::add($dom, $wrapper, self::NS_SF, 'sum1:RegistroAlta');
        self::addText($dom, $alta, 'IDVersion', '1.0');
        $idInvoice = self::add($dom, $alta, self::NS_SF, 'sum1:IDFactura');
        self::addText($dom, $idInvoice, 'IDEmisorFactura', $issuerNif);
        self::addText($dom, $idInvoice, 'NumSerieFactura', $number);
        self::addText($dom, $idInvoice, 'FechaExpedicionFactura', $issueDate);

        if (!empty($data['external_reference'])) {
            self::addText(
                $dom,
                $alta,
                'RefExterna',
                VerifactuConfig::limitText($data['external_reference'], 60, 'referencia externa')
            );
        }
        self::addText($dom, $alta, 'NombreRazonEmisor', $issuerName);
        if ($subsanacion === 'S') {
            self::addText($dom, $alta, 'Subsanacion', 'S');
            if ($rechazoPrevio !== '') {
                self::addText($dom, $alta, 'RechazoPrevio', $rechazoPrevio);
            }
        }
        self::addText($dom, $alta, 'TipoFactura', $invoiceType);

        $rectificationType = isset($data['rectification_type'])
            ? strtoupper(trim((string) $data['rectification_type']))
            : '';
        $isRectificative = in_array($invoiceType, array('R1', 'R2', 'R3', 'R4', 'R5'), true);
        if ($isRectificative) {
            if (!in_array($rectificationType, array('I', 'S'), true)) {
                throw new Exception('La factura rectificativa debe indicar TipoRectificativa I o S.');
            }
            self::addText($dom, $alta, 'TipoRectificativa', $rectificationType);
            self::appendRectifiedInvoices(
                $dom,
                $alta,
                isset($data['rectified_invoices']) ? (array) $data['rectified_invoices'] : array()
            );
            if ($rectificationType === 'S') {
                $amount = isset($data['rectification_amount']) ? (array) $data['rectification_amount'] : array();
                if (!array_key_exists('base', $amount) || !array_key_exists('tax', $amount)) {
                    throw new Exception('La rectificativa sustitutiva debe indicar base y cuota rectificadas.');
                }
                $node = self::add($dom, $alta, self::NS_SF, 'sum1:ImporteRectificacion');
                self::addText($dom, $node, 'BaseRectificada', self::amount($amount['base']));
                self::addText($dom, $node, 'CuotaRectificada', self::amount($amount['tax']));
                if (isset($amount['recargo'])) {
                    self::addText($dom, $node, 'CuotaRecargoRectificado', self::amount($amount['recargo']));
                }
            }
        } elseif ($rectificationType !== '') {
            throw new Exception('Una factura no rectificativa no puede indicar TipoRectificativa.');
        }

        if (!empty($data['operation_date'])) {
            self::addText($dom, $alta, 'FechaOperacion', self::date($data['operation_date']));
        }
        self::addText(
            $dom,
            $alta,
            'DescripcionOperacion',
            VerifactuConfig::limitText($data['description'], 500, 'descripcion de la operacion')
        );

        if (!empty($data['recipient'])) {
            self::appendRecipient($dom, $alta, (array) $data['recipient']);
        } elseif (!in_array($invoiceType, array('F2', 'R5'), true)) {
            throw new Exception('Las facturas F1 y las rectificativas R1-R4 deben identificar al destinatario.');
        }

        self::appendBreakdown($dom, $alta, $tax['details']);
        self::addText($dom, $alta, 'CuotaTotal', self::amount($tax['tax_total']));
        self::addText($dom, $alta, 'ImporteTotal', self::amount($tax['gross_total']));
        self::appendChain($dom, $alta, $previous);
        self::appendSystem($dom, $alta, (array) $data['system']);
        self::addText($dom, $alta, 'FechaHoraHusoGenRegistro', $timestamp);
        self::addText($dom, $alta, 'TipoHuella', '01');
        self::addText($dom, $alta, 'Huella', $fingerprint);

        $xml = self::serialiseAndValidate($dom);

        return array(
            'xml' => $xml,
            'fingerprint' => $fingerprint,
            'fingerprint_input' => $fingerprintInput,
            'record_timestamp' => $timestamp,
        );
    }

    public static function buildAnulacion(array $data)
    {
        self::requireFields($data, array(
            'issuer_nif', 'issuer_name', 'document_number', 'issue_date', 'system', 'record_timestamp'
        ));
        $issuerNif = VerifactuConfig::normaliseSpanishNif($data['issuer_nif'], 'NIF del emisor');
        $issuerName = VerifactuConfig::limitText($data['issuer_name'], 120, 'razon social del emisor');
        $number = self::documentNumber($data['document_number']);
        $issueDate = self::date($data['issue_date']);
        $previous = self::normalisePrevious(isset($data['previous']) ? $data['previous'] : null);
        $timestamp = self::timestamp($data['record_timestamp']);
        $fingerprintInput = self::buildCancellationFingerprintInput(
            $issuerNif,
            $number,
            $issueDate,
            $previous ? $previous['fingerprint'] : '',
            $timestamp
        );
        $fingerprint = strtoupper(hash('sha256', $fingerprintInput));

        $sinRegistroPrevio = isset($data['sin_registro_previo'])
            ? strtoupper(trim((string) $data['sin_registro_previo']))
            : '';
        $rechazoPrevio = isset($data['rechazo_previo'])
            ? strtoupper(trim((string) $data['rechazo_previo']))
            : '';
        foreach (array('SinRegistroPrevio' => $sinRegistroPrevio, 'RechazoPrevio' => $rechazoPrevio) as $label => $value) {
            if ($value !== '' && !in_array($value, array('S', 'N'), true)) {
                throw new Exception($label . ' solo admite S o N en una anulacion.');
            }
        }

        $dom = self::newDocument();
        $root = $dom->createElementNS(self::NS_LR, 'sum:RegFactuSistemaFacturacion');
        $root->setAttributeNS('http://www.w3.org/2000/xmlns/', 'xmlns:sum1', self::NS_SF);
        $dom->appendChild($root);
        self::appendHeader($dom, $root, $issuerName, $issuerNif, !empty($data['incident']));

        $wrapper = self::add($dom, $root, self::NS_LR, 'sum:RegistroFactura');
        $cancel = self::add($dom, $wrapper, self::NS_SF, 'sum1:RegistroAnulacion');
        self::addText($dom, $cancel, 'IDVersion', '1.0');
        $idInvoice = self::add($dom, $cancel, self::NS_SF, 'sum1:IDFactura');
        self::addText($dom, $idInvoice, 'IDEmisorFacturaAnulada', $issuerNif);
        self::addText($dom, $idInvoice, 'NumSerieFacturaAnulada', $number);
        self::addText($dom, $idInvoice, 'FechaExpedicionFacturaAnulada', $issueDate);
        if (!empty($data['external_reference'])) {
            self::addText(
                $dom,
                $cancel,
                'RefExterna',
                VerifactuConfig::limitText($data['external_reference'], 60, 'referencia externa')
            );
        }
        if ($sinRegistroPrevio !== '') {
            self::addText($dom, $cancel, 'SinRegistroPrevio', $sinRegistroPrevio);
        }
        if ($rechazoPrevio !== '') {
            self::addText($dom, $cancel, 'RechazoPrevio', $rechazoPrevio);
        }
        self::appendChain($dom, $cancel, $previous);
        self::appendSystem($dom, $cancel, (array) $data['system']);
        self::addText($dom, $cancel, 'FechaHoraHusoGenRegistro', $timestamp);
        self::addText($dom, $cancel, 'TipoHuella', '01');
        self::addText($dom, $cancel, 'Huella', $fingerprint);

        $xml = self::serialiseAndValidate($dom);

        return array(
            'xml' => $xml,
            'fingerprint' => $fingerprint,
            'fingerprint_input' => $fingerprintInput,
            'record_timestamp' => $timestamp,
        );
    }

    public static function withIncidentFlag($xml)
    {
        $dom = new DOMDocument();
        if (!$dom->loadXML((string) $xml, LIBXML_NONET | LIBXML_NOBLANKS)) {
            throw new Exception('No se puede preparar el reenvio: el XML almacenado no es valido.');
        }
        $xpath = new DOMXPath($dom);
        $headers = $xpath->query('/*[local-name()="RegFactuSistemaFacturacion"]/*[local-name()="Cabecera"]');
        if (!$headers || $headers->length !== 1) {
            throw new Exception('No se ha encontrado una cabecera unica en el XML almacenado.');
        }
        $header = $headers->item(0);
        $remission = $xpath->query('./*[local-name()="RemisionVoluntaria"]', $header);
        if ($remission && $remission->length > 0) {
            $remissionNode = $remission->item(0);
        } else {
            $remissionNode = self::add($dom, $header, self::NS_SF, 'sum1:RemisionVoluntaria');
        }
        $incidents = $xpath->query('./*[local-name()="Incidencia"]', $remissionNode);
        if ($incidents && $incidents->length > 0) {
            $incidents->item(0)->nodeValue = 'S';
        } else {
            self::addText($dom, $remissionNode, 'Incidencia', 'S');
        }

        return self::serialiseAndValidate($dom);
    }

    public static function buildAltaFingerprintInput(
        $issuerNif,
        $number,
        $issueDate,
        $invoiceType,
        $taxTotal,
        $grossTotal,
        $previousFingerprint,
        $timestamp
    ) {
        return 'IDEmisorFactura=' . trim((string) $issuerNif)
            . '&NumSerieFactura=' . trim((string) $number)
            . '&FechaExpedicionFactura=' . trim((string) $issueDate)
            . '&TipoFactura=' . trim((string) $invoiceType)
            . '&CuotaTotal=' . self::amount($taxTotal)
            . '&ImporteTotal=' . self::amount($grossTotal)
            . '&Huella=' . trim((string) $previousFingerprint)
            . '&FechaHoraHusoGenRegistro=' . trim((string) $timestamp);
    }

    public static function buildCancellationFingerprintInput(
        $issuerNif,
        $number,
        $issueDate,
        $previousFingerprint,
        $timestamp
    ) {
        return 'IDEmisorFacturaAnulada=' . trim((string) $issuerNif)
            . '&NumSerieFacturaAnulada=' . trim((string) $number)
            . '&FechaExpedicionFacturaAnulada=' . trim((string) $issueDate)
            . '&Huella=' . trim((string) $previousFingerprint)
            . '&FechaHoraHusoGenRegistro=' . trim((string) $timestamp);
    }

    public static function validateXml($xml)
    {
        $dom = new DOMDocument();
        if (!$dom->loadXML((string) $xml, LIBXML_NONET | LIBXML_NOBLANKS)) {
            throw new Exception('El XML generado no es XML bien formado.');
        }
        self::validateDocument($dom);
        return true;
    }

    private static function appendHeader(DOMDocument $dom, DOMElement $root, $issuerName, $issuerNif, $incident)
    {
        $header = self::add($dom, $root, self::NS_LR, 'sum:Cabecera');
        $obligor = self::add($dom, $header, self::NS_SF, 'sum1:ObligadoEmision');
        self::addText($dom, $obligor, 'NombreRazon', $issuerName);
        self::addText($dom, $obligor, 'NIF', $issuerNif);
        if ($incident) {
            $remission = self::add($dom, $header, self::NS_SF, 'sum1:RemisionVoluntaria');
            self::addText($dom, $remission, 'Incidencia', 'S');
        }
    }

    private static function appendRectifiedInvoices(DOMDocument $dom, DOMElement $alta, array $invoices)
    {
        if (empty($invoices)) {
            throw new Exception('La factura rectificativa debe identificar al menos una factura rectificada.');
        }
        if (count($invoices) > 1000) {
            throw new Exception('No pueden identificarse mas de 1000 facturas rectificadas en un registro.');
        }
        $container = self::add($dom, $alta, self::NS_SF, 'sum1:FacturasRectificadas');
        foreach ($invoices as $invoice) {
            $invoice = (array) $invoice;
            self::requireFields($invoice, array('issuer_nif', 'number', 'issue_date'));
            $node = self::add($dom, $container, self::NS_SF, 'sum1:IDFacturaRectificada');
            self::addText(
                $dom,
                $node,
                'IDEmisorFactura',
                VerifactuConfig::normaliseSpanishNif($invoice['issuer_nif'], 'NIF de la factura rectificada')
            );
            self::addText($dom, $node, 'NumSerieFactura', self::documentNumber($invoice['number']));
            self::addText($dom, $node, 'FechaExpedicionFactura', self::date($invoice['issue_date']));
        }
    }

    private static function appendRecipient(DOMDocument $dom, DOMElement $alta, array $recipient)
    {
        self::requireFields($recipient, array('name', 'identifier_kind', 'identifier'));
        $container = self::add($dom, $alta, self::NS_SF, 'sum1:Destinatarios');
        $node = self::add($dom, $container, self::NS_SF, 'sum1:IDDestinatario');
        self::addText(
            $dom,
            $node,
            'NombreRazon',
            VerifactuConfig::limitText($recipient['name'], 120, 'nombre del destinatario')
        );
        if ($recipient['identifier_kind'] === 'NIF') {
            self::addText(
                $dom,
                $node,
                'NIF',
                VerifactuConfig::normaliseSpanishNif($recipient['identifier'], 'NIF del destinatario')
            );
            return;
        }
        if ($recipient['identifier_kind'] !== 'IDOtro') {
            throw new Exception('El tipo de identificador del destinatario no es valido.');
        }
        self::requireFields($recipient, array('country_code', 'id_type'));
        $country = strtoupper(trim((string) $recipient['country_code']));
        if (!preg_match('/^[A-Z]{2}$/', $country) || $country === 'ES') {
            throw new Exception('IDOtro debe utilizar un codigo de pais extranjero valido.');
        }
        $idType = trim((string) $recipient['id_type']);
        if (!in_array($idType, array('02', '03', '04', '05', '06', '07'), true)) {
            throw new Exception('El tipo IDOtro del destinatario no es valido.');
        }
        $identifier = trim((string) $recipient['identifier']);
        if ($identifier === '' || self::length($identifier) > 20) {
            throw new Exception('El identificador extranjero debe contener entre 1 y 20 caracteres.');
        }
        $other = self::add($dom, $node, self::NS_SF, 'sum1:IDOtro');
        self::addText($dom, $other, 'CodigoPais', $country);
        self::addText($dom, $other, 'IDType', $idType);
        self::addText($dom, $other, 'ID', $identifier);
    }

    private static function appendBreakdown(DOMDocument $dom, DOMElement $alta, array $details)
    {
        if (empty($details) || count($details) > 12) {
            throw new Exception('El desglose debe contener entre 1 y 12 lineas.');
        }
        $container = self::add($dom, $alta, self::NS_SF, 'sum1:Desglose');
        foreach ($details as $detail) {
            $detail = (array) $detail;
            self::requireFields($detail, array('regime', 'operation', 'base', 'tax', 'rate'));
            $operation = strtoupper(trim((string) $detail['operation']));
            $regime = trim((string) $detail['regime']);
            if (!preg_match('/^\d{2}$/', $regime)) {
                throw new Exception('La clave de regimen del desglose debe tener dos digitos.');
            }
            if (!in_array($operation, array('S1', 'S2', 'N1', 'N2', 'E1', 'E2', 'E3', 'E4', 'E5', 'E6'), true)) {
                throw new Exception('La calificacion fiscal del desglose no es valida.');
            }

            $node = self::add($dom, $container, self::NS_SF, 'sum1:DetalleDesglose');
            self::addText($dom, $node, 'Impuesto', isset($detail['tax_code']) ? $detail['tax_code'] : '01');
            self::addText($dom, $node, 'ClaveRegimen', $regime);
            if (strpos($operation, 'E') === 0) {
                self::addText($dom, $node, 'OperacionExenta', $operation);
            } else {
                self::addText($dom, $node, 'CalificacionOperacion', $operation);
            }

            $base = (float) $detail['base'];
            $tax = (float) $detail['tax'];
            $rate = max(0.0, round((float) $detail['rate'], 2));
            if (in_array($operation, array('S1', 'S2'), true)) {
                if ($operation === 'S2' && abs($tax) >= 0.005) {
                    throw new Exception('Una operacion con inversion del sujeto pasivo no puede contener cuota repercutida.');
                }
                self::addText($dom, $node, 'TipoImpositivo', self::rate($rate));
            } elseif (abs($tax) >= 0.005) {
                throw new Exception('Una operacion exenta o no sujeta no puede contener cuota repercutida.');
            }
            self::addText($dom, $node, 'BaseImponibleOimporteNoSujeto', self::amount($base));
            if (in_array($operation, array('S1', 'S2'), true)) {
                self::addText($dom, $node, 'CuotaRepercutida', self::amount($tax));
            }

            $recargoRate = isset($detail['recargo_rate']) ? round((float) $detail['recargo_rate'], 2) : 0.0;
            $recargoTax = isset($detail['recargo_tax']) ? round((float) $detail['recargo_tax'], 2) : 0.0;
            if (abs($recargoRate) >= 0.005 || abs($recargoTax) >= 0.005) {
                if ($operation !== 'S1') {
                    throw new Exception('El recargo de equivalencia solo puede informarse en una operacion S1.');
                }
                self::addText($dom, $node, 'TipoRecargoEquivalencia', self::rate($recargoRate));
                self::addText($dom, $node, 'CuotaRecargoEquivalencia', self::amount($recargoTax));
            }
        }
    }

    private static function appendChain(DOMDocument $dom, DOMElement $parent, $previous)
    {
        $chain = self::add($dom, $parent, self::NS_SF, 'sum1:Encadenamiento');
        if (!$previous) {
            self::addText($dom, $chain, 'PrimerRegistro', 'S');
            return;
        }
        $node = self::add($dom, $chain, self::NS_SF, 'sum1:RegistroAnterior');
        self::addText($dom, $node, 'IDEmisorFactura', $previous['issuer_nif']);
        self::addText($dom, $node, 'NumSerieFactura', $previous['document_number']);
        self::addText($dom, $node, 'FechaExpedicionFactura', $previous['issue_date']);
        self::addText($dom, $node, 'Huella', $previous['fingerprint']);
    }

    private static function appendSystem(DOMDocument $dom, DOMElement $parent, array $system)
    {
        self::requireFields($system, array(
            'producer_name', 'producer_nif', 'system_name', 'system_id', 'version',
            'installation_id', 'only_verifactu', 'possible_multi_ot', 'multiple_ot'
        ));
        $node = self::add($dom, $parent, self::NS_SF, 'sum1:SistemaInformatico');
        self::addText(
            $dom,
            $node,
            'NombreRazon',
            VerifactuConfig::limitText($system['producer_name'], 120, 'razon social del productor')
        );
        self::addText(
            $dom,
            $node,
            'NIF',
            VerifactuConfig::normaliseSpanishNif($system['producer_nif'], 'NIF del productor')
        );
        self::addText($dom, $node, 'NombreSistemaInformatico', VerifactuConfig::limitText($system['system_name'], 30, 'nombre del sistema'));
        self::addText($dom, $node, 'IdSistemaInformatico', VerifactuConfig::limitText($system['system_id'], 2, 'identificador del sistema'));
        self::addText($dom, $node, 'Version', VerifactuConfig::limitText($system['version'], 50, 'version del sistema'));
        self::addText($dom, $node, 'NumeroInstalacion', VerifactuConfig::limitText($system['installation_id'], 100, 'numero de instalacion'));
        foreach (array(
            'TipoUsoPosibleSoloVerifactu' => $system['only_verifactu'],
            'TipoUsoPosibleMultiOT' => $system['possible_multi_ot'],
            'IndicadorMultiplesOT' => $system['multiple_ot'],
        ) as $tag => $value) {
            $value = strtoupper(trim((string) $value));
            if (!in_array($value, array('S', 'N'), true)) {
                throw new Exception('El indicador ' . $tag . ' del sistema informatico no es valido.');
            }
            self::addText($dom, $node, $tag, $value);
        }
    }

    private static function normaliseTax($tax)
    {
        $tax = (array) $tax;
        self::requireFields($tax, array('details', 'tax_total', 'gross_total'));
        $details = (array) $tax['details'];
        if (empty($details)) {
            throw new Exception('El documento no contiene desglose fiscal.');
        }
        $taxTotal = round((float) $tax['tax_total'], 2);
        $grossTotal = round((float) $tax['gross_total'], 2);
        $detailGross = 0.0;
        $detailTax = 0.0;
        $allDetailsUseExceptionalTotalRegime = true;
        $exceptionalTotalRegimes = array('03', '05', '06', '08', '09');
        foreach ($details as $detail) {
            $detail = (array) $detail;
            $detailGross += (float) $detail['base'] + (float) $detail['tax']
                + (isset($detail['recargo_tax']) ? (float) $detail['recargo_tax'] : 0.0);
            $detailTax += (float) $detail['tax']
                + (isset($detail['recargo_tax']) ? (float) $detail['recargo_tax'] : 0.0);
            $regime = isset($detail['regime']) ? str_pad(trim((string) $detail['regime']), 2, '0', STR_PAD_LEFT) : '';
            if (!in_array($regime, $exceptionalTotalRegimes, true)) {
                $allDetailsUseExceptionalTotalRegime = false;
            }
        }

        // The immutable CuotaTotal and ImporteTotal always come from the
        // issued PrestaShop OrderInvoice. For ordinary regimes we additionally
        // reconcile them against the detail lines. AEAT explicitly exempts
        // regimes 03/05/06/08/09 from BOTH ordinary CuotaTotal and ImporteTotal
        // reconciliation rules. REBU (03) is the common case: the customer
        // invoice does not expose the taxable margin, so the detail can report
        // base/quota 0 while ImporteTotal remains the exact invoice total.
        if (!$allDetailsUseExceptionalTotalRegime
            && (abs(round($detailTax, 2) - $taxTotal) > 0.02
                || abs(round($detailGross, 2) - $grossTotal) > 0.02)
        ) {
            throw new Exception('El desglose fiscal no coincide con CuotaTotal e ImporteTotal.');
        }

        return array('details' => $details, 'tax_total' => $taxTotal, 'gross_total' => $grossTotal);
    }

    private static function normalisePrevious($previous)
    {
        if (empty($previous)) {
            return null;
        }
        $previous = (array) $previous;
        $map = array(
            'issuer_nif' => isset($previous['issuer_nif']) ? $previous['issuer_nif'] : (isset($previous['previous_issuer_nif']) ? $previous['previous_issuer_nif'] : ''),
            'document_number' => isset($previous['document_number']) ? $previous['document_number'] : (isset($previous['previous_document_number']) ? $previous['previous_document_number'] : ''),
            'issue_date' => isset($previous['issue_date']) ? $previous['issue_date'] : (isset($previous['previous_issue_date']) ? $previous['previous_issue_date'] : ''),
            'fingerprint' => isset($previous['fingerprint']) ? $previous['fingerprint'] : (isset($previous['previous_fingerprint']) ? $previous['previous_fingerprint'] : ''),
        );
        self::requireFields($map, array('issuer_nif', 'document_number', 'issue_date', 'fingerprint'));
        $fingerprint = strtoupper(trim((string) $map['fingerprint']));
        if (!preg_match('/^[A-F0-9]{64}$/', $fingerprint)) {
            throw new Exception('La huella del registro anterior no tiene formato SHA-256.');
        }

        return array(
            'issuer_nif' => VerifactuConfig::normaliseSpanishNif($map['issuer_nif'], 'NIF del registro anterior'),
            'document_number' => self::documentNumber($map['document_number']),
            'issue_date' => self::date($map['issue_date']),
            'fingerprint' => $fingerprint,
        );
    }

    private static function newDocument()
    {
        $dom = new DOMDocument('1.0', 'UTF-8');
        $dom->formatOutput = true;
        $dom->preserveWhiteSpace = false;
        return $dom;
    }

    private static function add(DOMDocument $dom, DOMElement $parent, $namespace, $qualifiedName)
    {
        $node = $dom->createElementNS($namespace, $qualifiedName);
        $parent->appendChild($node);
        return $node;
    }

    private static function addText(DOMDocument $dom, DOMElement $parent, $name, $value)
    {
        $node = $dom->createElementNS(self::NS_SF, 'sum1:' . $name);
        $node->appendChild($dom->createTextNode((string) $value));
        $parent->appendChild($node);
        return $node;
    }

    private static function serialiseAndValidate(DOMDocument $dom)
    {
        self::validateDocument($dom);
        $xml = $dom->saveXML();
        if (!is_string($xml) || $xml === '') {
            throw new Exception('No se ha podido serializar el XML VeriFactu.');
        }
        return $xml;
    }

    private static function validateDocument(DOMDocument $dom)
    {
        $schema = dirname(__DIR__) . '/wsdl/SuministroLR.xsd';
        if (!is_file($schema)) {
            throw new Exception('No se encuentra el esquema XSD oficial incluido en el modulo.');
        }
        $previous = libxml_use_internal_errors(true);
        try {
            if (!$dom->schemaValidate($schema)) {
                $messages = array();
                foreach (array_slice(libxml_get_errors(), 0, 5) as $error) {
                    $messages[] = trim($error->message) . ' (linea ' . (int) $error->line . ')';
                }
                throw new Exception('El XML no cumple el XSD VeriFactu: ' . implode('; ', $messages));
            }
        } finally {
            libxml_clear_errors();
            libxml_use_internal_errors($previous);
        }
    }

    private static function requireFields(array $data, array $fields)
    {
        foreach ($fields as $field) {
            if (!array_key_exists($field, $data)
                || $data[$field] === null
                || (is_string($data[$field]) && trim($data[$field]) === '')
            ) {
                throw new Exception('Falta el campo obligatorio ' . $field . ' para construir el registro VeriFactu.');
            }
        }
    }

    private static function documentNumber($value)
    {
        $value = trim((string) $value);
        if ($value === '' || self::length($value) > 60) {
            throw new Exception('La serie y numero del documento deben contener entre 1 y 60 caracteres.');
        }
        return $value;
    }

    private static function date($value)
    {
        $value = trim((string) $value);
        $parsed = DateTime::createFromFormat('!d-m-Y', $value);
        if (!$parsed || $parsed->format('d-m-Y') !== $value) {
            throw new Exception('La fecha VeriFactu debe tener formato DD-MM-AAAA y ser valida.');
        }
        return $value;
    }

    private static function timestamp($value)
    {
        $value = trim((string) $value);
        try {
            $date = new DateTime($value);
        } catch (Exception $e) {
            throw new Exception('La fecha y hora del registro no es valida.');
        }
        if (!preg_match('/[+-]\d{2}:\d{2}$/', $value)) {
            throw new Exception('La fecha y hora del registro debe incluir el huso horario.');
        }
        return $date->format('Y-m-d\TH:i:sP');
    }

    private static function amount($value)
    {
        $value = round((float) $value, 2);
        if (abs($value) >= 1000000000000.0) {
            throw new Exception('Un importe supera el limite de 12 enteros y 2 decimales.');
        }
        if (abs($value) < 0.005) {
            $value = 0.0;
        }
        return number_format($value, 2, '.', '');
    }

    private static function rate($value)
    {
        $value = round((float) $value, 2);
        if ($value < 0 || $value > 999.99) {
            throw new Exception('El tipo impositivo no es valido.');
        }
        return number_format($value, 2, '.', '');
    }

    private static function length($value)
    {
        return function_exists('mb_strlen') ? mb_strlen((string) $value, 'UTF-8') : strlen((string) $value);
    }
}
