<?php

namespace Twnt4All\Engine\Soap;

use Twnt4All\Engine\Contract\ConfigProviderInterface;
use Twnt4All\Engine\Support\EngineHelper;
use Twnt4All\Engine\TwintCore;
use Twnt4All\Exception\SoapNotLoadedException;
use Twnt4All\Exception\TwintCredentialsNotSetException;

/**
 * TWINT Merchant Service v8.4 client.
 *
 * NOTE ON XML SCHEMAS — HISTORICAL VS CURRENT
 * -------------------------------------------
 * Earlier versions of this plugin shipped a full copy of the TWINT WSDL and
 * XSD contract under `schemas/v8_4/`. WordPress.org's plugin review team has
 * a strict blanket policy that only allows a hardcoded list of file
 * extensions in the ZIP (php / js / css / txt / md / json / xml / images /
 * po-mo-pot). Even after renaming the `.wsdl` and `.xsd` files to `.xml` the
 * automated scans still flag the WSDL/XSD content and refuse the submission.
 *
 * The client is therefore rewritten in **non-WSDL mode** :
 *   - PHP's `\SoapClient` is still used **only for its transport layer** :
 *     mTLS handshake driven by the merchant's .pem file, SSL cipher tuning,
 *     gzip, timeouts, keep-alive.
 *   - The request XML is built by hand by `build_envelope()` — no schema is
 *     needed at runtime, the operation-specific structure lives in each
 *     public method (`start_order()`, `monitor_order()`, etc).
 *   - The response XML is parsed by hand by `sxml_to_object()` into a
 *     `stdClass` tree that mimics SoapClient's classic shape so every
 *     existing consumer (`$response->Order->Status->Status->_`, etc.)
 *     keeps working without any refactor at call sites.
 *
 * TESTING THE NON-WSDL REWRITE
 * ----------------------------
 * There is no automated integration test suite against a real TWINT sandbox.
 * The recommended validation path is a small-amount live payment on the
 * production TWINT service, watching the plugin's own journal for any
 * response parsing anomaly (empty `$response->Order`, missing `->_` on
 * status leaves, etc.). See docs/checkout-testing.md for the exact steps.
 */
class TwintSoapClient
{
    const HEADER_NAMESPACE    = 'http://service.twint.ch/header/types/v8_4';
    const MERCHANT_NAMESPACE  = 'http://service.twint.ch/merchant/types/v8_4';
    const BASE_NAMESPACE      = 'http://service.twint.ch/base/types/v8_4';
    const SOAP_ENV_NAMESPACE  = 'http://schemas.xmlsoap.org/soap/envelope/';
    const PAT_LOCATION        = 'https://service-pat.twint.ch/merchant/service/TWINTMerchantServiceV8_4';
    const INT_LOCATION        = 'https://service-int.twint.ch/merchant/service/TWINTMerchantServiceV8_4';
    const PRODUCTION_LOCATION = 'https://service.twint.ch/merchant/service/TWINTMerchantServiceV8_4';

    /** @var \SoapClient */
    private $client;

    /** @var string */
    private $message_id;

    /** @var string */
    private $password;

    /** @var ConfigProviderInterface */
    private $data_provider;

    /**
     * @param null|ConfigProviderInterface $data_provider
     * @throws SoapNotLoadedException
     * @throws TwintCredentialsNotSetException
     */
    public function __construct( $data_provider = null )
    {
        $this->data_provider = $data_provider;
        $this->create_client();
    }

    /**
     * @param $fault
     * @return string
     */
    private function get_error( $fault )
    {
        return sprintf( "ERROR: The TWINT Server returned Error-Code '%s' with the following message: %s ()", $fault->faultcode, $fault->faultstring );
    }

