<?php

namespace Pay4AllForMypos\Checkout;

use Pay4AllForMypos\Backend\ConfigPage;
use Pay4AllForMypos\Support\Countries;
use Pay4AllForMypos\Support\Journal;

defined( 'ABSPATH' ) || exit;

/**
 * Builds the signed IPCPurchase form (fields + endpoint) that
 * MyposGateway::receipt_page() auto-submits from the browser to redirect
 * the customer to myPOS.
 *
 * This is the OUTBOUND-to-user flow ; the server-to-server IPCRefund flow
 * lives in Api\IpcClient. The signature convention is identical between
 * the two (base64-encode(implode('-', values)) then openssl_sign(SHA256)
 * then base64-encode the raw signature).
 */
final class IpcFormBuilder
{
    /**
     * Map the merchant's payment-method slugs (as stored in
     * `pay4all_for_mypos_methods_enabled`) to myPOS' numeric PaymentMethod
     * codes. Kept next to the builder because it's the only place we
     * translate them.
     *
     * @var array<string,int>
     */
    private const METHOD_MAP = [
        'card'     => 1,
        'ideal'    => 2,
        'both'     => 3, // "card + iDEAL" bundle
        'satispay' => 4,
        'twint'    => 5,
        'iris'     => 6,
    ];

    /**
     * Build the payload posted to myPOS' /vmp/checkout endpoint.
     *
     * @param \WC_Order $order
     * @param string    $mode            'live' or 'test'.
     * @param string[]  $enabled_methods Slugs from ConfigPage::enabled_methods().
     *                                   `['all']` means "let myPOS show every
     *                                   method the merchant account has on".
     *
     * @return array{fields:array<string,string>,url:string}
     */
    public static function build_purchase( \WC_Order $order, string $mode, array $enabled_methods ) : array
    {
        $mode      = ( $mode === 'live' ) ? 'live' : 'test';
        $endpoint  = $mode === 'live' ? PAY4ALL_FOR_MYPOS_IPC_URL_LIVE : PAY4ALL_FOR_MYPOS_IPC_URL_TEST;

        $sid = ConfigPage::get_pack_field( $mode, 'sid' );
        $cn  = ConfigPage::get_pack_field( $mode, 'cn' );
        $idx = ConfigPage::get_pack_field( $mode, 'idx' );

        // ISO 4217 currency the WC order is charged in.
        $currency = (string) $order->get_currency();
        $amount   = number_format( (float) $order->get_total(), 2, '.', '' );

        // Single wc-api callback URL used for OK / CANCEL / Notify - matches
        // the official myPOS plugin's approach. Extra query params (ipc,
        // order_id) were rejected by myPOS with Error 5. The dispatcher
        // in WebhookHandler routes on IPCmethod (POST body from myPOS)
        // and finds the target WC order via OrderID lookup.
        $base_url   = str_replace( 'http:', 'https:', home_url( '/?wc-api=wc_gateway_pay4all_for_mypos' ) );
        $url_ok     = $base_url;
        $url_cancel = $base_url;
        $url_notify = $base_url;

        // ISO2 -> myPOS 3-letter. Defaults to CHE when the billing country
        // isn't in the map so the request doesn't get rejected outright.
        $country_code = Countries::iso2_to_mypos_code( (string) $order->get_billing_country() ) ?? 'CHE';

        // Client IP for anti-fraud (kept as an empty string when the
        // server sits behind a load balancer that strips REMOTE_ADDR).
        $customer_ip = isset( $_SERVER['REMOTE_ADDR'] )
            ? sanitize_text_field( wp_unslash( (string) $_SERVER['REMOTE_ADDR'] ) )
            : '';

        // Field NAMES matter as much as ORDER : myPOS' upstream plugin
        // uses `keyindex` (lowercase), `URL_CANCEL` (all caps), and
        // `PaymentMethod` (singular). Using any other spelling triggers
        // E_MISSING_REQ_PARAMS on the checkout redirect.
        $fields = [
            'IPCmethod'    => 'IPCPurchase',
            'IPCVersion'   => '1.4',
            'IPCLanguage'  => self::current_language(),
            'WalletNumber' => $cn,
            'SID'          => $sid,
            'keyindex'     => $idx,
            'Source'       => self::source_header(),

            'Amount'     => $amount,
            'Currency'   => $currency,
            // OrderID is prefixed with a per-attempt unique hash for two
            // reasons: (1) shared demo Packs cause OrderID collisions on
            // myPOS' side (they treat "561" as already-used by another
            // merchant), which triggers Error 5 ; (2) the merchant can
            // retry a failed order without myPOS complaining. The webhook
            // handler extracts the trailing numeric segment to find the WC
            // order back. Matches the pattern of myPOS' own official plugin
            // (e.g. "6a7ec52c8e51e_561").
            'OrderID'    => uniqid( '', false ) . '_' . $order->get_id(),
            'URL_OK'     => $url_ok,
            'URL_CANCEL' => $url_cancel,
            'URL_Notify' => $url_notify,

            'CustomerIP'         => $customer_ip,
            'CustomerEmail'      => (string) $order->get_billing_email(),
            'CustomerFirstNames' => self::escape( (string) $order->get_billing_first_name() ),
            'CustomerFamilyName' => self::escape( (string) $order->get_billing_last_name() ),
            'CustomerCountry'    => $country_code,
            'CustomerCity'       => self::escape( (string) $order->get_billing_city() ),
            'CustomerZIPCode'    => (string) $order->get_billing_postcode(),
            'CustomerAddress'    => self::escape( (string) $order->get_billing_address_1() ),
            'CustomerPhone'      => (string) $order->get_billing_phone(),
            'Note'               => 'Pay4All for myPOS - Order ' . $order->get_order_number(),

            'CardTokenRequest'          => '0',
            // Boolean-ish flag: 1 = "post the extra payment parameters back
            // in the IPCPurchaseNotify". myPOS rejects any other value with
            // Error 5 - confirmed by diffing against the official plugin.
            'PaymentParametersRequired' => '1',
            'PaymentMethod'             => self::methods_to_ipc_codes( $enabled_methods ),
        ];

        // Cart items - myPOS validates that SUM(Amount_N) == Amount and
        // rejects with a bare Error Code 5 (E_INVALID_PARAMS) when they
        // differ by even one cent. We therefore include tax IN each
        // line (get_line_total with $inc_tax=true) AND emit shipping +
        // fees as their own lines - anything missed means the sums
        // don't match. A final "Adjustment" pseudo-line closes any
        // residual rounding gap so the request always sums correctly.
        $index = 1;
        $sum   = 0.0;
        foreach ( $order->get_items() as $item ) {
            /** @var \WC_Order_Item_Product $item */
            $qty        = max( 1, (int) ( method_exists( $item, 'get_quantity' ) ? $item->get_quantity() : 1 ) );
            $line_total = (float) $order->get_line_total( $item, true, false );
            $unit       = $line_total / $qty;

            $fields[ 'Article_'  . $index ] = self::escape( (string) $item->get_name() );
            $fields[ 'Quantity_' . $index ] = (string) $qty;
            $fields[ 'Price_'    . $index ] = number_format( $unit, 2, '.', '' );
            $fields[ 'Amount_'   . $index ] = number_format( $line_total, 2, '.', '' );
            $fields[ 'Currency_' . $index ] = $currency;
            $sum += $line_total;
            $index++;
        }

        foreach ( $order->get_items( 'shipping' ) as $shipping ) {
            /** @var \WC_Order_Item_Shipping $shipping */
            $ship_total = (float) $shipping->get_total() + (float) $shipping->get_total_tax();
            if ( $ship_total <= 0 ) { continue; }
            $fields[ 'Article_'  . $index ] = self::escape( 'Shipping: ' . (string) $shipping->get_name() );
            $fields[ 'Quantity_' . $index ] = '1';
            $fields[ 'Price_'    . $index ] = number_format( $ship_total, 2, '.', '' );
            $fields[ 'Amount_'   . $index ] = number_format( $ship_total, 2, '.', '' );
            $fields[ 'Currency_' . $index ] = $currency;
            $sum += $ship_total;
            $index++;
        }

        foreach ( $order->get_items( 'fee' ) as $fee ) {
            /** @var \WC_Order_Item_Fee $fee */
            $fee_total = (float) $fee->get_total() + (float) $fee->get_total_tax();
            if ( $fee_total == 0.0 ) { continue; }
            $fields[ 'Article_'  . $index ] = self::escape( (string) $fee->get_name() );
            $fields[ 'Quantity_' . $index ] = '1';
            $fields[ 'Price_'    . $index ] = number_format( $fee_total, 2, '.', '' );
            $fields[ 'Amount_'   . $index ] = number_format( $fee_total, 2, '.', '' );
            $fields[ 'Currency_' . $index ] = $currency;
            $sum += $fee_total;
            $index++;
        }

        // Reconciliation. Even with the loops above, WC's per-line tax
        // rounding can leave a 1-cent gap vs $order->get_total(). Emit
        // an "Adjustment" pseudo-line to close it (positive or negative)
        // so the sum matches Amount exactly.
        $expected = (float) $order->get_total();
        $gap      = round( $expected - $sum, 2 );
        if ( abs( $gap ) >= 0.01 ) {
            $fields[ 'Article_'  . $index ] = 'Adjustment';
            $fields[ 'Quantity_' . $index ] = '1';
            $fields[ 'Price_'    . $index ] = number_format( $gap, 2, '.', '' );
            $fields[ 'Amount_'   . $index ] = number_format( $gap, 2, '.', '' );
            $fields[ 'Currency_' . $index ] = $currency;
            $index++;
        }

        // Fallback : an empty cart is a WC misconfiguration, but myPOS
        // still needs at least ONE line to accept the request. Emit a
        // single synthetic line covering the full total.
        if ( $index === 1 ) {
            $fields[ 'Article_1'  ] = 'Order ' . $order->get_order_number();
            $fields[ 'Quantity_1' ] = '1';
            $fields[ 'Price_1'    ] = $amount;
            $fields[ 'Amount_1'   ] = $amount;
            $fields[ 'Currency_1' ] = $currency;
            $index = 2;
        }

        $fields['CartItems'] = (string) ( $index - 1 );

        // Sign last, then append Signature to the fields (Signature is
        // NOT itself signed). myPOS expects the field to be named
        // `Signature` with a capital S ; other names return
        // E_MISSING_REQ_PARAMS at the checkout redirect.
        $fields['Signature'] = self::sign( $fields, $mode );


        return [ 'fields' => $fields, 'url' => $endpoint ];
    }

