<?php
/**
 * Receives the submitted declaration.
 *
 * @package SkyServers\Retragere
 */

declare(strict_types=1);

namespace SkyServers\Retragere\Wp;

use SkyServers\Retragere\Core\ClientIp;
use SkyServers\Retragere\Core\LineSelection;
use SkyServers\Retragere\Core\ReferenceToken;
use SkyServers\Retragere\Core\RequestLine;
use SkyServers\Retragere\Core\RequestValidator;
use SkyServers\Retragere\Core\WithdrawalRequest;
use SkyServers\Retragere\Wc\Bootstrap;
use SkyServers\Retragere\Wc\GuestAccess;
use SkyServers\Retragere\Wc\OrderEvaluation;
use SkyServers\Retragere\Wc\OrderItems;
use SkyServers\Retragere\Wc\OrderLookup;
use SkyServers\Retragere\Wc\OrderNote;

/**
 * The record is written before any mail is attempted. A failed send must never
 * erase the trace of a valid withdrawal.
 */
final class FormHandler
{
    private const RATE_LIMIT  = 5;
    private const RATE_WINDOW = 3600;

    public static function register(): void
    {
        add_action('admin_post_nopriv_ssrc_submit', [self::class, 'handle']);
        add_action('admin_post_ssrc_submit', [self::class, 'handle']);
        add_action('wp_ajax_nopriv_ssrc_nonce', [self::class, 'nonce']);
        add_action('wp_ajax_ssrc_nonce', [self::class, 'nonce']);
    }

    /**
     * Serves a fresh nonce to a page that may have come from a cache.
     */
    public static function nonce(): void
    {
        header('Content-Type: text/plain; charset=utf-8');
        echo esc_html(wp_create_nonce('ssrc_submit'));
        wp_die();
    }

    public static function handle(): void
    {
        $pageUrl = self::pageUrl();

        $nonce = isset($_POST['ssrc_nonce']) ? sanitize_text_field(wp_unslash((string) $_POST['ssrc_nonce'])) : '';
        if (!wp_verify_nonce($nonce, 'ssrc_submit')) {
            self::redirect($pageUrl, ['nonce']);
        }

        $ip = ClientIp::resolve($_SERVER);
        if (self::rateLimited($ip)) {
            self::redirect($pageUrl, ['rate']);
        }

        // Unslashed here, sanitised by RequestValidator::clean() - it strips
        // markup, collapses whitespace and enforces length. The sniff looks for
        // a WordPress sanitiser on this line and cannot follow the call.
        // phpcs:disable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- sanitised in RequestValidator.
        $validator = new RequestValidator();
        $result    = $validator->validate(
            [
                'name'         => isset($_POST['name']) ? wp_unslash((string) $_POST['name']) : '',
                'email'        => isset($_POST['email']) ? wp_unslash((string) $_POST['email']) : '',
                'contract_ref' => isset($_POST['contract_ref']) ? wp_unslash((string) $_POST['contract_ref']) : '',
                'reason'       => isset($_POST['reason']) ? wp_unslash((string) $_POST['reason']) : '',
                'hp'           => isset($_POST['hp']) ? wp_unslash((string) $_POST['hp']) : '',
            ]
        );
        // phpcs:enable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized

        if (!$result['valid']) {
            self::redirect($pageUrl, array_keys($result['errors']));
        }

        $resolved = self::resolveOrder($result['clean']);
        $request  = WithdrawalRequest::create(
            $result['clean'],
            $resolved['lines'],
            $resolved['order_id'],
            $resolved['total_qty']
        );
        $token    = ReferenceToken::generate();

        $userAgent = isset($_SERVER['HTTP_USER_AGENT'])
            ? sanitize_text_field(wp_unslash((string) $_SERVER['HTTP_USER_AGENT']))
            : '';

        $id = Repository::save(
            $request,
            [
                'token_hash'    => ReferenceToken::hash($token),
                'ip'            => $ip,
                'user_agent'    => $userAgent,
                'snapshot'      => array_merge(
                    Settings::snapshot(),
                    ['payment_method' => $resolved['payment_method']]
                ),
                'customer_user' => get_current_user_id(),
                'b2b_signal'    => $resolved['b2b_signal'],
                'submitted_gmt' => gmdate('Y-m-d H:i:s'),
            ]
        );

        if (0 === $id) {
            self::redirect($pageUrl, ['nonce']);
        }

        if ($resolved['order_id'] > 0) {
            OrderNote::add($resolved['order_id'], Repository::reference($id), $request->type());
        }

        Mailer::send($id);

        self::redirect(add_query_arg('ssrc_ref', rawurlencode(Repository::reference($id)), $pageUrl), []);
    }