    /**
     * Instantiates the non-WSDL SoapClient. The transport options (mTLS,
     * gzip, timeout, proxy) match the historical WSDL-mode wiring 1-to-1.
     */
    private function create_client()
    {
        if ( !class_exists( 'SoapClient' ) ) {
            TwintCore::$logger::log_error( 'SoapClient not found', 'The SOAP extension is not enabled on your server. SOAP is required for the TWINT plugin.', $this->data_provider );

            throw new SoapNotLoadedException();
        }

        // Resolve the certificate path LIVE so a cert re-upload in the
        // same request (e.g. wizard step 2 : save creds → convert cert →
        // test connection) reads the just-written file name from the
        // option, not the T4A_CERTIFICATE_PATH constant that was frozen
        // at plugin boot with the previous file name.
        $certificate    = \Twnt4All\Engine\EnvironmentConfig::get_certificate_file_path();
        // Read the password live via EnvironmentConfig::get_certificate_password()
        // so we always pass the decrypted plaintext to the SoapClient's
        // `passphrase` option. The wp_option now stores an encrypted
        // blob (`S1:…` / `G1:…`) — reading it raw via get_option +
        // stripcslashes would hand the SoapClient the ciphertext, the
        // mTLS handshake would fail silently and TWINT would drop the
        // connection with an empty response.
        $this->password = \Twnt4All\Engine\EnvironmentConfig::get_certificate_password();

        if ( !$certificate || !$this->password || empty( $this->data_provider->get_merchant_uuid() ) ) {
            throw new TwintCredentialsNotSetException();
        }

        $opts = array(
            'ssl' => array( 'ciphers' => 'DEFAULT:!DH' )
        );

        // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- T4A_PREFIX equals the plugin's internal prefix (`t4a_twint`) ; the sniffer cannot statically resolve the concatenation.
        $args = apply_filters( T4A_PREFIX . '_soap_data', [
            // In non-WSDL mode we MUST provide `location` (the endpoint) and
            // `uri` (the tns of the request/response body elements). Without
            // these SoapClient would refuse to construct.
            'location'           => $this->get_location(),
            'uri'                => static::MERCHANT_NAMESPACE,
            'soap_version'       => SOAP_1_1,
            'local_cert'         => $certificate,
            'passphrase'         => $this->password,
            'connection_timeout' => T4A_CONNECTION_TIMEOUT,
            'keep_alive'         => false,
            'compression'        => ( SOAP_COMPRESSION_ACCEPT | SOAP_COMPRESSION_GZIP ),
            'stream_context'     => stream_context_create( $opts ),
        ] );

        if ( !empty( T4A_PROXY_HOST ) ) {
            $args = array_merge( $args, array( 'proxy_host' => T4A_PROXY_HOST ) );
        }
        if ( !empty( T4A_PROXY_PORT ) ) {
            $args = array_merge( $args, array( 'proxy_port' => T4A_PROXY_PORT ) );
        }

        // Non-WSDL mode : first argument is null. SoapClient falls back to
        // straight HTTP/HTTPS transport driven by `location` + `uri` above.
        $this->client = new \SoapClient( null, $args );

        // Fresh message id per client instance ; every call to `dispatch()`
        // will refresh it so consecutive calls stay traceable.
        $this->message_id = EngineHelper::create_v4_uuid();
    }

