<?php

declare (strict_types = 1);
namespace Brightfern\WooCommerce;

use Brightfern\Archive\NullArchive;
use Brightfern\Invoice\Fa3Serializer;
use Brightfern\Invoice\FacturXBuilder;
use Brightfern\Invoice\Model\Invoice;
use Brightfern\Invoice\OrderInvoiceMapper;
use Brightfern\Invoice\UblBisSerializer;
use Brightfern\Settings;
use Brightfern\Support\Http\WpHttpClient;
use Brightfern\Support\Logger;
use Brightfern\Validation\GateResult;
use Brightfern\Validation\InvoiceValidator;
use Brightfern\Validation\PartyValidator;
use Brightfern\Validation\PreflightGate;
use Brightfern\Validation\RecipientResolver;
use WC_Abstract_Order;
use WC_Order;
use WC_Order_Refund;
/**
 * The outbound SEND path: on the configured order status, generate the invoice,
 * run the pre-flight gate, then either transmit (premium) or just store the file
 * for download (free). Status, provider message id and validation issues are
 * persisted on the order for the metabox.
 *
 * The free/premium seam lives here and only here for outbound:
 *   - generate + validate + download → FREE.
 *   - transmit                       → PREMIUM.
 *
 * The transmit branch sits in a bare `if ( brightfern_fs()->is__premium_only() )`
 * block. That exact shape matters: Freemius' build processor pattern-matches it
 * to delete the block when generating the free (WordPress.org) build, so that
 * build genuinely has no transmission code rather than code behind a flag.
 * Adding guards to the condition (function_exists(), null checks) stops the
 * processor recognising it — verified the hard way — and brightfern_is_pro() is
 * likewise invisible to it. The SDK ships in both builds, so brightfern_fs() is
 * always an object here.
 */
final class OutboundFlow {
    public const META_STATUS = '_brightfern_status';

    public const META_MESSAGE = '_brightfern_message_id';

    // Which AccessPointProvider issued META_MESSAGE (AccessPointProvider::id()).
    // Recorded so the hourly resolve pass never hands one provider's message id
    // to a DIFFERENT provider after a merchant switches provider in settings —
    // see OutboundStatusResolver. Only set for orders transmitted after this
    // field was introduced; empty for older orders (resolved with today's
    // pre-existing, non-drift-safe behavior for backward compatibility).
    public const META_PROVIDER = '_brightfern_provider';

    public const META_XML = '_brightfern_xml';

    public const META_PDF = '_brightfern_pdf';

    // Factur-X only, base64 (postmeta is text-oriented).
    public const META_ISSUES = '_brightfern_issues';

    /**
     * MySQL TEXT's own byte ceiling (2^16 - 1) — WooCommerce's High-Performance
     * Order Storage (HPOS, the default since WooCommerce 8.2) declares its own
     * order-meta table's meta_value column as `text`, NOT the `longtext` (4 GB)
     * that legacy wp_postmeta uses. A write past this silently fails: $wpdb's
     * own strip_invalid_text() truncation check makes insert()/update() return
     * false, and WC_Data::save_meta_data() never inspects that return value, so
     * $order->save() reports success while the meta row is simply never
     * written. persist() used to store the generated XML/PDF unconditionally —
     * a large order (roughly 80+ Peppol/XRechnung lines, or 45+ Factur-X lines,
     * with realistic product names) would show "generated" with no way to
     * actually download the document, and on Pro the invoice would already
     * have been transmitted with no local copy of what was sent.
     */
    private const HPOS_META_BYTE_LIMIT = 65535;

    public const META_NUMBER = '_brightfern_invoice_number';

    public const META_UPDATED = '_brightfern_updated';

    public const STATUS_GENERATED = 'generated';

    // Free: file ready, not sent.
    public const STATUS_BLOCKED = 'blocked';

    // Pre-flight gate failed.
    public const STATUS_SUBMITTED = 'submitted';

    public const STATUS_DELIVERED = 'delivered';

    public const STATUS_REJECTED = 'rejected';

    public const STATUS_FAILED = 'failed';

    /**
     * Generous margin above the SMP/VIES/provider HTTP calls' own ~10s
     * timeouts, each of which can happen inside the locked window — long
     * enough that a genuinely-in-progress run is never mistaken for stale, but
     * short enough that a crashed/killed PHP process (which never reaches the
     * finally block that releases the lock) doesn't wedge an order for good.
     */
    private const LOCK_STALE_SECONDS = 120;