    /**
     * Attaches an order when one can be matched, and never fails if it cannot.
     * A declaration without an order is still a valid declaration.
     *
     * @param array<string,string> $clean
     *
     * @return array{order_id:int,lines:array,total_qty:int}
     */
    private static function resolveOrder(array $clean): array
    {
        $empty = ['order_id' => 0, 'lines' => [], 'total_qty' => 0, 'payment_method' => '', 'b2b_signal' => ''];

        if (!Bootstrap::isActive()) {
            return $empty;
        }

        $order = null;

        // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce verified by the caller.
        $picked = isset($_POST['ssrc_order']) ? (int) $_POST['ssrc_order'] : 0;
        if ($picked > 0 && is_user_logged_in()) {
            $candidate = OrderLookup::byId($picked);

            if (null !== $candidate && (int) $candidate->get_customer_id() === get_current_user_id()) {
                $order = $candidate;
            }
        }

        if (null === $order) {
            $order = GuestAccess::match($clean['contract_ref'], $clean['email']);
        }

        if (null === $order) {
            return $empty;
        }

        $evaluation = OrderEvaluation::forOrder($order);

        if (!$evaluation['eligible']) {
            return $empty;
        }

        $posted = [];

        // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce verified by the caller.
        if (isset($_POST['ssrc_lines']) && is_array($_POST['ssrc_lines'])) {
            // Cast to int on the next line, which is the only sanitising a
            // quantity map can take.
            // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce verified by the caller, values cast to int below.
            foreach (wp_unslash($_POST['ssrc_lines']) as $itemId => $qty) {
                $posted[(int) $itemId] = (int) $qty;
            }
        }

        // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce verified by the caller.
        $pickerShown = !empty($_POST['ssrc_lines_present']);

        // Nothing arrived and no picker was ever rendered - the customer came
        // from a context without one. Take the whole order rather than record
        // an order-less declaration.
        if ([] === $posted && !$pickerShown) {
            foreach ($evaluation['lines'] as $line) {
                if (true === ($line['eligible'] ?? false)) {
                    $posted[(int) $line['item_id']] = (int) $line['available'];
                }
            }
        }

        $selected = LineSelection::resolve($evaluation['lines'], $posted);

        // The picker was shown and the customer unticked everything. That is a
        // mistake worth pointing out, not a silent free declaration.
        if ([] === $selected && $pickerShown) {
            self::redirect(self::pageUrl(), ['lines']);
        }

        if ([] === $selected) {
            return $empty;
        }

        $lines = [];
        foreach ($selected as $line) {
            $lines[] = new RequestLine(
                (int) $line['item_id'],
                (int) $line['product_id'],
                (int) $line['variation_id'],
                (string) $line['name'],
                (int) $line['qty'],
                (string) $line['sku']
            );
        }

        return [
            'order_id'       => (int) $order->get_id(),
            'lines'          => $lines,
            'total_qty'      => OrderItems::totalQty($order),
            'payment_method' => (string) $order->get_payment_method_title(),
            'b2b_signal'     => (string) $evaluation['b2b_signal'],
        ];
    }

    private static function rateLimited(string $ip): bool
    {
        // The limit exists to stop anonymous abuse, not to get in the way of
        // the shop owner testing their own compliance function. Someone who can
        // manage the store can already do far worse than file withdrawals.
        if (current_user_can('manage_woocommerce') || current_user_can('manage_options')) {
            return false;
        }

        if ('' === $ip) {
            return false;
        }

        $key   = 'ssrc_rl_' . md5($ip);
        $count = (int) get_transient($key);

        if ($count >= self::RATE_LIMIT) {
            return true;
        }

        set_transient($key, $count + 1, self::RATE_WINDOW);

        return false;
    }

    private static function pageUrl(): string
    {
        $pageId = (int) Settings::get('page_id');
        $url    = $pageId > 0 ? get_permalink($pageId) : '';

        return is_string($url) && '' !== $url ? $url : home_url('/');
    }

    /**
     * @param string[] $errors
     */
    private static function redirect(string $url, array $errors): void
    {
        if ([] !== $errors) {
            $url = add_query_arg('ssrc_err', rawurlencode(implode(',', $errors)), $url);
        }

        wp_safe_redirect($url);
        exit;
    }
}