    /**
     * Sends a SOAP request built from the given operation name + body children
     * XML fragment. Returns the parsed response as a stdClass tree matching
     * the historical SoapClient shape, or an error array. Every call site of
     * this method is a private wrapper that constructs the body XML with the
     * types the specific operation expects.
     *
     * @param string $operation TWINT operation, e.g. « StartOrder », « MonitorOrder ».
     * @param string $body_xml  Pre-serialized XML of the *inner* body children
     *                          (below the wrapper `<mer:{Operation}RequestElement>`).
     * @return array{status:bool, message?:string, response?:mixed}
     */
    private function dispatch( $operation, $body_xml )
    {
        // Refresh the message id for every operation — the header attribute is
        // supposed to uniquely identify each request.
        $this->message_id = EngineHelper::create_v4_uuid();
        $envelope = $this->build_envelope( $operation, $body_xml );

        // Log ONLY the operation name and message id. NEVER the envelope /
        // body / response bodies : they carry order UUIDs, pairing UUIDs and
        // could carry the certificate password if some future body helper
        // included it by mistake. See the plugin review notes for context.
        TwintCore::$logger::log_event( $operation . ' - Request - ' . $this->message_id, [], $this->data_provider );

        try {
            $raw = $this->client->__doRequest(
                $envelope,
                $this->get_location(),
                // The TWINT service does not require a SOAPAction header value ;
                // an empty string is the industry-standard « I have no SOAPAction »
                // marker for SOAP 1.1.
                '',
                SOAP_1_1
            );
        } catch ( \SoapFault $e ) {
            TwintCore::$logger::log_error( $operation . ' - Response - ' . $this->message_id, [ 'code' => $e->faultcode, 'message' => $e->faultstring ], $this->data_provider );
            return [ 'status' => false, 'message' => $this->get_error( $e ) ];
        }

        if ( ! is_string( $raw ) || $raw === '' ) {
            TwintCore::$logger::log_error( $operation . ' - Response - ' . $this->message_id, [ 'code' => 'empty_response', 'message' => 'Empty TWINT response' ], $this->data_provider );
            return [ 'status' => false, 'message' => __( 'Empty response from TWINT', 'pay4all-for-twint' ) ];
        }

        $parsed = $this->parse_response( $raw, $operation );

        if ( isset( $parsed['fault'] ) ) {
            TwintCore::$logger::log_error( $operation . ' - Response - ' . $this->message_id, [ 'code' => $parsed['fault']['code'], 'message' => $parsed['fault']['message'] ], $this->data_provider );
            return [ 'status' => false, 'message' => sprintf( "ERROR: The TWINT Server returned Error-Code '%s' with the following message: %s ()", $parsed['fault']['code'], $parsed['fault']['message'] ) ];
        }

        TwintCore::$logger::log_event( $operation . ' - Response - ' . $this->message_id, [ 'ok' => true ], $this->data_provider );

        return [ 'status' => true, 'response' => $parsed['body'] ];
    }

/**
     * Wraps the operation body in a full SOAP 1.1 envelope, with all three
     * TWINT namespaces declared at the root so operation-specific fragments
     * only need to use the `hdr:` / `mer:` / `base:` prefixes without ever
     * repeating the xmlns declarations.
     */
    private function build_envelope( $operation, $body_xml )
    {
        $msgid  = self::esc( $this->message_id );
        // Fallback to plugin-provided defaults if the constants are missing at
        // boot (defensive against downstream test harnesses that skip the
        // constants file).
        $swname = self::esc( defined( 'T4A_SOFTWARE_NAME' ) ? T4A_SOFTWARE_NAME : 'pay4all-for-twint' );
        $swver  = self::esc( defined( 'T4A_PLUGIN_VERSION' ) ? T4A_PLUGIN_VERSION : '1.0.0' );

        // Concatenated string literals — WordPress.org's Plugin Check
        // (PluginCheck.CodeAnalysis.Heredoc.NotAllowed) refuses heredoc
        // syntax across the codebase, so we build the envelope with plain
        // string concatenation.
        return '<?xml version="1.0" encoding="UTF-8"?>'
            . '<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/"'
            . ' xmlns:hdr="http://service.twint.ch/header/types/v8_4"'
            . ' xmlns:mer="http://service.twint.ch/merchant/types/v8_4"'
            . ' xmlns:base="http://service.twint.ch/base/types/v8_4">'
            . '<soap:Header>'
            . '<hdr:RequestHeaderElement>'
            . '<hdr:MessageId>' . $msgid . '</hdr:MessageId>'
            . '<hdr:ClientSoftwareName>' . $swname . '</hdr:ClientSoftwareName>'
            . '<hdr:ClientSoftwareVersion>' . $swver . '</hdr:ClientSoftwareVersion>'
            . '</hdr:RequestHeaderElement>'
            . '</soap:Header>'
            . '<soap:Body>'
            . '<mer:' . $operation . 'RequestElement>'
            . $body_xml
            . '</mer:' . $operation . 'RequestElement>'
            . '</soap:Body>'
            . '</soap:Envelope>';
    }

