<?php

namespace Pay4AllForMypos\Backend;

use Pay4AllForMypos\Support\Cipher;
use Pay4AllForMypos\Support\Journal;

defined( 'ABSPATH' ) || exit;

/**
 * Settings page + option-store for Pay4All for myPOS.
 *
 * Unlike Stripe (Publishable + Secret + Webhook triplet), myPOS ships a
 * single opaque "Configuration Pack" per mode - a base64-encoded JSON
 * blob returned by the myPOS Developer / Merchant portal. The Pack
 * carries five fields the plugin needs at runtime :
 *
 *   - sid : Store ID
 *   - cn  : Client / wallet number
 *   - pk  : RSA private key (PEM)
 *   - pc  : myPOS public certificate (PEM)
 *   - idx : Key index (numeric, 1-based)
 *
 * The merchant pastes the whole Pack in the wizard - no manual copy of
 * any of the 5 fields. It is stored encrypted at rest via Cipher, only
 * decoded on demand for the IPC client (Batch 3).
 *
 * There is deliberately no mp-auth handshake : the merchant is fully
 * autonomous once they have the Pack.
 */
final class ConfigPage
{
    /** @var ConfigPage|null */
    private static $instance = null;

    /**
     * Full list of payment methods the wizard exposes on step 3. myPOS
     * accepts the special "all" slug that overrides individual method
     * selection - we keep it here so the checkbox stays visible.
     */
    private const METHODS = [
        'card'     => 'Cards (Visa, Mastercard, Amex...)',
        'ideal'    => 'iDEAL',
        'twint'    => 'TWINT',
        'iris'     => 'IRIS',
        'satispay' => 'Satispay',
        'all'      => 'All methods (myPOS decides the layout)',
    ];

    /** All 15 myPOS-supported currencies. Kept centralised for reuse by the gateway class (Batch 3). */
    private const CURRENCIES = [
        'BGN', 'USD', 'EUR', 'GBP', 'CHF', 'JPY', 'RON', 'HRK',
        'NOK', 'ISK', 'SEK', 'DKK', 'CZK', 'HUF', 'PLN',
    ];

    public static function get_instance(): self
    {
        if ( self::$instance === null ) {
            self::$instance = new self();
        }
        return self::$instance;
    }

    /* --------------------------------------------------------------------- *
     *  Host-plugin detection - mirrors Pay4All for Stripe helpers.
     * --------------------------------------------------------------------- */

    public static function has_pay4all_form() : bool
    {
        return class_exists( '\\Pay4All\\Form\\Plugin' )
            || defined( 'P4ALL_FORM_VERSION' )
            || post_type_exists( 'p4all_form' );
    }

    public static function has_woocommerce() : bool
    {
        return ( defined( 'PAY4ALL_FOR_MYPOS_WC_ACTIVE' ) && PAY4ALL_FOR_MYPOS_WC_ACTIVE ) || class_exists( 'WooCommerce' );
    }

    /**
     * Allowed Pay4All Shop post-status slugs on the wizard's step 4.
     * Mirrors `Pay4All\Form\Admin\Orders\Statuses::all()` minus the
     * terminal "Cancelled".
     *
     * @return array<string,string>
     */
    public static function p4form_status_choices() : array
    {
        return [
            'p4all_new'        => __( 'New', 'pay4all-for-mypos' ),
            'p4all_processing' => __( 'Processing', 'pay4all-for-mypos' ),
            'p4all_pickup'     => __( 'Awaiting pickup', 'pay4all-for-mypos' ),
            'p4all_shipped'    => __( 'Shipped', 'pay4all-for-mypos' ),
            'p4all_completed'  => __( 'Completed', 'pay4all-for-mypos' ),
        ];
    }

    private function __construct()
    {
        add_action( 'admin_post_pay4all_for_mypos_toggle_mode',    [ self::class, 'handle_toggle_mode' ] );
        add_action( 'admin_post_pay4all_for_mypos_save_methods',   [ self::class, 'handle_save_methods' ] );
        add_action( 'admin_post_pay4all_for_mypos_save_statuses',  [ self::class, 'handle_save_statuses' ] );
        add_action( 'admin_post_pay4all_for_mypos_test_connection',[ self::class, 'handle_test_connection' ] );
    }

    /* --------------------------------------------------------------------- *
     *  Mode + Pack accessors used by MyposGateway / WebhookHandler (Batch 3)
     * --------------------------------------------------------------------- */