    /** Hourly pass that finishes documents the provider hadn't confirmed yet. */
    public const CRON_HOOK = 'brightfern_resolve_outbound';

    public function register() : void {
        add_action(
            'woocommerce_order_status_changed',
            array($this, 'onStatusChanged'),
            10,
            4
        );
        add_action(
            'woocommerce_order_refunded',
            array($this, 'onRefunded'),
            10,
            2
        );
        add_action( self::CRON_HOOK, array($this, 'resolvePending') );
        $this->syncResolveSchedule();
    }

    /**
     * Ask the provider what became of everything still STATUS_SUBMITTED.
     *
     * Transmission stores "submitted" plus a provisional handle and returns —
     * confirmation can lag by minutes, and for KSeF the number it eventually
     * assigns is the invoice's legal receipt. Without this pass that number
     * would never be recorded. See OutboundStatusResolver.
     */
    public function resolvePending() : void {
        $orders = $this->pendingOrders();
        if ( array() === $orders ) {
            return;
        }
    }

    /**
     * Orders and refunds still waiting on a provider verdict.
     *
     * Deliberately capped: this runs on cron, and a site with a large backlog
     * should work through it over successive runs rather than making one
     * request do hundreds of sequential provider round-trips.
     *
     * @return WC_Abstract_Order[]
     */
    private function pendingOrders() : array {
        if ( !function_exists( 'wc_get_orders' ) ) {
            return array();
        }
        $orders = wc_get_orders( array(
            'limit'      => 20,
            'type'       => array('shop_order', 'shop_order_refund'),
            'orderby'    => 'date',
            'order'      => 'ASC',
            'meta_query' => array(array(
                'key'     => self::META_STATUS,
                'value'   => self::STATUS_SUBMITTED,
                'compare' => '=',
            )),
        ) );
        return ( is_array( $orders ) ? $orders : array() );
    }

    private function syncResolveSchedule() : void {
        $scheduled = wp_next_scheduled( self::CRON_HOOK );
        if ( brightfern_is_pro() && !$scheduled ) {
            wp_schedule_event( time() + HOUR_IN_SECONDS, 'hourly', self::CRON_HOOK );
        } elseif ( !brightfern_is_pro() && $scheduled ) {
            wp_unschedule_event( $scheduled, self::CRON_HOOK );
        }
    }

    /** @param mixed $order */
    public function onStatusChanged(
        int $orderId,
        string $from,
        string $to,
        $order
    ) : void {
        if ( $to !== Settings::triggerStatus() ) {
            return;
        }
        if ( !$order instanceof WC_Order ) {
            $order = wc_get_order( $orderId );
        }
        if ( !$order instanceof WC_Order ) {
            return;
        }
        // Don't regenerate if we've already produced a document for this order.
        if ( '' !== (string) $order->get_meta( self::META_STATUS ) ) {
            return;
        }
        $this->process( $order );
    }

    /**
     * Generate → gate → (premium) transmit / (free) store. Returns the resulting
     * status string.
     *
     * Callable directly for the initial run; {@see resend()} is the guarded
     * entry point for a manual re-run, because calling this a second time for
     * an order that already reached STATUS_SUBMITTED/STATUS_DELIVERED would
     * silently regenerate the document — possibly with different content, if
     * seller settings changed since the first send — and retransmit it to the
     * Access Point under the SAME invoice number. Nothing downstream
     * deduplicates that: {@see \Brightfern\Transmission\Send\OutboundTransmitter}
     * calls the provider unconditionally.
     *
     * Wrapped in an atomic claim (see {@see acquireLock()}) because the body
     * below is genuinely slow (mapping, serialization, an SMP lookup and a
     * VIES lookup, each with a ~10s timeout, plus — on Pro — the provider
     * submission itself) and, before this lock existed, NOTHING claimed the
     * order during that whole window: onStatusChanged()'s own "don't regenerate
     * if a status is already set" check only reads META_STATUS, which
     * persist() only writes at the very end. Two near-simultaneous triggers
     * for the same order (a duplicate gateway webhook, a double-clicked
     * "Generate & send now" — WordPress nonces are valid for hours, not
     * single-use) could both pass that check and both run this method
     * concurrently, each unaware of the other, resulting in the same invoice
     * number being transmitted to the Access Point twice.
     */
    public function process( WC_Order $order ) : string {
        $lockKey = 'order:' . $order->get_id();
        if ( !$this->acquireLock( $lockKey ) ) {
            Logger::warning( 'Order #' . $order->get_id() . ' is already being processed by another request; not starting a second one.' );
            return self::STATUS_BLOCKED;
        }
        try {
            return $this->doProcess( $order );
        } finally {
            $this->releaseLock( $lockKey );
        }
    }