    /**
     * Parses a raw SOAP 1.1 response envelope. Returns either
     *   [ 'body' => stdClass ]   on success (structure mirrors SoapClient's
     *                            classic deserialization : attribute-bearing
     *                            leaves expose text via `->_` and attributes
     *                            as sibling properties)
     *   [ 'fault' => [code, message] ]   on server-side fault.
     *
     * Implementation note : uses DOMDocument, not SimpleXMLElement. SimpleXML
     * has multiple traps that make it unreliable for a multi-namespace SOAP
     * envelope :
     *   1. `!$element` evaluates to true for elements that only carry child
     *      elements (no text, no attributes), causing false parse errors.
     *   2. `->xpath('./*')` returns only the current namespace context
     *      children on some libxml builds, missing ns-prefixed siblings.
     *   3. `->children($ns)->Body->xpath(...)` chains behave differently on
     *      PHP 7.4 vs 8.x vs 8.3.
     * DOMDocument is boring and predictable — it always walks every element
     * child regardless of namespace, and DOM node checks are unambiguous.
     *
     * @param string $raw_xml  Raw XML SOAP response returned by __doRequest.
     * @param string $operation TWINT operation name (unused ; kept for future logging hooks).
     * @return array{body?:mixed, fault?:array{code:string,message:string}}
     */
    private function parse_response( $raw_xml, $operation )
    {
        $prev_state = libxml_use_internal_errors( true );

        $doc = new \DOMDocument();
        $loaded = $doc->loadXML( $raw_xml );
        libxml_clear_errors();
        libxml_use_internal_errors( $prev_state );

        if ( ! $loaded || ! $doc->documentElement ) {
            return [ 'fault' => [ 'code' => 'parse_error', 'message' => 'Invalid XML response' ] ];
        }

        // documentElement should be <soap:Envelope>. Find <soap:Body> among
        // its children — namespace-agnostic local-name lookup so we don't
        // depend on which prefix TWINT decides to use.
        $envelope = $doc->documentElement;
        $body     = null;
        foreach ( $envelope->childNodes as $child ) {
            if ( $child->nodeType === XML_ELEMENT_NODE && $child->localName === 'Body' ) {
                $body = $child;
                break;
            }
        }
        if ( ! $body ) {
            return [ 'fault' => [ 'code' => 'parse_error', 'message' => 'Missing SOAP body' ] ];
        }

        // SOAP fault detection — the standard <soap:Fault> child (any prefix).
        // Traverse local-name so the check works for SOAP 1.1 and 1.2.
        foreach ( $body->childNodes as $child ) {
            if ( $child->nodeType === XML_ELEMENT_NODE && $child->localName === 'Fault' ) {
                $code    = 'server';
                $message = 'Unknown fault';
                foreach ( $child->childNodes as $fc ) {
                    if ( $fc->nodeType !== XML_ELEMENT_NODE ) { continue; }
                    if ( $fc->localName === 'faultcode' )   { $code    = trim( $fc->textContent ); }
                    if ( $fc->localName === 'faultstring' ) { $message = trim( $fc->textContent ); }
                    if ( $fc->localName === 'Code' ) {
                        foreach ( $fc->childNodes as $v ) {
                            if ( $v->nodeType === XML_ELEMENT_NODE && $v->localName === 'Value' ) {
                                $code = trim( $v->textContent );
                            }
                        }
                    }
                    if ( $fc->localName === 'Reason' ) {
                        foreach ( $fc->childNodes as $t ) {
                            if ( $t->nodeType === XML_ELEMENT_NODE && $t->localName === 'Text' ) {
                                $message = trim( $t->textContent );
                            }
                        }
                    }
                }
                return [ 'fault' => [ 'code' => $code, 'message' => $message ] ];
            }
        }

        // Grab the single response wrapper (the first non-Fault element child
        // of Body). TWINT always ships exactly one wrapper element per body.
        $wrapper = null;
        foreach ( $body->childNodes as $child ) {
            if ( $child->nodeType === XML_ELEMENT_NODE ) {
                $wrapper = $child;
                break;
            }
        }
        if ( ! $wrapper ) {
            return [ 'body' => new \stdClass() ];
        }

        return [ 'body' => $this->dom_to_object( $wrapper ) ];
    }