    /**
     * Runtime mode : 'live' or 'test'. Defaults to 'test' - we don't want
     * a fresh install landing on production before any credentials have
     * been validated (myPOS refuses signed calls with a mismatched Pack).
     */
    public static function get_mode() : string
    {
        $mode = (string) get_option( 'pay4all_for_mypos_mode', 'test' );
        return in_array( $mode, [ 'live', 'test' ], true ) ? $mode : 'test';
    }

    /**
     * Returns the raw base64 Pack stored (decrypted) for the given mode,
     * or an empty string when nothing is saved.
     */
    public static function get_pack( ?string $mode = null ) : string
    {
        $mode      = $mode ?: self::get_mode();
        $encrypted = (string) get_option( 'pay4all_for_mypos_pack_' . $mode, '' );
        if ( $encrypted === '' ) { return ''; }

        try {
            $cipher = new Cipher();
            return (string) $cipher->decrypt( $encrypted );
        } catch ( \Throwable $e ) {
            Journal::log( 'ConfigPage::get_pack decrypt failed for mode ' . $mode . ' : ' . $e->getMessage() );
            return '';
        }
    }

    /**
     * Decoded Pack field lookup. Returns '' when the Pack is empty or the
     * requested field is missing. `$field` is one of sid|cn|pk|pc|idx.
     */
    public static function get_pack_field( string $mode, string $field ) : string
    {
        $pack = self::decode_pack( self::get_pack( $mode ) );
        if ( empty( $pack ) ) { return ''; }
        $field = strtolower( $field );
        return isset( $pack[ $field ] ) ? (string) $pack[ $field ] : '';
    }

    /**
     * Encrypts + persists the Pack for a mode. Validates the input decodes
     * to a well-formed structure with the 5 required fields ; throws on
     * failure so callers can surface a user-friendly error.
     *
     * @throws \InvalidArgumentException when the Pack fails validation.
     * @throws \RuntimeException         when the Cipher call fails.
     */
    public static function set_pack( string $mode, string $b64_pack ) : void
    {
        $mode = in_array( $mode, [ 'live', 'test' ], true ) ? $mode : 'test';

        // Empty Pack is treated as "clear the credential" - handy for the
        // wizard's État tab trash button. No cipher call needed.
        if ( $b64_pack === '' ) {
            delete_option( 'pay4all_for_mypos_pack_' . $mode );
            return;
        }

        $decoded = self::decode_pack( $b64_pack );
        if ( empty( $decoded ) ) {
            throw new \InvalidArgumentException(
                'The Configuration Pack is not a valid myPOS Pack (must be base64 JSON with sid, cn, pk, pc, idx).'
            );
        }

        try {
            $cipher    = new Cipher();
            $encrypted = (string) $cipher->encrypt( $b64_pack );
        } catch ( \Throwable $e ) {
            // $previous chaining dropped on purpose : Plugin Check flags
            // any $e passed to a throwable constructor as unescaped output
            // even though the argument is an object, not a printable
            // string. The message alone (already wrapped in esc_html) is
            // enough for the caller's error notice.
            throw new \RuntimeException( esc_html( 'Cipher failure: ' . $e->getMessage() ) );
        }

        update_option( 'pay4all_for_mypos_pack_' . $mode, $encrypted, false );
    }

    /**
     * Pack decoder helper. Mirrors the myPOS gateway logic in
     * mypos-virtual-for-woocommerce (base64_decode -> json_decode -> pick
     * sid/cn/pk/pc/idx). Returns [] on any failure so callers can early-return
     * without checking for individual fields.
     *
     * @return array<string,string>
     */
    public static function decode_pack( string $b64 ) : array
    {
        if ( $b64 === '' ) { return []; }

        // Reject non-ASCII / non-base64 characters early so a stray paste
        // of the raw JSON (missing the base64 layer) fails fast.
        if ( ! preg_match( '#^[A-Za-z0-9+/=\s]+$#', $b64 ) ) {
            return [];
        }

        $json = base64_decode( trim( $b64 ), true );
        if ( ! is_string( $json ) || $json === '' ) { return []; }

        $data = json_decode( $json, true );
        if ( ! is_array( $data ) ) { return []; }

        // myPOS keys are lowercased in the Pack JSON, but we accept a stray
        // capitalisation on defensive grounds. Everything is coerced to
        // string to match the gateway's runtime expectations.
        $lower = [];
        foreach ( $data as $k => $v ) {
            $lower[ strtolower( (string) $k ) ] = is_scalar( $v ) ? (string) $v : '';
        }

        $required = [ 'sid', 'cn', 'pk', 'pc', 'idx' ];
        foreach ( $required as $key ) {
            if ( ! isset( $lower[ $key ] ) || $lower[ $key ] === '' ) {
                return [];
            }
        }

        // Return only the 5 fields the plugin knows how to use - extra
        // Pack fields (myPOS has been known to ship metadata like `ppr`
        // or `merchant_country`) are dropped for safety.
        return [
            'sid' => $lower['sid'],
            'cn'  => $lower['cn'],
            'pk'  => $lower['pk'],
            'pc'  => $lower['pc'],
            'idx' => $lower['idx'],
        ];
    }