    private function doProcess( WC_Order $order ) : string {
        if ( !Settings::sellerIdentityComplete() ) {
            Logger::error( 'Cannot process order #' . $order->get_id() . ': seller identity is incomplete (VAT / Peppol endpoint / address).' );
            return $this->persist(
                $order,
                self::STATUS_BLOCKED,
                '',
                array('Seller identity is incomplete in Brightfern settings.'),
                ''
            );
        }
        try {
            $invoice = ( new OrderInvoiceMapper() )->map( ( new WooOrderAdapter() )->toOrderData( $order ), Settings::sellerProfile() );
            [$xml, $pdf] = $this->buildDocument( $invoice );
        } catch ( \Throwable $e ) {
            Logger::error( 'Invoice generation failed for order #' . $order->get_id() . ': ' . $e->getMessage() );
            return $this->persist(
                $order,
                self::STATUS_FAILED,
                '',
                array('Generation error: ' . $e->getMessage()),
                ''
            );
        }
        $oversized = $this->oversizedForStorage( $xml, $pdf );
        if ( null !== $oversized ) {
            Logger::error( 'Order #' . $order->get_id() . ': ' . $oversized );
            return $this->persist(
                $order,
                self::STATUS_FAILED,
                $invoice->number,
                array($oversized),
                ''
            );
        }
        $gate = $this->buildGate()->run( $invoice, $xml, $this->gateOptions() );
        $messages = $this->messages( $gate );
        // Archive the generated document (no-op in v1).
        ( new NullArchive() )->store( 'outbound', $xml, array(
            'order'  => $order->get_id(),
            'number' => $invoice->number,
        ) );
        if ( !$gate->canSend ) {
            Logger::warning( 'Order #' . $order->get_id() . ' blocked by pre-flight gate: ' . implode( ' | ', $messages ) );
            return $this->persist(
                $order,
                self::STATUS_BLOCKED,
                $invoice->number,
                $messages,
                $xml,
                '',
                $pdf
            );
        }
        // FREE (and the national formats on any plan): the validated document is
        // stored and downloadable.
        $messages[] = $this->readyMessage( false );
        return $this->persist(
            $order,
            self::STATUS_GENERATED,
            $invoice->number,
            $messages,
            $xml,
            '',
            $pdf
        );
    }

    /**
     * Manual resend from the order metabox (premium).
     *
     * Refuses to re-run once the provider has already accepted or confirmed
     * delivery of THIS invoice number ({@see STATUS_SUBMITTED} /
     * {@see STATUS_DELIVERED}): those are the two outcomes a real Access Point
     * or the buyer's own system has already acted on, and Peppol/KSeF have no
     * notion of "replace the invoice with this number" — reissuing different
     * content under the same number is what a credit note + new invoice is
     * for, a decision the merchant has to make deliberately, not one this
     * button should make for them by silently overwriting.
     *
     * BLOCKED, FAILED and REJECTED remain freely retryable: those mean the
     * provider never actually received a document under this number, which is
     * the "safe to call again" case this method's callers actually rely on
     * (e.g. retrying after a transient provider outage).
     */
    public function resend( int $orderId ) : string {
        $order = wc_get_order( $orderId );
        if ( !$order instanceof WC_Order ) {
            return self::STATUS_FAILED;
        }
        $currentStatus = (string) $order->get_meta( self::META_STATUS );
        if ( in_array( $currentStatus, array(self::STATUS_SUBMITTED, self::STATUS_DELIVERED), true ) ) {
            Logger::warning( 'Resend refused for order #' . $orderId . ': already ' . $currentStatus . ' — reissue as a new document instead of resending.' );
            $issues = json_decode( (string) $order->get_meta( self::META_ISSUES ), true );
            $issues = ( is_array( $issues ) ? $issues : array() );
            $issues[] = 'INFO [BRIGHTFERN-ALREADY-SENT] This invoice was already ' . $currentStatus . ' and was not resent. To correct it, issue a credit note (or a new invoice) instead of resending — reusing the same invoice number for different content is not valid on Peppol/KSeF.';
            $order->update_meta_data( self::META_ISSUES, wp_json_encode( array_values( $issues ) ) );
            $order->save();
            return $currentStatus;
        }
        // Clear prior status so process() runs fully.
        $order->delete_meta_data( self::META_STATUS );
        $order->save();
        return $this->process( $order );
    }