    /**
     * Recursive DOMElement → stdClass converter that mimics PHP SoapClient's
     * default XML deserialization :
     *
     *   - A leaf with no attributes and no element children becomes a plain
     *     string (or empty string).
     *   - A leaf with attributes exposes `->_` (text) plus one property per
     *     attribute name.
     *   - A parent element becomes a stdClass whose properties are the
     *     children (grouped as arrays if a name appears multiple times).
     *   - Attributes on parent elements are exposed as sibling properties.
     *
     * Every existing consumer in this plugin reads paths like
     * `$response->Order->Status->Status->_` — this shape reproduces exactly
     * what SoapClient used to hand back.
     */
    private function dom_to_object( \DOMElement $node )
    {
        // Aggregate attributes, indexed by local name so an attribute like
        // `xsi:type="foo"` shows up as `->type` (same as classic SoapClient).
        $attrs = [];
        if ( $node->hasAttributes() ) {
            foreach ( $node->attributes as $attr ) {
                // Skip xmlns:... pseudo-attributes ; they're metadata, not data.
                $lname = $attr->localName;
                if ( $lname === null ) { continue; }
                $attrs[ $lname ] = $attr->value;
            }
        }

        // Gather element-node children, grouped by their local name so that
        // repeated siblings (e.g. an array of Coupons) collapse into an
        // indexed array on the parent.
        $children_by_name = [];
        $text_pieces      = '';
        foreach ( $node->childNodes as $child ) {
            if ( $child->nodeType === XML_ELEMENT_NODE ) {
                $name = $child->localName;
                if ( ! isset( $children_by_name[ $name ] ) ) {
                    $children_by_name[ $name ] = [];
                }
                $children_by_name[ $name ][] = $child;
            } elseif ( $child->nodeType === XML_TEXT_NODE || $child->nodeType === XML_CDATA_SECTION_NODE ) {
                $text_pieces .= $child->nodeValue;
            }
        }
        $text = trim( $text_pieces );

        // Leaf without element children :
        //   - no attributes → plain string (or empty string)
        //   - with attributes → stdClass with ->_ + attribute siblings
        if ( empty( $children_by_name ) ) {
            if ( empty( $attrs ) ) {
                return $text;
            }
            $leaf = new \stdClass();
            $leaf->_ = $text;
            foreach ( $attrs as $k => $v ) {
                $leaf->{$k} = $v;
            }
            return $leaf;
        }

        // Parent element — one property per child name (arrays if multiple).
        $obj = new \stdClass();
        foreach ( $attrs as $k => $v ) {
            $obj->{$k} = $v;
        }
        // Preserve text content of parents that carry both text and children
        // (rare, but SoapClient exposes `->_` in that case too).
        if ( $text !== '' ) {
            $obj->_ = $text;
        }
        foreach ( $children_by_name as $name => $child_nodes ) {
            if ( count( $child_nodes ) === 1 ) {
                $obj->{$name} = $this->dom_to_object( $child_nodes[0] );
            } else {
                $obj->{$name} = array_map( [ $this, 'dom_to_object' ], $child_nodes );
            }
        }
        return $obj;
    }

    /**
     * XML-escape a text value for safe embedding in an element's text body.
     * We stay conservative — always encode `&`, `<`, `>`, `'`, `"` even
     * though only `&` and `<` are strictly required inside text nodes ; this
     * makes the value safe to reuse inside attributes if we ever refactor.
     */
    private static function esc( $value )
    {
        return htmlspecialchars( (string) $value, ENT_QUOTES | ENT_XML1, 'UTF-8' );
    }

    /**
     * Serializes the standard « MerchantInformation » block used at the head
     * of every operation body. The nested elements live in the base namespace
     * per the XSD (elementFormDefault="qualified").
     */
    private function merchant_information_xml()
    {
        $uuid = self::esc( $this->data_provider->get_merchant_uuid() );
        $cash = self::esc( T4A_CASHREGISTER_ID );
        return "<mer:MerchantInformation><base:MerchantUuid>{$uuid}</base:MerchantUuid><base:CashRegisterId>{$cash}</base:CashRegisterId></mer:MerchantInformation>";
    }

    /**
     * Returns the SOAP request location.
     *
     * @return string
     */
    private function get_location()
    {

        switch ( T4A_ENVIRONMENT ) {

            case 'int':
                return static::INT_LOCATION;
                break;
            case 'pat':
                return static::PAT_LOCATION;
                break;
            default:
                return static::PRODUCTION_LOCATION;
                break;
        }
    }