    /** True when a Pack is stored for the currently active mode. */
    public static function has_credentials() : bool
    {
        return self::get_pack() !== '';
    }

    /** Convenience getter for the wallet number (CN) of the active mode. */
    public static function get_wallet() : string
    {
        return self::get_pack_field( self::get_mode(), 'cn' );
    }

    /**
     * @return string[] The static method whitelist used by the wizard.
     */
    public static function method_labels() : array
    {
        // Translate at call time - the METHODS constant can't wrap __()
        // itself because PHP forbids function calls in constant expressions
        // prior to 8.1. Keeping the array centralised means the .pot
        // extractor still picks each label up from this method body.
        return [
            'card'     => __( 'Cards (Visa, Mastercard, Amex...)', 'pay4all-for-mypos' ),
            'ideal'    => __( 'iDEAL', 'pay4all-for-mypos' ),
            'twint'    => __( 'TWINT', 'pay4all-for-mypos' ),
            'iris'     => __( 'IRIS', 'pay4all-for-mypos' ),
            'satispay' => __( 'Satispay', 'pay4all-for-mypos' ),
            'all'      => __( 'All methods (myPOS decides the layout)', 'pay4all-for-mypos' ),
        ];
    }

    /**
     * @return string[] Enabled payment method slugs (from `pay4all_for_mypos_methods_enabled`).
     */
    public static function enabled_methods() : array
    {
        $stored = get_option( 'pay4all_for_mypos_methods_enabled', null );
        if ( ! is_array( $stored ) ) {
            return []; // fresh install : no selection - wizard step 3 unlocks nothing yet.
        }
        $out = [];
        foreach ( $stored as $slug ) {
            $slug = (string) $slug;
            if ( $slug === '' ) { continue; }
            if ( preg_match( '/^[a-z0-9_]+$/', $slug ) ) { $out[] = $slug; }
        }
        return array_values( array_unique( $out ) );
    }

    /** All 15 myPOS-supported ISO currency codes. */
    public static function supported_currencies() : array
    {
        return self::CURRENCIES;
    }

    /* --------------------------------------------------------------------- *
     *  Order-status routing - reads mirror the Stripe plugin's five getters.
     * --------------------------------------------------------------------- */

    private const STATUS_VALUES        = [ 'processing', 'completed', 'on-hold' ];
    private const STATUS_FAILED_VALUES = [ 'pending', 'failed' ];
    private const STATUS_CANCEL_VALUES = [ 'pending', 'cancelled' ];

    public static function get_status_non_virtual() : string
    {
        $val = (string) get_option( 'pay4all_for_mypos_status_non_virtual', PAY4ALL_FOR_MYPOS_DEFAULT_STATUS_ORDER );
        return in_array( $val, self::STATUS_VALUES, true ) ? $val : PAY4ALL_FOR_MYPOS_DEFAULT_STATUS_ORDER;
    }

    public static function get_status_virtual() : string
    {
        $val = (string) get_option( 'pay4all_for_mypos_status_virtual', PAY4ALL_FOR_MYPOS_DEFAULT_STATUS_VIRTUAL );
        return in_array( $val, self::STATUS_VALUES, true ) ? $val : PAY4ALL_FOR_MYPOS_DEFAULT_STATUS_VIRTUAL;
    }

    public static function get_status_deferred() : string
    {
        $val = (string) get_option( 'pay4all_for_mypos_status_deferred', PAY4ALL_FOR_MYPOS_DEFAULT_STATUS_DEFERRED );
        return in_array( $val, self::STATUS_VALUES, true ) ? $val : PAY4ALL_FOR_MYPOS_DEFAULT_STATUS_DEFERRED;
    }

    public static function get_status_failed() : string
    {
        $val = (string) get_option( 'pay4all_for_mypos_status_failed', 'pending' );
        return in_array( $val, self::STATUS_FAILED_VALUES, true ) ? $val : 'pending';
    }