    /**
     * Fires whenever a refund is created against an order. Only issues a
     * credit note when the order actually has an e-invoice on record — a
     * refund against an order that never got invoiced has nothing to credit.
     */
    public function onRefunded( int $orderId, int $refundId ) : void {
        $order = wc_get_order( $orderId );
        if ( !$order instanceof WC_Order ) {
            return;
        }
        $invoiceNumber = (string) $order->get_meta( self::META_NUMBER );
        $invoiceStatus = (string) $order->get_meta( self::META_STATUS );
        $wasIssued = in_array( $invoiceStatus, array(self::STATUS_GENERATED, self::STATUS_SUBMITTED, self::STATUS_DELIVERED), true );
        if ( '' === $invoiceNumber || !$wasIssued ) {
            return;
        }
        $refund = wc_get_order( $refundId );
        if ( !$refund instanceof WC_Order_Refund ) {
            return;
        }
        // Regression guard, mirroring resend()'s SUBMITTED/DELIVERED refusal: the
        // invoice path is careful never to re-transmit a document the provider
        // already accepted, but the credit-note path had no equivalent guard at
        // all — nothing here checked the REFUND's own status before (re)issuing.
        // woocommerce_order_refunded firing more than once for the same refund
        // (a re-fired hook, a retry wrapper, third-party code) would otherwise
        // transmit the identical, deterministically-numbered credit note again.
        $refundStatus = (string) $refund->get_meta( self::META_STATUS );
        if ( in_array( $refundStatus, array(self::STATUS_SUBMITTED, self::STATUS_DELIVERED), true ) ) {
            Logger::warning( 'Credit note for refund #' . $refundId . ' already ' . $refundStatus . ' — onRefunded() fired again but was not reprocessed.' );
            return;
        }
        $this->processCreditNote( $refund, $order, $invoiceNumber );
    }

    /**
     * Generate → gate → (premium) transmit / (free) store for a credit note, the
     * refund-side mirror of {@see process()}. Status/XML are persisted on the
     * refund itself (its own order-type post/HPOS record), keyed identically
     * to invoices so the same metabox rendering can read either.
     *
     * Atomically locked for the same reason {@see process()} is — see that
     * method's docblock.
     */
    public function processCreditNote( WC_Order_Refund $refund, WC_Order $order, string $precedingInvoiceNumber ) : string {
        $lockKey = 'refund:' . $refund->get_id();
        if ( !$this->acquireLock( $lockKey ) ) {
            Logger::warning( 'Credit note for refund #' . $refund->get_id() . ' is already being processed by another request; not starting a second one.' );
            return self::STATUS_BLOCKED;
        }
        try {
            return $this->doProcessCreditNote( $refund, $order, $precedingInvoiceNumber );
        } finally {
            $this->releaseLock( $lockKey );
        }
    }

