<?php
/**
 * Minimal SOAP 1.1 document/literal HTTP client for AEAT VeriFactu.
 *
 * We construct the SOAP envelope explicitly instead of delegating the body
 * serialization to ext-soap. This guarantees the exact Envelope/Header/Body
 * structure required by AEAT while keeping mutual TLS certificate handling
 * under our control.
 */

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

class VerifactuSoapHttpClient
{
    const SOAP_NS = 'http://schemas.xmlsoap.org/soap/envelope/';

    public static function buildEnvelope($payloadXml)
    {
        if (!class_exists('DOMDocument')) {
            throw new Exception('La extension DOM de PHP es obligatoria para construir el sobre SOAP.');
        }

        $payload = new DOMDocument();
        $previous = libxml_use_internal_errors(true);
        libxml_clear_errors();
        $loaded = $payload->loadXML((string) $payloadXml, LIBXML_NONET | LIBXML_NOBLANKS);
        $errors = libxml_get_errors();
        libxml_clear_errors();
        libxml_use_internal_errors($previous);
        if (!$loaded || !$payload->documentElement) {
            $message = !empty($errors) ? trim($errors[0]->message) : 'XML no valido';
            throw new Exception('No se puede construir el sobre SOAP: ' . $message);
        }

        $soap = new DOMDocument('1.0', 'UTF-8');
        $soap->formatOutput = false;
        $soap->preserveWhiteSpace = false;
        $envelope = $soap->createElementNS(self::SOAP_NS, 'soapenv:Envelope');
        $soap->appendChild($envelope);
        $envelope->appendChild($soap->createElementNS(self::SOAP_NS, 'soapenv:Header'));
        $body = $soap->createElementNS(self::SOAP_NS, 'soapenv:Body');
        $envelope->appendChild($body);
        $body->appendChild($soap->importNode($payload->documentElement, true));

        $xml = $soap->saveXML();
        if (!is_string($xml) || $xml === '') {
            throw new Exception('No se ha podido serializar el sobre SOAP.');
        }
        return $xml;
    }

    /**
     * Sends a complete SOAP 1.1 message over HTTPS mutual TLS.
     * Returns the actual request envelope and response bytes for evidence.
     */
    public static function post($payloadXml, $endpoint, array $certificate, $userAgent)
    {
        if (!extension_loaded('curl')) {
            throw new VerifactuTransportException(
                'La extension cURL de PHP es obligatoria para remitir registros a la AEAT.'
            );
        }
        if (!extension_loaded('openssl')) {
            throw new VerifactuTransportException(
                'La extension OpenSSL de PHP no esta activa.'
            );
        }
        if (empty($certificate['path']) || !is_file($certificate['path'])) {
            throw new VerifactuTransportException('No existe el certificado temporal para la conexion TLS.');
        }

        $request = self::buildEnvelope($payloadXml);
        $ch = curl_init();
        if ($ch === false) {
            throw new VerifactuTransportException('No se ha podido inicializar cURL.', $request);
        }

        $headers = array(
            'Content-Type: text/xml; charset=UTF-8',
            'Accept: text/xml',
            'SOAPAction: ""',
            'Connection: close',
        );
        $options = array(
            CURLOPT_URL => (string) $endpoint,
            CURLOPT_POST => true,
            CURLOPT_POSTFIELDS => $request,
            CURLOPT_HTTPHEADER => $headers,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_HEADER => false,
            CURLOPT_CONNECTTIMEOUT => 30,
            CURLOPT_TIMEOUT => 60,
            CURLOPT_SSL_VERIFYPEER => true,
            CURLOPT_SSL_VERIFYHOST => 2,
            CURLOPT_SSLCERT => $certificate['path'],
            CURLOPT_SSLKEY => $certificate['path'],
            CURLOPT_USERAGENT => (string) $userAgent,
        );
        if (defined('CURLOPT_SSLCERTTYPE')) {
            $options[CURLOPT_SSLCERTTYPE] = 'PEM';
        }
        if (defined('CURLOPT_SSLKEYTYPE')) {
            $options[CURLOPT_SSLKEYTYPE] = 'PEM';
        }
        $passphrase = isset($certificate['passphrase']) ? (string) $certificate['passphrase'] : '';
        if ($passphrase !== '' && defined('CURLOPT_KEYPASSWD')) {
            $options[CURLOPT_KEYPASSWD] = $passphrase;
        }
        if (defined('CURLOPT_HTTP_VERSION') && defined('CURL_HTTP_VERSION_1_1')) {
            $options[CURLOPT_HTTP_VERSION] = CURL_HTTP_VERSION_1_1;
        }
        if (defined('CURLOPT_SSLVERSION') && defined('CURL_SSLVERSION_TLSv1_2')) {
            $options[CURLOPT_SSLVERSION] = CURL_SSLVERSION_TLSv1_2;
        }

        curl_setopt_array($ch, $options);
        $response = curl_exec($ch);
        $errno = curl_errno($ch);
        $curlError = curl_error($ch);
        $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
        curl_close($ch);

        $responseText = is_string($response) ? $response : '';
        if ($response === false || $errno !== 0) {
            throw new VerifactuTransportException(
                'No se ha podido completar la conexion HTTPS con la AEAT: ' . ($curlError !== '' ? $curlError : 'error cURL ' . $errno),
                $request,
                $responseText,
                null,
                true,
                '',
                '',
                '',
                $httpCode
            );
        }
        if ($responseText === '') {
            throw new VerifactuTransportException(
                'La AEAT no ha devuelto una respuesta SOAP.',
                $request,
                '',
                null,
                true,
                '',
                '',
                '',
                $httpCode
            );
        }

        $fault = self::parseFault($responseText);
        if ($fault !== null) {
            $aeatCode = self::extractAeatCode($fault['faultstring']);
            $retryable = self::isRetryableFault($fault['faultcode'], $aeatCode, $fault['faultstring']);
            $message = 'SOAP Fault AEAT';
            if ($fault['faultcode'] !== '') {
                $message .= ' [' . $fault['faultcode'] . ']';
            }
            if ($fault['faultstring'] !== '') {
                $message .= ': ' . $fault['faultstring'];
            }
            throw new VerifactuTransportException(
                $message,
                $request,
                $responseText,
                null,
                $retryable,
                $fault['faultcode'],
                $fault['faultstring'],
                $aeatCode,
                $httpCode
            );
        }

        if ($httpCode >= 400) {
            throw new VerifactuTransportException(
                'La AEAT ha devuelto HTTP ' . $httpCode . ' sin una respuesta SOAP Fault interpretable.',
                $request,
                $responseText,
                null,
                true,
                '',
                '',
                '',
                $httpCode
            );
        }

        return array(
            'request' => $request,
            'response' => $responseText,
            'http_status' => $httpCode,
        );
    }