    public static function get_status_cancelled() : string
    {
        $val = (string) get_option( 'pay4all_for_mypos_status_cancelled', 'cancelled' );
        return in_array( $val, self::STATUS_CANCEL_VALUES, true ) ? $val : 'cancelled';
    }

    /** Human labels for the 3 "paid" status options. */
    public static function status_labels() : array
    {
        return [
            'processing' => __( 'Processing / In preparation', 'pay4all-for-mypos' ),
            'completed'  => __( 'Completed', 'pay4all-for-mypos' ),
            'on-hold'    => __( 'On hold', 'pay4all-for-mypos' ),
        ];
    }

    public static function status_failed_labels() : array
    {
        return [
            'pending' => __( 'Pending payment', 'pay4all-for-mypos' ),
            'failed'  => __( 'Failed', 'pay4all-for-mypos' ),
        ];
    }

    public static function status_cancel_labels() : array
    {
        return [
            'pending'   => __( 'Pending payment', 'pay4all-for-mypos' ),
            'cancelled' => __( 'Cancelled', 'pay4all-for-mypos' ),
        ];
    }

    /**
     * Returns the WC post_status to apply for a paid myPOS order.
     * @return string One of 'processing', 'completed', 'on-hold'.
     */
    public static function paid_status_for_wc_order( \WC_Order $order ) : string
    {
        return self::wc_order_is_virtual( $order ) ? self::get_status_virtual() : self::get_status_non_virtual();
    }

    /**
     * True when every line item on the WC order is virtual OR downloadable.
     * An empty order (shipping-only, edge case) is treated as physical.
     */
    private static function wc_order_is_virtual( \WC_Order $order ) : bool
    {
        $items = $order->get_items();
        if ( empty( $items ) ) { return false; }
        foreach ( $items as $item ) {
            $variation_id = (int) ( is_object( $item ) && method_exists( $item, 'get_variation_id' ) ? $item->get_variation_id() : ( $item['variation_id'] ?? 0 ) );
            $product_id   = (int) ( is_object( $item ) && method_exists( $item, 'get_product_id' )   ? $item->get_product_id()   : ( $item['product_id']   ?? 0 ) );
            $pid          = $variation_id > 0 ? $variation_id : $product_id;
            $product      = function_exists( 'wc_get_product' ) ? wc_get_product( $pid ) : null;
            if ( ! $product ) { return false; }
            if ( ! ( $product->is_virtual() || $product->is_downloadable() ) ) {
                return false;
            }
        }
        return true;
    }

    /**
     * Returns the Pay4All Shop post-status slug to apply on a just-paid
     * order. Reads `pay4all_for_mypos_p4form_status_virtual` /
     * `_physical`. Per user rule : a "ticket" (Billet) product counts as
     * virtual.
     */
    public static function paid_status_for_p4all_form_order( int $order_id ) : string
    {
        $virtual  = (string) get_option( 'pay4all_for_mypos_p4form_status_virtual',  'p4all_completed' );
        $physical = (string) get_option( 'pay4all_for_mypos_p4form_status_physical', 'p4all_processing' );
        $choices  = self::p4form_status_choices();
        if ( ! isset( $choices[ $virtual ] ) )  { $virtual  = 'p4all_completed'; }
        if ( ! isset( $choices[ $physical ] ) ) { $physical = 'p4all_processing'; }
        return self::p4all_form_order_is_virtual( $order_id ) ? $virtual : $physical;
    }

    /** True when every product on the Pay4All Shop order is a virtual file OR a ticket. */
    private static function p4all_form_order_is_virtual( int $order_id ) : bool
    {
        if ( ! class_exists( '\\Pay4All\\Form\\Domain\\Order' ) )   { return false; }
        if ( ! class_exists( '\\Pay4All\\Form\\Domain\\Product' ) ) { return false; }
        $items = \Pay4All\Form\Domain\Order::items( $order_id );
        if ( empty( $items ) || ! is_array( $items ) ) { return false; }
        foreach ( $items as $item ) {
            $pid = (int) ( $item['product_id'] ?? 0 );
            if ( $pid <= 0 ) { return false; }
            $type = \Pay4All\Form\Domain\Product::type( $pid );
            if ( ! in_array( $type, [ 'virtual', 'ticket' ], true ) ) {
                return false;
            }
        }
        return true;
    }

    /* --------------------------------------------------------------------- *
     *  Rendering
     * --------------------------------------------------------------------- */

