<?php

namespace Pay4AllForMypos\Checkout;

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

defined( 'ABSPATH' ) || exit;

/**
 * Handles server-to-server IPC callbacks from myPOS (IPCPurchaseNotify,
 * IPCPurchaseRollback, IPCPurchaseCancel, IPCPurchaseOK, IPCSignatureVerify).
 *
 * WebhookHandler OWNS the single wc-api hook shared with ReturnHandler.
 * The dispatch rule:
 *
 *     if ( POST && has IPCmethod ) -> handle webhook (this class)
 *     else                         -> delegate to ReturnHandler
 *
 * That way both server-to-server callbacks AND the customer's URL_OK /
 * URL_Cancel return can use the same endpoint (which is what myPOS wants -
 * URL_Notify, URL_OK and URL_Cancel share the same host + path in our
 * IpcFormBuilder payload).
 */
final class WebhookHandler
{
    /**
     * wc-api slug is derived from the WC gateway class - here we shim to
     * MyposGateway::SLUG so the URL is stable no matter what class name
     * the gateway ends up with.
     */
    private const WC_API_ACTION = 'woocommerce_api_wc_gateway_pay4all_for_mypos';

    public static function register() : void
    {
        add_action( self::WC_API_ACTION, [ self::class, 'dispatch' ] );
    }

    /**
     * Central entrypoint - decides who handles this request (webhook vs
     * customer return) and forwards accordingly.
     */
    public static function dispatch() : void
    {
        // Grab RAW POST data before any sanitisation - the signature is
        // computed over the exact bytes myPOS sent, and sanitize_text_field
        // would strip characters that alter the concatenated payload.
        $post_raw = [];
        // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- external webhook, signature-verified below.
        if ( ! empty( $_POST ) ) {
            // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- external webhook, raw data required for signature check.
            foreach ( $_POST as $key => $value ) {
                if ( is_scalar( $value ) ) {
                    $post_raw[ (string) $key ] = wp_unslash( (string) $value );
                }
            }
        }

        $is_webhook = ! empty( $post_raw['IPCmethod'] );

        if ( ! $is_webhook ) {
            // GET (or POST without IPCmethod) - customer-facing return.
            ReturnHandler::handle_return();
            return;
        }

        self::handle( $post_raw );
    }

    /**
     * Handle a signed IPC webhook from myPOS. Always exits.
     *
     * @param array<string,string> $post_raw
     */
    public static function handle( array $post_raw ) : void
    {
        $method = (string) ( $post_raw['IPCmethod'] ?? '' );
        if ( $method === '' ) {
            self::respond( 400, 'NO IPC METHOD' );
        }
        if ( ! in_array( $method, PAY4ALL_FOR_MYPOS_SUPPORTED_IPC_METHODS, true ) ) {
            Journal::error( 'Webhook: unsupported IPCmethod ' . $method );
            self::respond( 200, 'UNSUPPORTED IPC METHOD' );
        }

        // Verify the signature BEFORE trusting any field, using the
        // merchant's public certificate (PEM) from their Pack.
        if ( ! self::verify_signature( $post_raw ) ) {
            Journal::error( 'Webhook: invalid signature for ' . $method );
            self::respond( 400, 'INVALID SIGNATURE' );
        }

        // Sanitise AFTER the signature check succeeds.
        $post = [];
        foreach ( $post_raw as $k => $v ) {
            $post[ $k ] = sanitize_text_field( (string) $v );
        }

        // Health-check ping myPOS issues from the merchant portal when the
        // merchant clicks "Verify signature".
        if ( $method === 'IPCSignatureVerify' ) {
            self::respond( 200, 'OK' );
        }

        $located = self::locate_order( $post );
        if ( $located === null ) {
            Journal::error( 'Webhook: order not found for OrderID=' . (string) ( $post['OrderID'] ?? '' ) );
            self::respond( 200, 'ORDER NOT FOUND' );
        }

        switch ( $method ) {
            case 'IPCPurchaseNotify':
                // Server-to-server ; respond "OK" plain-text and exit so
                // myPOS' 10 s timeout never fires.
                self::mark_paid( $located, $post );
                self::respond_notify_ok();
                break;

            case 'IPCPurchaseOK':
                // Browser return after a successful payment. Mark paid then
                // redirect the customer to the thank-you page (WC or
                // p4form-specific).
                self::mark_paid( $located, $post );
                self::redirect_to_thank_you( $located );
                break;

            case 'IPCPurchaseRollback':
                // Server-to-server ; myPOS reversed the charge before it
                // settled (fraud check, chargeback).
                self::mark_failed( $located, $post );
                self::respond_notify_ok();
                break;

            case 'IPCPurchaseCancel':
                // Browser return after the customer clicked "Cancel" on the
                // myPOS page. Mark cancelled then redirect back to the
                // checkout / form origin.
                self::mark_cancelled( $located, $post );
                self::redirect_to_cancel( $located );
                break;
        }

        self::respond( 200, 'OK' );
    }