    /* --------------------------------------------------------------------- *
     *  Public operations
     * --------------------------------------------------------------------- */

    /**
     * Check the Webservice system status.
     *
     * @return array
     */
    public function check_system_status()
    {
        return $this->dispatch( 'CheckSystemStatus', $this->merchant_information_xml() );
    }

    /**
     * Enrolls the cash register to TWINT (EPOS).
     *
     * @return array
     */
    public function enroll_cash_register()
    {
        return $this->dispatch(
            'EnrollCashRegister',
            $this->merchant_information_xml() . '<mer:CashRegisterType>EPOS</mer:CashRegisterType>'
        );
    }

    /**
     * TWINT check-in request.
     *
     * @param bool        $request_alias
     * @param null|string $customer_relation_uuid
     * @return array
     */
    public function request_checkin( $request_alias = false, $customer_relation_uuid = null )
    {
        $body  = $this->merchant_information_xml();
        // The XSD choice group MUST come before QRCodeRendering per the WSDL
        // sequence — mirror the historical param order.
        if ( $customer_relation_uuid ) {
            $body .= '<mer:CustomerRelationUuid>' . self::esc( $customer_relation_uuid ) . '</mer:CustomerRelationUuid>';
        } else {
            $body .= '<mer:UnidentifiedCustomer>true</mer:UnidentifiedCustomer>';
        }
        if ( $request_alias ) {
            $body .= '<mer:RequestCustomerRelationAlias>RECURRING_PAYMENT</mer:RequestCustomerRelationAlias>';
        }
        $body .= '<mer:QRCodeRendering>true</mer:QRCodeRendering>';

        return $this->dispatch( 'RequestCheckIn', $body );
    }

    /**
     * Returns the TWINT check-in status.
     *
     * @param string      $pairing_uuid
     * @param null|string $customer_relation_uuid
     * @return array
     */
    public function monitor_checkin( $pairing_uuid, $customer_relation_uuid = null )
    {
        $body = $this->merchant_information_xml();
        if ( $customer_relation_uuid ) {
            $body .= '<mer:CustomerRelationUuid>' . self::esc( $customer_relation_uuid ) . '</mer:CustomerRelationUuid>';
        } else {
            $body .= '<mer:PairingUuid>' . self::esc( $pairing_uuid ) . '</mer:PairingUuid>';
        }
        $body .= '<mer:WaitForResponse>true</mer:WaitForResponse>';

        return $this->dispatch( 'MonitorCheckIn', $body );
    }

    /**
     * Cancel the check-in to TWINT.
     *
     * @param string $pairing_uuid
     * @return array
     */
    public function cancel_checkin( $pairing_uuid )
    {
        $body  = $this->merchant_information_xml();
        $body .= '<mer:Reason>PAYMENT_ABORT</mer:Reason>';
        $body .= '<mer:PairingUuid>' . self::esc( $pairing_uuid ) . '</mer:PairingUuid>';

        return $this->dispatch( 'CancelCheckIn', $body );
    }