    /**
     * Standalone renderer for the "Operating mode" panel - the test/prod
     * toggle switch. Extracted so the wizard's État tab can inline the
     * same markup without duplicating the CSS or the POST wiring.
     */
    public static function render_mode_cursor(): void
    {
        $mode        = self::get_mode();
        $wizard_done = WizardPage::is_complete();

        // Force Test mode when the merchant has no production Pack. Prevents
        // an accidental flip to Live with empty credentials (would leave
        // every checkout dead in the water since myPOS refuses unsigned calls).
        $has_live_pack = self::get_pack( 'live' ) !== '';
        if ( ! $has_live_pack && $mode !== 'test' ) {
            update_option( 'pay4all_for_mypos_mode', 'test', false );
            $mode = 'test';
        }
        $can_switch = $wizard_done && $has_live_pack;

        if ( defined( 'PAY4ALL_FOR_MYPOS_PLUGIN_URL' ) && defined( 'PAY4ALL_FOR_MYPOS_PLUGIN_VERSION' ) ) {
            $css_rel = 'assets/admin/css/mode-cursor.css';
            $css_abs = PAY4ALL_FOR_MYPOS_PLUGIN_PATH . '/' . $css_rel;
            if ( file_exists( $css_abs ) ) {
                wp_enqueue_style(
                    'pay4all-for-mypos-mode-cursor',
                    PAY4ALL_FOR_MYPOS_PLUGIN_URL . $css_rel,
                    [],
                    PAY4ALL_FOR_MYPOS_PLUGIN_VERSION
                );
            }
        }
        ?>
        <section class="p4a-mode-section" style="border-left:4px solid <?php echo $mode === 'live' ? '#22c55e' : '#f59e0b'; ?>;<?php echo $wizard_done ? '' : 'opacity:.55;pointer-events:none;'; ?>">
            <h2><?php esc_html_e( 'Operating mode', 'pay4all-for-mypos' ); ?></h2>
            <p class="description">
                <?php esc_html_e( 'Flip this switch to production to accept real payments. Your Test Pack remains saved and you can switch back to test mode at any time without re-pasting it.', 'pay4all-for-mypos' ); ?>
            </p>
            <?php if ( ! $has_live_pack ) : ?>
                <p class="description" style="color:#a16207;background:#fefce8;border-left:3px solid #f59e0b;padding:8px 12px;margin:0 0 12px;border-radius:4px;">
                    <strong><?php esc_html_e( 'Test mode locked.', 'pay4all-for-mypos' ); ?></strong>
                    <?php esc_html_e( 'No production Pack is configured - the plugin can only operate in Test mode. Add your production Pack in the wizard to enable the Production switch.', 'pay4all-for-mypos' ); ?>
                </p>
            <?php endif; ?>

            <?php
            // Capture the wizard step the form is submitted from so the
            // handler's redirect_back() sends the merchant back to the
            // same page. Falls back to step 5 (État).
            // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only display lookup ; the value only populates a hidden field validated (nonce + clamp) by handle_toggle_mode() before use.
            $current_step = isset( $_GET['step'] ) ? (int) $_GET['step'] : 0;
            if ( $current_step < 1 || $current_step > 20 ) { $current_step = 5; }
            ?>
            <form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>" class="p4a-mode-cursor">
                <input type="hidden" name="action" value="pay4all_for_mypos_toggle_mode">
                <input type="hidden" name="redirect_step" value="<?php echo esc_attr( (string) $current_step ); ?>">
                <?php wp_nonce_field( 'pay4all_for_mypos_toggle_mode' ); ?>

                <span class="p4a-mode-label <?php echo $mode === 'live' ? 'p4a-active-live' : ''; ?>"><?php esc_html_e( 'Production', 'pay4all-for-mypos' ); ?></span>

                <label class="p4a-switch" title="<?php esc_attr_e( 'Toggle production / test', 'pay4all-for-mypos' ); ?>">
                    <input type="checkbox"
                           name="target_mode"
                           value="test"
                           <?php checked( $mode, 'test' ); ?>
                           <?php disabled( ! $can_switch ); ?>
                           onchange="this.form.submit();">
                    <span class="p4a-switch-slider" aria-hidden="true"></span>
                </label>

                <span class="p4a-mode-label <?php echo $mode === 'test' ? 'p4a-active-test' : ''; ?>"><?php esc_html_e( 'Test', 'pay4all-for-mypos' ); ?></span>

                <noscript>
                    <button type="submit" class="button" style="margin-left:1rem;" <?php disabled( ! $can_switch ); ?>>
                        <?php esc_html_e( 'Switch', 'pay4all-for-mypos' ); ?>
                    </button>
                </noscript>
            </form>
        </section>
        <?php
    }