    /**
     * Flushes an HTTP 200 "OK" plain-text response then hands control back
     * (or exits, depending on caller). Used for server-to-server IPC
     * responses where myPOS expects a bare "OK" acknowledgement.
     */
    private static function respond_notify_ok() : void
    {
        while ( ob_get_level() > 0 ) {
            ob_end_clean();
        }
        header( 'Connection: close' );
        header( 'Content-Length: 2' );
        header( 'Content-Type: text/plain; charset=utf-8' );
        echo 'OK';
        if ( function_exists( 'fastcgi_finish_request' ) ) {
            fastcgi_finish_request();
        } else {
            flush();
        }
        exit;
    }

    /**
     * Redirects the customer's browser to the correct thank-you page after
     * a successful payment. WC orders go to the standard order-received
     * page ; p4form orders go back to the page that hosted the form (URL
     * stored on the order by Pay4AllForm at submission time).
     *
     * @param array{type:string,wc_order?:\WC_Order,p4form_id?:int} $located
     */
    private static function redirect_to_thank_you( array $located ) : void
    {
        if ( $located['type'] === 'wc' && $located['wc_order'] instanceof \WC_Order ) {
            $url = $located['wc_order']->get_checkout_order_received_url();
        } elseif ( $located['type'] === 'p4form' && ! empty( $located['p4form_id'] ) ) {
            $url = \Pay4AllForMypos\Integrations\Pay4AllForm::get_return_url( (int) $located['p4form_id'], 'ok' );
        } else {
            $url = home_url( '/' );
        }
        wp_safe_redirect( $url );
        exit;
    }

    /**
     * Redirects the customer's browser after a cancellation. WC orders go
     * back to the checkout page ; p4form orders go back to their origin.
     *
     * @param array{type:string,wc_order?:\WC_Order,p4form_id?:int} $located
     */
    private static function redirect_to_cancel( array $located ) : void
    {
        if ( $located['type'] === 'wc' && $located['wc_order'] instanceof \WC_Order ) {
            $url = function_exists( 'wc_get_checkout_url' )
                ? wc_get_checkout_url()
                : $located['wc_order']->get_cancel_order_url();
        } elseif ( $located['type'] === 'p4form' && ! empty( $located['p4form_id'] ) ) {
            $url = \Pay4AllForMypos\Integrations\Pay4AllForm::get_return_url( (int) $located['p4form_id'], 'cancel' );
        } else {
            $url = home_url( '/' );
        }
        wp_safe_redirect( $url );
        exit;
    }