    /**
     * Convert the merchant's high-level method slugs into the comma-separated
     * numeric list myPOS expects. Defaults to "3" (all methods on one page)
     * when the merchant left the selection empty or picked "all" - the
     * official plugin uses the same default and it's what actually works ;
     * "-1" from the docs triggers Error 5.
     */
    private static function methods_to_ipc_codes( array $enabled_methods ) : string
    {
        if ( in_array( 'all', $enabled_methods, true ) || empty( $enabled_methods ) ) {
            return '3';
        }

        $codes = [];
        foreach ( $enabled_methods as $slug ) {
            $slug = strtolower( (string) $slug );
            if ( isset( self::METHOD_MAP[ $slug ] ) ) {
                $codes[] = self::METHOD_MAP[ $slug ];
            }
        }
        $codes = array_values( array_unique( $codes ) );

        if ( empty( $codes ) ) {
            return '3';
        }
        return implode( ',', $codes );
    }

    /**
     * Two-letter language code, WPML/Polylang aware via ICL_LANGUAGE_CODE.
     */
    private static function current_language() : string
    {
        if ( defined( 'ICL_LANGUAGE_CODE' ) && ICL_LANGUAGE_CODE ) {
            return (string) ICL_LANGUAGE_CODE;
        }
        return substr( (string) get_locale(), 0, 2 ) ?: 'en';
    }