    /**
     * Legacy settings page. The wizard is the primary UI now, but this
     * page stays reachable for form-action targets (test_connection button
     * and back-compat direct URLs).
     */
    public static function render_settings_page(): void
    {
        if ( ! current_user_can( 'manage_options' ) ) {
            return;
        }

        $notice = get_transient( 'pay4all_for_mypos_notice' );
        if ( is_array( $notice ) ) {
            delete_transient( 'pay4all_for_mypos_notice' );
        }

        $wizard_url = admin_url( 'admin.php?page=' . WizardPage::SLUG );
        ?>
        <div class="wrap pay4all-for-mypos">
            <h1><?php esc_html_e( 'Pay4All for myPOS', 'pay4all-for-mypos' ); ?></h1>

            <?php if ( is_array( $notice ) && ! empty( $notice['message'] ) ) :
                $cls = ( $notice['type'] ?? 'success' ) === 'error' ? 'notice-error' : 'notice-success';
                ?>
                <div class="notice <?php echo esc_attr( $cls ); ?> is-dismissible" style="margin-top:1rem;">
                    <p><?php echo esc_html( (string) $notice['message'] ); ?></p>
                </div>
            <?php endif; ?>

            <div class="p4all-color-sections">
                <?php self::render_mode_cursor(); ?>

                <section class="p4all-color-section">
                    <h2><?php esc_html_e( 'Configuration', 'pay4all-for-mypos' ); ?></h2>
                    <p>
                        <?php
                        printf(
                            wp_kses(
                                /* translators: %s: link to the wizard. */
                                __( 'The full configuration (Pack test, Pack production, payment methods, order statuses) lives in the %s.', 'pay4all-for-mypos' ),
                                [ 'a' => [ 'href' => [] ] ]
                            ),
                            '<a href="' . esc_url( $wizard_url ) . '"><strong>' . esc_html__( 'installation wizard', 'pay4all-for-mypos' ) . '</strong></a>'
                        );
                        ?>
                    </p>
                    <p>
                        <a href="<?php echo esc_url( $wizard_url ); ?>" class="button button-primary">
                            <?php esc_html_e( 'Open the wizard', 'pay4all-for-mypos' ); ?>
                        </a>
                    </p>
                </section>
            </div>
        </div>
        <?php
    }

    /* --------------------------------------------------------------------- *
     *  Form handlers
     * --------------------------------------------------------------------- */

    /**
     * Flips the runtime mode between 'test' and 'live'. Refuses to switch
     * to 'live' if no live Pack is stored (a live checkout without a live
     * Pack would fail on every transaction).
     */
    public static function handle_toggle_mode() : void
    {
        self::assert_admin_and_nonce( 'pay4all_for_mypos_toggle_mode' );

        // Server-side gate matching the visual grey-out on render : refuse
        // to flip the mode until the wizard has been completed. Catches
        // request forgery / URL tampering.
        if ( ! WizardPage::is_complete() ) {
            self::flash( 'error', __( 'Complete the installation wizard before switching the operating mode.', 'pay4all-for-mypos' ) );
            self::redirect_back();
        }

        // Checkbox sends value="test" when CHECKED (slider on the Test side).
        // Absence of target_mode means the merchant flipped to Production.
        // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce verified above via self::assert_admin_and_nonce().
        $target = isset( $_POST['target_mode'] ) ? sanitize_key( (string) wp_unslash( $_POST['target_mode'] ) ) : 'live';
        if ( ! in_array( $target, [ 'live', 'test' ], true ) ) {
            $target = 'live';
        }

        if ( $target === 'live' && self::get_pack( 'live' ) === '' ) {
            self::flash( 'error', __( 'Unable to switch to production: paste your production Pack in the wizard first.', 'pay4all-for-mypos' ) );
            self::redirect_back();
        }

        update_option( 'pay4all_for_mypos_mode', $target, false );
        self::flash(
            'success',
            $target === 'live'
                ? __( 'Pay4All for myPOS is now in production. Real payments are active.', 'pay4all-for-mypos' )
                : __( 'Pay4All for myPOS is back in test mode. No real payments are being collected.', 'pay4all-for-mypos' )
        );
        self::redirect_back();
    }