    /**
     * Mark the located order as paid. Idempotent - safe if the notify
     * arrives after the browser return already touched the order.
     *
     * @param array{type:string,wc_order?:\WC_Order,p4form_id?:int} $located
     * @param array<string,string>                                   $post
     */
    private static function mark_paid( array $located, array $post ) : void
    {
        $trnref = (string) ( $post['IPC_Trnref'] ?? '' );

        if ( $located['type'] === 'wc' && $located['wc_order'] instanceof \WC_Order ) {
            try {
                $order = $located['wc_order'];
                if ( $trnref !== '' ) {
                    $order->update_meta_data( '_pay4all_for_mypos_trnref', $trnref );
                    $order->set_transaction_id( $trnref );
                }
                $order->set_payment_method( MyposGateway::SLUG );

                // payment_complete() routes through
                // woocommerce_payment_complete_order_status, which Bootstrap.php
                // hooks into to pick the right final status (virtual vs physical)
                // using ConfigPage::paid_status_for_wc_order().
                $current = $order->get_status();
                if ( ! in_array( $current, [ 'processing', 'completed' ], true ) ) {
                    if ( $trnref !== '' ) {
                        $order->payment_complete( $trnref );
                    } else {
                        $order->payment_complete();
                    }
                }
                $order->add_order_note( sprintf(
                    /* translators: %s = myPOS transaction reference */
                    __( 'Pay4All for myPOS: payment confirmed (%s).', 'pay4all-for-mypos' ),
                    $trnref !== '' ? $trnref : '-'
                ) );
                $order->save();
                if ( class_exists( PendingPaymentsManager::class ) ) {
                    PendingPaymentsManager::remove_pending( (int) $order->get_id() );
                }
                Journal::event( sprintf( 'Paid WC order #%d trnref=%s', (int) $order->get_id(), $trnref ) );
            } catch ( \Throwable $e ) {
                Journal::error( 'mark_paid (WC) error: ' . $e->getMessage() );
            }
            return;
        }

        if ( $located['type'] === 'p4form' && ! empty( $located['p4form_id'] ) ) {
            try {
                \Pay4AllForMypos\Integrations\Pay4AllForm::mark_paid(
                    (int) $located['p4form_id'],
                    $trnref,
                    'webhook'
                );
                Journal::event( sprintf( 'Paid p4form order #%d trnref=%s', (int) $located['p4form_id'], $trnref ) );
            } catch ( \Throwable $e ) {
                Journal::error( 'mark_paid (p4form) error: ' . $e->getMessage() );
            }
        }
    }

    /**
     * @param array{type:string,wc_order?:\WC_Order,p4form_id?:int} $located
     * @param array<string,string>                                   $post
     */
    private static function mark_failed( array $located, array $post ) : void
    {
        if ( $located['type'] === 'wc' && $located['wc_order'] instanceof \WC_Order ) {
            try {
                $order = $located['wc_order'];
                $order->update_status(
                    ConfigPage::get_status_failed(),
                    __( 'Pay4All for myPOS: payment declined by gateway.', 'pay4all-for-mypos' )
                );
                $order->save();
                if ( class_exists( PendingPaymentsManager::class ) ) {
                    PendingPaymentsManager::remove_pending( (int) $order->get_id() );
                }
                Journal::event( sprintf( 'Rollback WC order #%d', (int) $order->get_id() ) );
            } catch ( \Throwable $e ) {
                Journal::error( 'mark_failed (WC) error: ' . $e->getMessage() );
            }
        }
        // p4form rollback : nothing to do yet - Pay4AllForm doesn't expose a
        // "mark failed" helper and Pay4All Shop doesn't have a "failed"
        // status distinct from "cancelled". Left as a TODO.
    }

    /**
     * @param array{type:string,wc_order?:\WC_Order,p4form_id?:int} $located
     * @param array<string,string>                                   $post
     */
    private static function mark_cancelled( array $located, array $post ) : void
    {
        if ( $located['type'] === 'wc' && $located['wc_order'] instanceof \WC_Order ) {
            try {
                $order = $located['wc_order'];
                $order->update_status(
                    ConfigPage::get_status_cancelled(),
                    __( 'Pay4All for myPOS: customer cancelled the payment.', 'pay4all-for-mypos' )
                );
                $order->save();
                if ( class_exists( PendingPaymentsManager::class ) ) {
                    PendingPaymentsManager::remove_pending( (int) $order->get_id() );
                }
                Journal::event( sprintf( 'Cancelled WC order #%d', (int) $order->get_id() ) );
            } catch ( \Throwable $e ) {
                Journal::error( 'mark_cancelled (WC) error: ' . $e->getMessage() );
            }
        }
        // p4form cancel is a no-op for now (no dedicated helper on
        // Pay4AllForm and the order simply remains "pending payment").
    }

