<?php
declare(strict_types=1);

namespace Instinctor\Core\Security;


if (!defined('ABSPATH')) exit;
use Instinctor\Core\Config;

/**
 * Spam — anti-spam utilities for Instinctor forms.
 *
 * Time-based honeypot: hidden field + submission timing.
 * Bots fill hidden fields instantly; this rejects submissions
 * faster than MIN_SUBMIT_SECS seconds.
 *
 * Usage in template:
 *   echo Spam::honeypotField();
 *
 * On submit:
 *   if (!Spam::validateHoneypot()) { // reject }
 *
 * No configuration required.
 */
class Spam
{
    private const HONEYPOT_FIELD  = '_instinctor_hp';
    private const TIMING_FIELD    = '_instinctor_ts';
    private const MIN_SUBMIT_SECS = 3;
    /* Matches the WP nonce lifetime (24h). Was 1h, which silently binned any
       message sent from a tab left open longer than that. */
    private const MAX_SUBMIT_SECS = 86400;
    private const HMAC_KEY_OPTION = 'instinctor_honeypot_secret';

    /* Outcomes of checkHoneypot(). Only the BOT_ ones may be answered with a
       fake success; the rest MUST be reported to the sender. */
    public const RESULT_OK              = 'ok';
    public const RESULT_BOT_HONEYPOT    = 'bot_honeypot';
    public const RESULT_BOT_TOO_FAST    = 'bot_too_fast';
    public const RESULT_TIMING_MISSING  = 'timing_missing';
    public const RESULT_TIMING_INVALID  = 'timing_invalid';
    public const RESULT_TIMING_STALE    = 'timing_stale';

    /**
     * Render hidden honeypot input + timing token inside a <form>.
     */
    public static function honeypotField(): string
    {
        $ts    = time();
        $token = self::signTimestamp($ts);

        return sprintf(
            '<div style="position:absolute;left:-9999px;top:-9999px;opacity:0;pointer-events:none;" aria-hidden="true">' .
            '<input type="text" name="%s" value="" tabindex="-1" autocomplete="off">' .
            '</div>' .
            '<input type="hidden" name="%s" value="%s">',
            esc_attr(self::HONEYPOT_FIELD),
            esc_attr(self::TIMING_FIELD),
            esc_attr($ts . ':' . $token)
        );
    }

    /**
     * Validate honeypot on form submission.
     * Returns false if submission looks like a bot.
     */
    public static function validateHoneypot(array $post = []): bool
    {
        return self::checkHoneypot($post) === self::RESULT_OK;
    }

    /**
     * Classify a submission instead of collapsing every failure to false.
     *
     * Callers answer a bot with a fake success so detection stays hidden, which
     * is right for a real bot and catastrophic for anything else: the visitor is
     * told "thanks, we'll get back to you" and the message is dropped with no
     * copy kept anywhere. Only RESULT_BOT_* are safe to swallow that way; every
     * other failure has to surface so the sender can retry. Sergei 2026-08-01,
     * after /talk-to-us was found discarding anything sent more than an hour
     * after page load, with zero submissions ever recorded.
     *
     * @return string one of the RESULT_* constants
     */
    public static function checkHoneypot(array $post = []): string
    {
        if (empty($post)) {
            $post = $_POST; // phpcs:ignore WordPress.Security.NonceVerification
        }

        // Filled hidden field: no human can reach it (off-screen, aria-hidden,
        // tabindex -1, autocomplete off). Unambiguous bot.
        if (!empty($post[self::HONEYPOT_FIELD])) {
            return self::RESULT_BOT_HONEYPOT;
        }

        $tsField = $post[self::TIMING_FIELD] ?? '';
        if (empty($tsField)) {
            return self::RESULT_TIMING_MISSING;
        }

        [$ts, $sig] = array_pad(explode(':', $tsField, 2), 2, '');

        if (!is_numeric($ts) || !hash_equals(self::signTimestamp((int) $ts), $sig)) {
            return self::RESULT_TIMING_INVALID;
        }

        $elapsed = time() - (int) $ts;

        // Faster than any human can read and fill the form.
        if ($elapsed < self::MIN_SUBMIT_SECS) {
            return self::RESULT_BOT_TOO_FAST;
        }

        // An old token means the tab sat open, NOT that the sender is a bot.
        // The window matches the WP nonce lifetime, so anything older is already
        // rejected (visibly) by the nonce check before it reaches here — this
        // branch is a backstop, and it must not be silent.
        if ($elapsed > self::MAX_SUBMIT_SECS) {
            return self::RESULT_TIMING_STALE;
        }

        return self::RESULT_OK;
    }

    private static function signTimestamp(int $ts): string
    {
        return substr(hash_hmac('sha256', (string) $ts, self::getHmacSecret()), 0, 16);
    }

    private static function getHmacSecret(): string
    {
        if (function_exists('get_option')) {
            $secret = get_option(self::HMAC_KEY_OPTION, '');
            if (empty($secret)) {
                $secret = bin2hex(random_bytes(32));
                update_option(self::HMAC_KEY_OPTION, $secret);
            }
            return $secret;
        }

        $secret = Config::get(self::HMAC_KEY_OPTION, '');
        if (empty($secret)) {
            $secret = bin2hex(random_bytes(32));
            Config::set(self::HMAC_KEY_OPTION, $secret);
        }
        return $secret;
    }
}