    /**
     * "Test the connection" button - will do a signed dummy IPC ping in
     * Batch 3 (Api\IpcClient). For now it stubs a friendly notice so the
     * button on the wizard doesn't 404.
     *
     * TODO(Batch 3): actually call MyposIpcClient::ping() and surface the
     * merchant's currency + wallet from the response.
     */
    public static function handle_test_connection() : void
    {
        self::assert_admin_and_nonce( 'pay4all_for_mypos_test_connection' );

        // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce verified above.
        $mode = isset( $_POST['mode'] ) ? sanitize_key( (string) wp_unslash( $_POST['mode'] ) ) : self::get_mode();
        if ( ! in_array( $mode, [ 'live', 'test' ], true ) ) {
            $mode = 'test';
        }

        if ( self::get_pack( $mode ) === '' ) {
            self::flash( 'error', sprintf(
                /* translators: %s: mode name - "test" or "production". */
                __( 'Paste the %s Pack first before testing the connection.', 'pay4all-for-mypos' ),
                $mode === 'live' ? __( 'production', 'pay4all-for-mypos' ) : __( 'test', 'pay4all-for-mypos' )
            ) );
            self::redirect_back();
        }

        // Batch 2 stub - Batch 3 will replace this with a real IPCTxnStatus
        // call using the stored Pack.
        self::flash( 'success', __( 'Test connection endpoint stubbed (will call myPOS IPC in Batch 3).', 'pay4all-for-mypos' ) );
        self::redirect_back();
    }

    /**
     * Saves the merchant's payment method selection from the wizard's
     * step 3. Store the raw slug list ; the gateway class (Batch 3) will
     * translate the array into the myPOS `paymentMethod` query-string
     * parameter at IPC-request time.
     */
    public static function handle_save_methods() : void
    {
        self::assert_admin_and_nonce( 'pay4all_for_mypos_save_methods' );

        // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce verified above ; each element sanitized by sanitize_key() below.
        $raw   = isset( $_POST['payment_methods'] ) ? (array) wp_unslash( $_POST['payment_methods'] ) : [];
        $clean = array_values( array_unique( array_filter( array_map( 'sanitize_key', $raw ), 'strlen' ) ) );

        // Intersect with the known slug whitelist - myPOS won't accept
        // anything outside the METHODS array, and a rogue value here
        // could break the IPC signing routine downstream.
        $allowed = array_keys( self::METHODS );
        $clean   = array_values( array_intersect( $clean, $allowed ) );

        update_option( 'pay4all_for_mypos_methods_enabled', $clean, false );

        self::flash( 'success', __( 'Payment methods saved.', 'pay4all-for-mypos' ) );
        self::redirect_back();
    }

    /**
     * Saves the merchant's post-payment status choices. Accepts up to 5
     * WC status keys + 2 Pay4All Shop scoped keys, matching the wizard's
     * step 4 layout.
     */
    public static function handle_save_statuses() : void
    {
        self::assert_admin_and_nonce( 'pay4all_for_mypos_save_statuses' );

        // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce verified above.
        $non_virtual = isset( $_POST['status_non_virtual'] ) ? sanitize_key( (string) wp_unslash( $_POST['status_non_virtual'] ) ) : PAY4ALL_FOR_MYPOS_DEFAULT_STATUS_ORDER;
        // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce verified above.
        $virtual     = isset( $_POST['status_virtual'] )     ? sanitize_key( (string) wp_unslash( $_POST['status_virtual'] ) )     : PAY4ALL_FOR_MYPOS_DEFAULT_STATUS_VIRTUAL;

        if ( ! in_array( $non_virtual, self::STATUS_VALUES, true ) ) { $non_virtual = PAY4ALL_FOR_MYPOS_DEFAULT_STATUS_ORDER; }
        if ( ! in_array( $virtual, self::STATUS_VALUES, true ) )     { $virtual     = PAY4ALL_FOR_MYPOS_DEFAULT_STATUS_VIRTUAL; }

        update_option( 'pay4all_for_mypos_status_non_virtual', $non_virtual, false );
        update_option( 'pay4all_for_mypos_status_virtual',     $virtual,     false );

        // Optional advanced statuses - only saved when their field is present.
        // phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce verified above.
        if ( isset( $_POST['status_deferred'] ) ) {
            $deferred = sanitize_key( (string) wp_unslash( $_POST['status_deferred'] ) );
            if ( ! in_array( $deferred, self::STATUS_VALUES, true ) ) { $deferred = PAY4ALL_FOR_MYPOS_DEFAULT_STATUS_DEFERRED; }
            update_option( 'pay4all_for_mypos_status_deferred', $deferred, false );
        }
        if ( isset( $_POST['status_failed'] ) ) {
            $failed = sanitize_key( (string) wp_unslash( $_POST['status_failed'] ) );
            if ( ! in_array( $failed, self::STATUS_FAILED_VALUES, true ) ) { $failed = 'pending'; }
            update_option( 'pay4all_for_mypos_status_failed', $failed, false );
        }
        if ( isset( $_POST['status_cancelled'] ) ) {
            $cancelled = sanitize_key( (string) wp_unslash( $_POST['status_cancelled'] ) );
            if ( ! in_array( $cancelled, self::STATUS_CANCEL_VALUES, true ) ) { $cancelled = 'cancelled'; }
            update_option( 'pay4all_for_mypos_status_cancelled', $cancelled, false );
        }

        // Pay4All Shop-scoped keys - only present when pay4all-form is loaded.
        $allowed_p4form = array_keys( self::p4form_status_choices() );
        if ( isset( $_POST['pay4all_for_mypos_p4form_status_virtual'] ) ) {
            $val = sanitize_key( (string) wp_unslash( $_POST['pay4all_for_mypos_p4form_status_virtual'] ) );
            if ( in_array( $val, $allowed_p4form, true ) ) {
                update_option( 'pay4all_for_mypos_p4form_status_virtual', $val, false );
            }
        }
        if ( isset( $_POST['pay4all_for_mypos_p4form_status_physical'] ) ) {
            $val = sanitize_key( (string) wp_unslash( $_POST['pay4all_for_mypos_p4form_status_physical'] ) );
            if ( in_array( $val, $allowed_p4form, true ) ) {
                update_option( 'pay4all_for_mypos_p4form_status_physical', $val, false );
            }
        }
        // phpcs:enable WordPress.Security.NonceVerification.Missing

        self::flash( 'success', __( 'Settings saved.', 'pay4all-for-mypos' ) );

        // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce verified above.
        $target = isset( $_POST['redirect_to'] ) ? esc_url_raw( (string) wp_unslash( $_POST['redirect_to'] ) ) : '';
        if ( $target !== '' ) {
            wp_safe_redirect( $target );
            exit;
        }
        self::redirect_back();
    }