    /**
     * Locate the order the webhook is about. Returns a discriminated
     * array so the caller can tell WC orders from Pay4All Shop (p4form)
     * orders without duck-typing.
     *
     * Priority : trnref meta lookup (both WC and p4form) -> OrderID
     * numeric fallback (both types).
     *
     * @param array<string,string> $post
     * @return array{type:'wc'|'p4form',wc_order?:\WC_Order,p4form_id?:int}|null
     */
    private static function locate_order( array $post ) : ?array
    {
        $trnref = (string) ( $post['IPC_Trnref'] ?? '' );

        // 1) WC by trnref meta
        if ( $trnref !== '' && function_exists( 'wc_get_orders' ) ) {
            $orders = wc_get_orders( [
                'limit'        => 1,
                'meta_key'     => '_pay4all_for_mypos_trnref', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
                'meta_value'   => $trnref,                     // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
                'meta_compare' => '=',
            ] );
            if ( is_array( $orders ) && ! empty( $orders ) && $orders[0] instanceof \WC_Order ) {
                return [ 'type' => 'wc', 'wc_order' => $orders[0] ];
            }
        }

        // 2) p4form by trnref meta
        if ( $trnref !== '' && class_exists( '\\Pay4AllForMypos\\Integrations\\Pay4AllForm' ) ) {
            $p = \Pay4AllForMypos\Integrations\Pay4AllForm::find_order_by_trnref( $trnref );
            if ( $p instanceof \WP_Post ) {
                return [ 'type' => 'p4form', 'p4form_id' => (int) $p->ID ];
            }
        }

        // OrderID may be prefixed with a per-attempt unique hash (see
        // IpcFormBuilder + Pay4AllForm build_purchase) to dodge shared-Pack
        // collisions on myPOS' side. Extract the trailing numeric segment.
        $order_id_raw = (string) ( $post['OrderID'] ?? '' );
        $order_id     = 0;
        if ( preg_match( '#(\d+)$#', $order_id_raw, $m ) ) {
            $order_id = (int) $m[1];
        }
        if ( $order_id <= 0 ) {
            return null;
        }

        // 3) WC by numeric OrderID
        $wc = wc_get_order( $order_id );
        if ( $wc instanceof \WC_Order ) {
            return [ 'type' => 'wc', 'wc_order' => $wc ];
        }

        // 4) p4form by numeric OrderID (WP post lookup)
        if ( class_exists( '\\Pay4All\\Form\\PostTypes' ) ) {
            $p = get_post( $order_id );
            if ( $p instanceof \WP_Post && $p->post_type === \Pay4All\Form\PostTypes::ORDER ) {
                return [ 'type' => 'p4form', 'p4form_id' => (int) $p->ID ];
            }
        }

        return null;
    }

    /**
     * Verify the myPOS RSA/SHA256 signature over the concatenated field
     * values (in the exact order myPOS sent them, MINUS the Signature
     * field). Uses the merchant's PC (public certificate) from their Pack.
     *
     * @param array<string,string> $post_raw
     */
    private static function verify_signature( array $post_raw ) : bool
    {
        // myPOS sends 'Signature' - accept lowercase too for safety.
        $signature_key = null;
        if ( isset( $post_raw['Signature'] ) ) {
            $signature_key = 'Signature';
        } elseif ( isset( $post_raw['signature'] ) ) {
            $signature_key = 'signature';
        }
        if ( $signature_key === null ) {
            return false;
        }
        $signature = (string) $post_raw[ $signature_key ];
        if ( $signature === '' ) {
            return false;
        }

        $mode = ConfigPage::get_mode();
        $pc   = ConfigPage::get_pack_field( $mode, 'pc' );
        if ( $pc === '' ) {
            Journal::error( 'Webhook: public certificate missing from Pack' );
            return false;
        }

        // Concatenate all values (in their original order) except the
        // Signature field itself.
        $parts = [];
        foreach ( $post_raw as $key => $value ) {
            if ( $key === $signature_key ) {
                continue;
            }
            $parts[] = $value;
        }
        // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- required by myPOS signature scheme
        $conc = base64_encode( implode( '-', $parts ) );

        $pub = openssl_get_publickey( $pc );
        if ( ! $pub ) {
            Journal::error( 'Webhook: openssl_get_publickey() failed' );
            return false;
        }

        // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- required by myPOS signature scheme
        $decoded = base64_decode( $signature );
        $result  = openssl_verify( $conc, $decoded, $pub, OPENSSL_ALGO_SHA256 );

        return $result === 1;
    }

    /**
     * Terminate the request with a bare text response - myPOS expects a
     * plain "OK" for success and any other text for failure. Never returns.
     */
    private static function respond( int $status, string $body ) : void
    {
        status_header( $status );
        header( 'Content-Type: text/plain; charset=utf-8' );
        echo esc_html( $body );
        exit;
    }
}