    private function doProcessCreditNote( WC_Order_Refund $refund, WC_Order $order, string $precedingInvoiceNumber ) : string {
        if ( !Settings::sellerIdentityComplete() ) {
            Logger::error( 'Cannot process credit note for refund #' . $refund->get_id() . ': seller identity is incomplete (VAT / Peppol endpoint / address).' );
            return $this->persist(
                $refund,
                self::STATUS_BLOCKED,
                '',
                array('Seller identity is incomplete in Brightfern settings.'),
                ''
            );
        }
        try {
            $invoice = ( new OrderInvoiceMapper() )->map( ( new WooOrderAdapter() )->toCreditNoteData( $refund, $order, $precedingInvoiceNumber ), Settings::sellerProfile() );
            [$xml, $pdf] = $this->buildDocument( $invoice );
        } catch ( \Throwable $e ) {
            Logger::error( 'Credit note generation failed for refund #' . $refund->get_id() . ': ' . $e->getMessage() );
            return $this->persist(
                $refund,
                self::STATUS_FAILED,
                '',
                array('Generation error: ' . $e->getMessage()),
                ''
            );
        }
        $oversized = $this->oversizedForStorage( $xml, $pdf );
        if ( null !== $oversized ) {
            Logger::error( 'Credit note for refund #' . $refund->get_id() . ': ' . $oversized );
            return $this->persist(
                $refund,
                self::STATUS_FAILED,
                $invoice->number,
                array($oversized),
                ''
            );
        }
        $gate = $this->buildGate()->run( $invoice, $xml, $this->gateOptions() );
        $messages = $this->messages( $gate );
        ( new NullArchive() )->store( 'outbound', $xml, array(
            'order'  => $order->get_id(),
            'refund' => $refund->get_id(),
            'number' => $invoice->number,
        ) );
        if ( !$gate->canSend ) {
            Logger::warning( 'Credit note for refund #' . $refund->get_id() . ' blocked by pre-flight gate: ' . implode( ' | ', $messages ) );
            return $this->persist(
                $refund,
                self::STATUS_BLOCKED,
                $invoice->number,
                $messages,
                $xml,
                '',
                $pdf
            );
        }
        // FREE (and the national formats on any plan): the validated document is
        // stored and downloadable.
        $messages[] = $this->readyMessage( true );
        return $this->persist(
            $refund,
            self::STATUS_GENERATED,
            $invoice->number,
            $messages,
            $xml,
            '',
            $pdf
        );
    }

    /**
     * What to tell the merchant once a document is generated but not transmitted.
     *
     * For the Peppol formats that is an upsell — transmission is the paid
     * capability. For the national formats it is simply the end of the workflow:
     * neither has a transmit step in this version, so presenting Pro as the way
     * to send them would be misleading.
     */
    private function readyMessage( bool $isCreditNote ) : string {
        $document = ( $isCreditNote ? 'credit note' : 'invoice' );
        return match (Settings::format()) {
            'facturx' => sprintf( 'Factur-X %s PDF generated and ready to download.', $document ),
            'ksef' => sprintf( 'KSeF FA(3) %s generated. Download it and submit it in the Ministry of Finance taxpayer application to obtain its KSeF number.', $document ),
            default => sprintf( 'Transmitting over Peppol requires Brightfern Pro. The validated %s is available for download.', $document ),
        };
    }

    /**
     * Generate the outbound document for the current Format setting.
     *
     * @return array{0:string,1:?string} [xml, pdf] — pdf is only non-null for
     *   Factur-X (its CII xml is embedded inside that PDF/A-3, never
     *   downloaded as bare XML the way Peppol/XRechnung's is).
     */
    private function buildDocument( Invoice $invoice ) : array {
        $format = Settings::format();
        if ( 'facturx' === $format ) {
            $built = ( new FacturXBuilder() )->build( $invoice );
            return array($built['xml'], $built['pdf']);
        }
        if ( 'ksef' === $format ) {
            // FA(3) is a national model, not another EN 16931 syntax binding —
            // see Fa3Serializer. Still plain XML, so no PDF slot is used.
            return array(( new Fa3Serializer() )->serialize( $invoice ), null);
        }
        $serializer = ( 'xrechnung' === $format ? UblBisSerializer::forXRechnung() : new UblBisSerializer() );
        return array($serializer->serialize( $invoice ), null);
    }

    /**
     * Checked BEFORE the pre-flight gate and BEFORE any transmit attempt,
     * because storing the document is the one guarantee "generated" and
     * "submitted" both make to the merchant, and neither $wpdb nor
     * WC_Data::save_meta_data() will ever tell us afterwards that it silently
     * didn't happen — see {@see HPOS_META_BYTE_LIMIT}. Only checked under HPOS:
     * legacy wp_postmeta's `longtext` column (4 GB) makes this a non-issue
     * there, and no realistic invoice/credit note approaches it. Detected via
     * WooCommerce's own public compatibility API (Utilities\OrderUtil, not the
     * Internal\ namespace), guarded so a WooCommerce version that predates HPOS
     * — or a test/harness context with neither — skips the check entirely
     * rather than erroring.
     *
     * @return string|null A merchant-facing error message, or null if the
     *   document fits safely.
     */
    private function oversizedForStorage( string $xml, ?string $pdf ) : ?string {
        $hposClass = '\\Automattic\\WooCommerce\\Utilities\\OrderUtil';
        if ( !class_exists( $hposClass ) || !$hposClass::custom_orders_table_usage_is_enabled() ) {
            return null;
        }
        $xmlBytes = strlen( $xml );
        // The stored form is base64, not the raw PDF — that's what actually
        // reaches the meta_value column and is ~4/3 larger than the source.
        $pdfBytes = ( null !== $pdf ? strlen( base64_encode( $pdf ) ) : 0 );
        if ( $xmlBytes < self::HPOS_META_BYTE_LIMIT && $pdfBytes < self::HPOS_META_BYTE_LIMIT ) {
            return null;
        }
        return sprintf( 'This document is too large to store (%s bytes, limit %s under WooCommerce High-Performance Order Storage) — likely too many line items. It was NOT generated or sent; splitting the order into smaller ones is the current workaround.', number_format( max( $xmlBytes, $pdfBytes ) ), number_format( self::HPOS_META_BYTE_LIMIT ) );
    }