    public static function parseFault($soapXml)
    {
        if (!class_exists('DOMDocument')) {
            return null;
        }
        $doc = new DOMDocument();
        $previous = libxml_use_internal_errors(true);
        libxml_clear_errors();
        $loaded = $doc->loadXML((string) $soapXml, LIBXML_NONET | LIBXML_NOBLANKS);
        libxml_clear_errors();
        libxml_use_internal_errors($previous);
        if (!$loaded) {
            return null;
        }
        $xp = new DOMXPath($doc);
        $fault = $xp->query('//*[local-name()="Fault"]')->item(0);
        if (!$fault) {
            return null;
        }
        $code = $xp->query('./*[local-name()="faultcode"]', $fault);
        $text = $xp->query('./*[local-name()="faultstring"]', $fault);
        return array(
            'faultcode' => $code && $code->length ? trim((string) $code->item(0)->textContent) : '',
            'faultstring' => $text && $text->length ? trim((string) $text->item(0)->textContent) : '',
        );
    }

    public static function extractAeatCode($faultString)
    {
        if (preg_match('/Codigo\s*\[\s*([0-9]+)\s*\]/i', (string) $faultString, $m)) {
            return (string) $m[1];
        }
        return '';
    }

    public static function isRetryableFault($faultCode, $aeatCode = '', $faultString = '')
    {
        $faultCode = strtolower(trim((string) $faultCode));
        $aeatCode = trim((string) $aeatCode);
        $faultString = strtolower(trim((string) $faultString));

        // SOAP 1.1 semantics take precedence. Client means the message must be
        // corrected; Server means the request can be retried later.
        if ($faultCode !== '' && preg_match('/(^|:)client$/', $faultCode)) {
            return false;
        }
        if ($faultCode !== '' && preg_match('/(^|:)server$/', $faultCode)) {
            return true;
        }

        // Legacy responses sometimes exposed only the AEAT code/text. Codigo
        // 102 is explicitly an internal server error in the responses observed
        // by this integration, so it must never be treated as malformed XML.
        if ($aeatCode === '102'
            || strpos($faultString, 'error interno en el servidor') !== false
            || strpos($faultString, 'no disponible') !== false
        ) {
            return true;
        }

        // Known client/protocol codes remain non-retryable when no SOAP fault
        // class is available. 102 is deliberately excluded.
        if (in_array($aeatCode, array('100', '101', '103', '104', '106'), true)) {
            return false;
        }

        // Unknown faults are conservative: remote state is not proven.
        return true;
    }
}
