<?php

namespace Formatrica\Submission\Security;

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

use Formatrica\Submission\Exception\Submission_Exception;
use wpdb;

/**
 * Enforces rate limiting on form submissions.
 *
 * Uses an atomic per-window counter in `formatrica_rate_buckets`
 * (INSERT ... ON DUPLICATE KEY UPDATE), so concurrent requests can't
 * under-count the way the previous transient read/increment/write could
 * (F-02). The submission limiter is defence-in-depth, not a security gate, so
 * it FAILS OPEN: if a bucket can't be counted (DB error, or the table is
 * missing mid-upgrade) the submission is allowed rather than blocking all
 * traffic — the opposite of the draft limiter's fail-closed policy.
 */
final class Rate_Limiter
{
    private const KIND = 'submission_window';

    private wpdb $wpdb;
    private string $table;

    public function __construct(?wpdb $db = null)
    {
        global $wpdb;

        $this->wpdb  = $db ?? $wpdb;
        $this->table = $this->wpdb->prefix . 'formatrica_rate_buckets';
    }

    /**
     * Enforce rate limit for a specific form and IP address.
     *
     * @throws Submission_Exception When the fixed-window count exceeds the cap.
     */
    public function enforce(int $form_id, string $ip_address, int $max_requests, int $per_seconds): void
    {
        if ($ip_address === '' || $max_requests <= 0 || $per_seconds <= 0) {
            return;
        }

        $window = (int) floor(time() / $per_seconds);
        $key    = substr(hash('sha256', $form_id . '|' . $ip_address . '|' . $window), 0, 64);

        $count = $this->increment($key);

        if ($count === null) {
            // Fail open — see class docblock.
            do_action('formatrica_debug_log', 'Rate_Limiter: bucket increment failed; allowing submission (fail-open).');
            return;
        }

        $this->maybe_cleanup();

        if ($count > $max_requests) {
            throw new Submission_Exception(esc_html__('Too many submissions detected. Please slow down.', 'formatrica'), 429);
        }
    }

    /**
     * Atomically increment (or create) the bucket and return its new count, or
     * null when the counter is unavailable.
     */
    private function increment(string $key): ?int
    {
        // Suppress wpdb error output for the fail-open window (missing table mid
        // upgrade) so a transient bucket outage doesn't spam the debug log.
        $prior_suppress = $this->wpdb->suppress_errors(true);

        try {
            // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- table name derived from prefix; atomic counter
            $written = $this->wpdb->query(
                $this->wpdb->prepare(
                    "INSERT INTO {$this->table} (bucket_kind, bucket_key, count) VALUES (%s, %s, 1)"
                        . ' ON DUPLICATE KEY UPDATE count = count + 1',
                    self::KIND,
                    $key
                )
            );

            if ($written === false) {
                return null;
            }

            $count = $this->wpdb->get_var(
                $this->wpdb->prepare(
                    "SELECT count FROM {$this->table} WHERE bucket_kind = %s AND bucket_key = %s",
                    self::KIND,
                    $key
                )
            );
            // phpcs:enable

            return $count === null ? null : (int) $count;
        } catch (\Throwable $e) {
            return null;
        } finally {
            $this->wpdb->suppress_errors($prior_suppress);
        }
    }

    /**
     * Occasionally prune expired submission buckets so the table can't grow
     * unbounded. Best-effort; failures are ignored.
     */
    private function maybe_cleanup(): void
    {
        try {
            if (random_int(1, 50) !== 1) {
                return;
            }

            // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- table name derived from prefix; retention sweep
            $this->wpdb->query(
                $this->wpdb->prepare(
                    "DELETE FROM {$this->table} WHERE bucket_kind = %s AND created_at < %s",
                    self::KIND,
                    gmdate('Y-m-d H:i:s', time() - HOUR_IN_SECONDS)
                )
            );
            // phpcs:enable
        } catch (\Throwable $e) {
            // Best-effort cleanup — ignore failures.
        }
    }
}