    /**
     * X-Source header myPOS uses to bucket traffic per integration.
     */
    private static function source_header() : string
    {
        return 'sc_wp_woocommerce ' . PAY4ALL_FOR_MYPOS_PLUGIN_VERSION
            . ' ' . PHP_VERSION
            . ' ' . get_bloginfo( 'version' );
    }

    /**
     * myPOS' spec forbids the following characters inside string fields:
     * `\/:*?"<>|`. Same helper as upstream, kept identical for parity.
     */
    private static function escape( string $value ) : string
    {
        return str_replace( [ '\\', '/', ':', '*', '?', '"', '<', '>', '|' ], '', $value );
    }

    /**
     * Sign an ordered field array. Identical convention to
     * {@see \Pay4AllForMypos\Api\IpcClient::sign()} - duplicated here rather
     * than shared because IpcClient is instance-based and this class is
     * static (keeps the dependency graph one-way).
     *
     * @param array<string,scalar> $ordered_fields
     */
    private static function sign( array $ordered_fields, string $mode ) : string
    {
        $pk_pem = ConfigPage::get_pack_field( $mode, 'pk' );
        if ( $pk_pem === '' ) {
            Journal::error( 'IPCPurchase signature: private key missing from Pack' );
            return '';
        }

        // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- required by myPOS signature scheme
        $conc = base64_encode( implode( '-', array_values( $ordered_fields ) ) );

        $pk = openssl_pkey_get_private( $pk_pem );
        if ( ! $pk ) {
            Journal::error( 'IPCPurchase signature: openssl_pkey_get_private() failed' );
            return '';
        }

        $signature = '';
        $ok        = openssl_sign( $conc, $signature, $pk, OPENSSL_ALGO_SHA256 );
        if ( ! $ok || $signature === '' ) {
            Journal::error( 'IPCPurchase signature: openssl_sign() failed' );
            return '';
        }

        // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- required by myPOS signature scheme
        return base64_encode( $signature );
    }
}