    /**
     * A best-effort mutex over the wp_options table, portable across every
     * WordPress install regardless of caching setup — unlike wp_cache_add(),
     * which is only atomic with an external object cache (Redis/Memcached) and
     * silently provides NO cross-request protection on the far more common
     * default (database-only) setup this plugin's WooCommerce SME audience
     * mostly runs on. add_option() is genuinely atomic everywhere: WordPress
     * core issues it as an INSERT that fails outright if the option name
     * already exists, so two near-simultaneous callers can never both succeed.
     *
     * Not a guarantee against every failure mode (a crashed PHP process skips
     * the `finally` release entirely) — {@see LOCK_STALE_SECONDS} bounds how
     * long such a lock can wedge an order before a later attempt reclaims it.
     */
    private function acquireLock( string $key ) : bool {
        $optionKey = '_brightfern_lock_' . $key;
        if ( add_option(
            $optionKey,
            time(),
            '',
            'no'
        ) ) {
            return true;
        }
        $heldSince = (int) get_option( $optionKey, 0 );
        if ( $heldSince > 0 && time() - $heldSince > self::LOCK_STALE_SECONDS ) {
            delete_option( $optionKey );
            return add_option(
                $optionKey,
                time(),
                '',
                'no'
            );
        }
        return false;
    }

    private function releaseLock( string $key ) : void {
        delete_option( '_brightfern_lock_' . $key );
    }

    /**
     * Pre-flight options for the current Format.
     *
     * The Peppol-specific checks must NOT apply to the national formats: Factur-X
     * goes through France's domestic PPF/PDP flow (or plain email) and FA(3)
     * through Poland's KSeF — neither ever touches the Peppol network. This
     * matters in practice because WooOrderAdapter derives a Peppol electronic
     * address from the buyer's VAT country as a best-effort fallback (FR → EAS
     * 9957), so such a buyer always *looks* addressable — and a perfectly valid
     * domestic invoice to a buyer who simply isn't Peppol-registered would
     * otherwise hard-fail on SMP-404, or on the BT-49 "electronic address
     * required" rule for a B2C buyer with no VAT at all.
     *
     * @return array<string,bool>
     */
    private function gateOptions() : array {
        $isPeppolBound = Settings::isPeppolFormat();
        // Regression: checkRecipient/requireRecipientReachable used to run the
        // live SMP lookup for EVERY Peppol-bound order regardless of tier — only
        // requirePeppolAddress was pro-gated. WooOrderAdapter::buyerEndpoint()
        // synthesizes a Peppol address from the buyer's VAT number whenever no
        // real one is on file ("just gives the SMP lookup something to test" —
        // not a registered address), so a free-tier merchant on the default
        // 'ubl' format selling to any B2B buyer with a VAT number in one of the
        // mapped EU countries got a real SMP-404 and a hard STATUS_BLOCKED, even
        // though they never asked to transmit and the document itself was valid
        // — contradicting the very principle this docblock states. Both must be
        // pro-gated exactly like requirePeppolAddress already is.
        $peppolRoutingMatters = $isPeppolBound && brightfern_is_pro();
        return array(
            'requirePeppolAddress'      => $peppolRoutingMatters,
            'checkRecipient'            => $peppolRoutingMatters,
            'requireRecipientReachable' => $peppolRoutingMatters && Settings::requireRecipientReachable(),
            'requireBuyerReference'     => 'xrechnung' === Settings::format(),
            'peppolBound'               => $isPeppolBound,
            'ksefBound'                 => 'ksef' === Settings::format(),
        );
    }