    /* --------------------------------------------------------------------- *
     *  Utilities
     * --------------------------------------------------------------------- */

    /**
     * Displays the first 4 characters + last 4 characters of a secret,
     * with dots in between. Feedback pattern used across every masked
     * field in the codebase - see MEMORY.md entry
     * "feedback_masked_field_badge".
     */
    public static function mask( string $value ) : string
    {
        if ( $value === '' ) {
            return '';
        }
        if ( strlen( $value ) <= 10 ) {
            // Too short to safely reveal 4+4 - just use the leading 5.
            return substr( $value, 0, 5 ) . str_repeat( "\u{2022}", 4 );
        }
        return substr( $value, 0, 4 ) . str_repeat( "\u{2022}", 8 ) . substr( $value, -4 );
    }

    private static function assert_admin_and_nonce( string $nonce_action ) : void
    {
        $cap = ( defined( 'PAY4ALL_FOR_MYPOS_WC_ACTIVE' ) && PAY4ALL_FOR_MYPOS_WC_ACTIVE ) ? 'manage_woocommerce' : 'manage_options';
        if ( ! current_user_can( $cap ) ) {
            wp_die( esc_html__( 'Permission denied.', 'pay4all-for-mypos' ), '', [ 'response' => 403 ] );
        }
        $nonce = isset( $_REQUEST['_wpnonce'] ) ? sanitize_text_field( wp_unslash( (string) $_REQUEST['_wpnonce'] ) ) : '';
        if ( ! wp_verify_nonce( $nonce, $nonce_action ) ) {
            wp_die( esc_html__( 'Invalid nonce.', 'pay4all-for-mypos' ), '', [ 'response' => 400 ] );
        }
    }

    private static function flash( string $type, string $message ) : void
    {
        set_transient( 'pay4all_for_mypos_notice', [
            'type'    => $type,
            'message' => $message,
        ], 60 );
        set_transient( 'pay4all_for_mypos_wizard_flash', [
            'type'    => $type,
            'message' => $message,
        ], 30 );
    }

    /**
     * Sends admin_post handlers back to the wizard step the form was
     * submitted from (mode cursor lives on step 5 today, may migrate).
     * Falls back to step 5 (État) when the hidden `redirect_step` field
     * is missing or out of range.
     */
    private static function redirect_back() : void
    {
        // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce checked by the caller before invoking redirect_back().
        $raw_step = isset( $_POST['redirect_step'] ) ? (int) $_POST['redirect_step'] : 0;
        $step     = ( $raw_step >= 1 && $raw_step <= 20 ) ? $raw_step : 5;
        wp_safe_redirect( WizardPage::url( $step ) );
        exit;
    }
}