    /**
     * Start the TWINT order. The XSD `choice` group only allows ONE of
     * `PairingUuid` / `CustomerRelationUuid` / `UnidentifiedCustomer` /
     * `OfflineAuthorization`, and `PaymentLayerRendering` is a peer of that
     * choice — so the ordering below matters.
     *
     * @param string      $pairing_uuid
     * @param string      $amount
     * @param string      $currency
     * @param string      $merchant_transaction_reference
     * @param bool        $deferred
     * @param null|string $customer_relation_uuid
     * @return array
     */
    public function start_order( $pairing_uuid, $amount, $currency, $merchant_transaction_reference, $deferred = false, $customer_relation_uuid = null )
    {
        $body  = $this->merchant_information_xml();

        // The historical implementation hardcoded PAYMENT_DEFERRED and
        // confirmationNeeded=true regardless of the caller's $deferred flag —
        // preserved verbatim here to avoid a behavior change on a rewrite.
        // Per TWINT XSD OrderRequestType : `type` and `confirmationNeeded` are
        // XML ATTRIBUTES on <Order>, NOT child elements. Getting this wrong
        // makes StartOrder look successful HTTP-wise but the response comes
        // back without a TwintURL because the whole order body is invalid.
        // Sequence order inside <Order> is fixed by the XSD :
        //   PostingType, RequestedAmount, MerchantTransactionReference.
        $ref    = self::esc( $merchant_transaction_reference );
        $amt    = self::esc( $amount );
        $curr   = self::esc( $currency );

        $body .= '<mer:Order type="PAYMENT_DEFERRED" confirmationNeeded="true">'
              .    '<mer:PostingType>GOODS</mer:PostingType>'
              .    '<mer:RequestedAmount>'
              .       '<base:Amount>' . $amt . '</base:Amount>'
              .       '<base:Currency>' . $curr . '</base:Currency>'
              .    '</mer:RequestedAmount>'
              .    '<mer:MerchantTransactionReference>' . $ref . '</mer:MerchantTransactionReference>'
              . '</mer:Order>';

        // StartOrderRequestType sequence : after <Order> comes the customer-
        // identifier choice, THEN the rendering choice (QRCodeRendering |
        // PaymentLayerRendering). Never swap these two.
        if ( T4A_TWINT_PM_PAGE ) {
            $body .= '<mer:UnidentifiedCustomer>true</mer:UnidentifiedCustomer>';
            $body .= '<mer:PaymentLayerRendering>PAYMENT_PAGE</mer:PaymentLayerRendering>';
        } elseif ( $customer_relation_uuid ) {
            $body .= '<mer:CustomerRelationUuid>' . self::esc( $customer_relation_uuid ) . '</mer:CustomerRelationUuid>';
        } else {
            $body .= '<mer:PairingUuid>' . self::esc( $pairing_uuid ) . '</mer:PairingUuid>';
        }

        return $this->dispatch( 'StartOrder', $body );
    }

    /**
     * Processes a refund.
     *
     * @param string $amount
     * @param string $currency
     * @param string $merchant_transaction_reference
     * @param string $order_uuid
     * @return array
     */
    public function reversal( $amount, $currency, $merchant_transaction_reference, $order_uuid )
    {
        // Same schema constraints as start_order() : `type` and
        // `confirmationNeeded` are XML attributes on <Order>. Sequence order
        // inside <Order> for REVERSAL : PostingType, RequestedAmount,
        // MerchantTransactionReference, Link.
        $body  = $this->merchant_information_xml();
        $ref   = self::esc( $merchant_transaction_reference );
        $amt   = self::esc( $amount );
        $curr  = self::esc( $currency );
        $ouid  = self::esc( $order_uuid );

        $body .= '<mer:Order type="REVERSAL" confirmationNeeded="false">'
              .    '<mer:PostingType>GOODS</mer:PostingType>'
              .    '<mer:RequestedAmount>'
              .       '<base:Amount>' . $amt . '</base:Amount>'
              .       '<base:Currency>' . $curr . '</base:Currency>'
              .    '</mer:RequestedAmount>'
              .    '<mer:MerchantTransactionReference>' . $ref . '</mer:MerchantTransactionReference>'
              .    '<mer:Link><mer:OrderUuid>' . $ouid . '</mer:OrderUuid></mer:Link>'
              . '</mer:Order>';
        $body .= '<mer:UnidentifiedCustomer>true</mer:UnidentifiedCustomer>';

        return $this->dispatch( 'StartOrder', $body );
    }

    /**
     * Check the TWINT order status.
     *
     * @param string $order_uuid
     * @return array
     */
    public function monitor_order( $order_uuid )
    {
        $body  = $this->merchant_information_xml();
        $body .= '<mer:OrderUuid>' . self::esc( $order_uuid ) . '</mer:OrderUuid>';
        $body .= '<mer:WaitForResponse>true</mer:WaitForResponse>';

        return $this->dispatch( 'MonitorOrder', $body );
    }