    private function buildGate() : PreflightGate {
        $http = new WpHttpClient();
        // Optional bundled resources enable the XSD + schematron layers when
        // present. Each format gets its own schematron slot (resources/README.md)
        // since the rule sets differ: Peppol BIS, the German CIUS, and the CII
        // binding are three different rule files over three different syntaxes.
        //
        // The CII slot matters more than it looks: several EN 16931 CII rules are
        // invisible to the XSD (which permits far more than the standard allows),
        // so neither schemaValidate() nor atgp/factur-x's embed-time XSD check
        // can catch them — CII-DT-031's currencyID restriction is exactly such a
        // case, and shipped broken until a review caught it by reading the rules.
        // The UBL XSD is skipped for Factur-X: its document isn't UBL at all.
        $format = Settings::format();
        // KSeF is the one format with no schematron slot: the Ministry publishes
        // no distributable rule file — its validation happens server-side when a
        // document is cleared. The XSD it does publish is unusually load-bearing
        // there, because FA(3) pins element order, mandatory annotation blocks
        // and every field pattern in the schema itself.
        $schematronFile = array(
            'xrechnung' => 'xrechnung.xslt',
            'facturx'   => 'cii-en16931.xslt',
            'ksef'      => null,
        )[$format] ?? 'peppol-bis-billing.xslt';
        $xsdFile = match ($format) {
            'facturx' => null,
            // Its document is CII, not UBL — the UBL XSD would reject it outright.
            'ksef' => 'xsd/ksef/schemat_FA(3)_v1-0E.xsd',
            default => 'xsd/UBL-Invoice-2.1.xsd',
        };
        $schematron = ( null !== $schematronFile && defined( 'BRIGHTFERN_DIR' ) ? BRIGHTFERN_DIR . 'resources/schematron/' . $schematronFile : null );
        $xsd = ( null !== $xsdFile && defined( 'BRIGHTFERN_DIR' ) ? BRIGHTFERN_DIR . 'resources/' . $xsdFile : null );
        $schematron = ( $schematron && is_readable( $schematron ) ? $schematron : null );
        $xsd = ( $xsd && is_readable( $xsd ) ? $xsd : null );
        $sml = ( (bool) Settings::get( 'use_test_network', false ) ? RecipientResolver::SML_TEST : RecipientResolver::SML_PRODUCTION );
        return new PreflightGate(new InvoiceValidator($schematron, $xsd), new RecipientResolver($http, $sml), new PartyValidator($http));
    }

    /** @return string[] */
    private function messages( GateResult $gate ) : array {
        $out = array();
        foreach ( $gate->validation->issues() as $issue ) {
            $out[] = strtoupper( $issue->severity ) . ' [' . $issue->code . '] ' . $issue->message;
        }
        return $out;
    }

    /**
     * @param string[] $messages
     */
    private function persist(
        WC_Abstract_Order $order,
        string $status,
        string $number,
        array $messages,
        string $xml,
        string $messageId = '',
        ?string $pdf = null,
        string $providerId = ''
    ) : string {
        $order->update_meta_data( self::META_STATUS, $status );
        $order->update_meta_data( self::META_NUMBER, $number );
        $order->update_meta_data( self::META_ISSUES, wp_json_encode( array_values( $messages ) ) );
        $order->update_meta_data( self::META_UPDATED, gmdate( 'c' ) );
        if ( '' !== $xml ) {
            $order->update_meta_data( self::META_XML, $xml );
        }
        if ( null !== $pdf && '' !== $pdf ) {
            $order->update_meta_data( self::META_PDF, base64_encode( $pdf ) );
        } else {
            // Must be cleared, not just skipped: regenerating a previously
            // Factur-X order under Peppol/XRechnung would otherwise leave the old
            // PDF behind, and both the metabox label and the download handler
            // prefer the PDF when present — so the merchant would be handed a
            // stale document for a freshly generated invoice.
            $order->delete_meta_data( self::META_PDF );
        }
        if ( '' !== $messageId ) {
            $order->update_meta_data( self::META_MESSAGE, $messageId );
        }
        if ( '' !== $providerId ) {
            $order->update_meta_data( self::META_PROVIDER, $providerId );
        }
        $order->save();
        return $status;
    }

}