    /**
     * Confirm the order after payment is completed in TWINT.
     *
     * @param string $order_uuid
     * @param double $amount
     * @param string $currency
     * @return array
     */
    public function confirm_order( $order_uuid, $amount, $currency )
    {
        $body  = $this->merchant_information_xml();
        $body .= '<mer:OrderUuid>' . self::esc( $order_uuid ) . '</mer:OrderUuid>';
        $body .= '<mer:RequestedAmount>'
              .   '<base:Amount>' . self::esc( $amount ) . '</base:Amount>'
              .   '<base:Currency>' . self::esc( $currency ) . '</base:Currency>'
              . '</mer:RequestedAmount>';

        return $this->dispatch( 'ConfirmOrder', $body );
    }

    /**
     * Cancel the TWINT order.
     *
     * @param string $order_uuid
     * @return array
     */
    public function cancel_order( $order_uuid )
    {
        $body  = $this->merchant_information_xml();
        $body .= '<mer:OrderUuid>' . self::esc( $order_uuid ) . '</mer:OrderUuid>';

        return $this->dispatch( 'CancelOrder', $body );
    }

    /**
     * Find a particular order.
     *
     * If $args is null, the transactions of the current day are returned.
     * If $args is a string the search will be by transaction reference.
     * To search by a date range an array with two elements [startDate, endDate].
     * DateTime has to be formated with format('Y-m-d\Th:i:s') or format( DateTime::ATOM )
     *
     * @param null|array|string $args
     * @return array
     */
    public function find_order( $args = null )
    {
        // FindOrderRequestType does NOT wrap the merchant fields inside a
        // <MerchantInformation> element — it exposes <MerchantUuid> and
        // <CashRegisterId> as direct siblings at the request root.
        $body  = '<mer:MerchantUuid>' . self::esc( $this->data_provider->get_merchant_uuid() ) . '</mer:MerchantUuid>';
        $body .= '<mer:CashRegisterId>' . self::esc( T4A_CASHREGISTER_ID ) . '</mer:CashRegisterId>';

        if ( is_string( $args ) ) {
            $body .= '<mer:MerchantTransactionReference>' . self::esc( $args ) . '</mer:MerchantTransactionReference>';
        } elseif ( is_array( $args ) && count( $args ) === 2 ) {
            $body .= '<mer:SearchStartDate>' . self::esc( $args[0] ) . '</mer:SearchStartDate>';
            $body .= '<mer:SearchEndDate>'   . self::esc( $args[1] ) . '</mer:SearchEndDate>';
        }

        return $this->dispatch( 'FindOrder', $body );
    }

    public function get_order( $order_uuid )
    {
        if ( empty( $order_uuid ) ) {
            return [ 'status' => false, 'message' => 'No order UUID found.' ];
        }

        // GetOrderRequestType : <MerchantUuid> + <OrderUuid> as request-root
        // siblings (no <MerchantInformation> wrapper).
        $body  = '<mer:MerchantUuid>' . self::esc( $this->data_provider->get_merchant_uuid() ) . '</mer:MerchantUuid>';
        $body .= '<mer:OrderUuid>' . self::esc( $order_uuid ) . '</mer:OrderUuid>';

        return $this->dispatch( 'GetOrder', $body );
    }

    /**
     * Checks the expiry date of the certificate and if it can already be renewed.
     *
     * @return array
     */
    public function get_certificate_validity()
    {
        // GetCertificateValidityRequestType : bare <MerchantUuid>.
        $body = '<mer:MerchantUuid>' . self::esc( $this->data_provider->get_merchant_uuid() ) . '</mer:MerchantUuid>';

        return $this->dispatch( 'GetCertificateValidity', $body );
    }

    /**
     * Renew the certificate.
     *
     * @return array
     */
    public function renew_certificate()
    {
        // RenewCertificateRequestType : <MerchantUuid> + <CertificatePassword>.
        // Per XSD `CertificatePassword` is xs:base64Binary — the raw password
        // MUST be base64-encoded on the wire (SoapClient did this transparently
        // in WSDL mode, we do it explicitly here).
        $body  = '<mer:MerchantUuid>' . self::esc( $this->data_provider->get_merchant_uuid() ) . '</mer:MerchantUuid>';
        $body .= '<mer:CertificatePassword>' . self::esc( base64_encode( $this->password ) ) . '</mer:CertificatePassword>';

        return $this->dispatch( 'RenewCertificate', $body );
    }
}
