<?php

declare(strict_types=1);

namespace ShieldX\Security;

defined('ABSPATH') || exit;

use ShieldX\Support\CryptoBox;
use ShieldX\Support\Json;

// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- SHIELD-X queries its own event table and, when opt-in is enabled, records bounded visitor request surfaces for security review.

final class UserProfiler
{
    public const COOKIE_NAME = 'shieldx_visitor';
    public const OPTION_NAME = 'shieldx_user_profiler_settings';
    public const CLEANUP_CRON_HOOK = 'shieldx_user_profiler_cleanup';
    private const COOKIE_TTL = 2592000; // 30 days, aligned with event retention.
    private const RETENTION_DAYS = 30;
    private const CLEANUP_INTERVAL_SECONDS = 3600;
    private const MAX_EVENTS = 100000;
    private const FRONTEND_GET_SAMPLE_RATE = 20;
    private const PROTECTED_IP_PREFIX = 'sxip:v1:';
    private const MAX_PROTECTED_USER_AGENT_BYTES = 240;
    private const INSERT_COLUMNS = [
        'visitor_id',
        'ip',
        'country_code',
        'browser',
        'user_agent',
        'method',
        'uri',
        'path',
        'query_string',
        'referer',
        'is_admin',
        'user_id',
        'visited_at',
    ];
    private const INSERT_FORMATS = [
        '%s',
        '%s',
        '%s',
        '%s',
        '%s',
        '%s',
        '%s',
        '%s',
        '%s',
        '%s',
        '%d',
        '%d',
        '%s',
    ];

    /** @var object|null */
    private $wpdb;

    /**
     * @var array<int,array<string,mixed>>
     */
    private $pendingRows = [];

    /** @var bool */
    private $shutdownRegistered = false;

    /** @var bool|null */
    private $tableExistsCache;

    /** @var bool */
    private static $cleanupHookRegistered = false;

    public function __construct($wpdb = null)
    {
        $this->wpdb = $wpdb;
    }

    public function __destruct()
    {
        $this->flush();
    }

    public static function fromWordPress(): self
    {
        global $wpdb;

        return new self($wpdb);
    }

    /**
     * Registers the independent cleanup callback. Core boot must call this on
     * every request so WordPress can dispatch the hook during cron requests.
     */
    public static function registerHooks(): void
    {
        if (!self::$cleanupHookRegistered && function_exists('add_action')) {
            add_action(self::CLEANUP_CRON_HOOK, [self::class, 'runScheduledCleanup']);
            self::$cleanupHookRegistered = true;
        }

        if (self::enabled()) {
            self::ensureCleanupScheduled();
        }
    }

    public static function runScheduledCleanup(): void
    {
        $profiler = self::fromWordPress();
        if (!self::enabled()) {
            $profiler->purge();
            self::clearCleanupSchedule();

            return;
        }

        $profiler->cleanup();
    }

    /**
     * @param array<string,mixed> $request
     */
    public function recordRequest(array $request): void
    {
        if (!$this->hasDatabase() || !$this->tableExists() || !self::enabled() || !$this->shouldTrack($request)) {
            return;
        }

        $visitorId = $this->visitorId();
        if ($visitorId === '') {
            return;
        }

        $parts = $this->splitUri((string) ($request['uri'] ?? ''));
        $path = $parts['path'] !== '' ? $parts['path'] : '/';
        $query = (string) ($request['query_string'] ?? '');

        if ($query === '') {
            $query = $parts['query'];
        }

        $redactedQuery = $this->redactQueryString($query);
        $uri = $this->truncate($this->rebuildUri($path, $redactedQuery), 900);

        if (!$this->shouldRecordSample($visitorId, $path, (string) ($request['method'] ?? 'GET'))) {
            return;
        }

        $this->pendingRows[] = [
            'visitor_id' => $visitorId,
            'ip' => $this->truncate((string) ($request['ip'] ?? 'unknown'), 45),
            'country_code' => $this->countryCode(),
            'browser' => $this->truncate((string) ($request['browser'] ?? 'Unknown'), 80),
            'user_agent' => $this->truncate((string) ($request['user_agent'] ?? ''), 500),
            'method' => $this->truncate(strtoupper((string) ($request['method'] ?? 'GET')), 10),
            'uri' => $uri,
            'path' => $this->truncate($path, 500),
            'query_string' => $this->truncate($redactedQuery, 1000),
            'referer' => $this->redactReferrer($this->serverText('HTTP_REFERER')),
            'is_admin' => $this->isAdminRequest($path) ? 1 : 0,
            'user_id' => function_exists('get_current_user_id') ? (int) get_current_user_id() : 0,
            'visited_at' => gmdate('Y-m-d H:i:s'),
        ];

        if (!$this->shutdownRegistered) {
            $this->shutdownRegistered = true;
            register_shutdown_function([$this, 'flush']);
        }

        if (count($this->pendingRows) >= 20) {
            $this->flush();
        }
    }

    public function flush(): void
    {
        if (!$this->hasDatabase() || !$this->tableExists()) {
            $this->pendingRows = [];

            return;
        }

        if ($this->pendingRows === []) {
            return;
        }

        $rows = $this->pendingRows;
        $this->pendingRows = [];

        if (count($rows) > 1 && $this->bulkInsertRows($rows)) {
            $this->cleanupIfDue();

            return;
        }

        foreach ($rows as $row) {
            $this->wpdb->insert($this->table(), $this->normalizedInsertRow($row));
        }

        $this->cleanupIfDue();
    }

    /**
     * @return array<string,mixed>
     */
    public function summary(int $profileLimit = 8, int $eventLimit = 30): array
    {
        if (!$this->hasDatabase()) {
            return $this->unavailableSummary('user_profiler_database_unavailable');
        }

        if (!$this->tableExists()) {
            return $this->unavailableSummary('user_profiler_table_missing');
        }

        $profileLimit = max(1, min(20, $profileLimit));
        $eventLimit = max(1, min(100, $eventLimit));
        $profiles = $this->rows($this->prepareTableSql(
            "SELECT visitor_id, ip, UPPER(COALESCE(NULLIF(country_code, ''), '')) AS country_code, browser, MAX(user_agent) AS user_agent, COUNT(*) AS visit_count,
                COUNT(DISTINCT path) AS unique_urls, MIN(visited_at) AS first_seen, MAX(visited_at) AS last_seen
             FROM %i
             GROUP BY visitor_id, ip, UPPER(COALESCE(NULLIF(country_code, ''), '')), browser
             ORDER BY last_seen DESC
             LIMIT %d",
            [$profileLimit]
        ));

        foreach ($profiles as &$profile) {
            $storageIp = (string) ($profile['ip'] ?? '');
            $profile = $this->revealSensitiveRow($profile);
            $profile['visit_count'] = (int) ($profile['visit_count'] ?? 0);
            $profile['unique_urls'] = (int) ($profile['unique_urls'] ?? 0);
            $profile['recent_events'] = $this->revealSensitiveRows($this->eventsForProfile(
                (string) ($profile['visitor_id'] ?? ''),
                $storageIp,
                (string) ($profile['browser'] ?? ''),
                (string) ($profile['country_code'] ?? ''),
                5
            ));
        }
        unset($profile);

        return [
            'available' => true,
            'enabled' => self::enabled(),
            'profiles' => $profiles,
            'recent_events' => $this->revealSensitiveRows($this->recentEvents($eventLimit)),
            'total_events' => (int) $this->wpdb->get_var($this->prepareTableSql('SELECT COUNT(*) FROM %i')),
            'tracked_profiles' => (int) $this->wpdb->get_var($this->prepareTableSql('SELECT COUNT(DISTINCT visitor_id) FROM %i')),
            'tracked_ips' => (int) $this->wpdb->get_var($this->prepareTableSql('SELECT COUNT(DISTINCT ip) FROM %i')),
            'retention_days' => self::RETENTION_DAYS,
            'cookie_ttl_days' => (int) (self::COOKIE_TTL / 86400),
            'schema_ready' => true,
            'schema_warning' => '',
            'sampling' => self::sampling(),
        ];
    }

    /**
     * @return array{rows:array<int,array<string,mixed>>,done:bool}
     */
    public function personalDataForUser(int $userId, int $page = 1, int $perPage = 100): array
    {
        if ($userId <= 0 || !$this->hasDatabase() || !$this->tableExists() || !method_exists($this->wpdb, 'get_results')) {
            return [
                'rows' => [],
                'done' => true,
            ];
        }

        $page = max(1, $page);
        $perPage = max(1, min(500, $perPage));
        $offset = ($page - 1) * $perPage;
        $rows = $this->rows($this->prepareTableSql(
            'SELECT id, visitor_id, ip, country_code, browser, user_agent, method, uri, path, query_string, referer, is_admin, user_id, visited_at
             FROM %i
             WHERE user_id = %d
             ORDER BY visited_at ASC, id ASC
             LIMIT %d OFFSET %d',
            [$userId, $perPage + 1, $offset]
        ));
        $done = count($rows) <= $perPage;
        if (!$done) {
            $rows = array_slice($rows, 0, $perPage);
        }

        return [
            'rows' => $this->revealSensitiveRows($rows),
            'done' => $done,
        ];
    }

    public function erasePersonalDataForUser(int $userId): int
    {
        if ($userId <= 0 || !$this->hasDatabase() || !$this->tableExists() || !method_exists($this->wpdb, 'update')) {
            return 0;
        }

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- WordPress privacy erasure anonymizes only rows linked to the requested user ID.
        $updated = $this->wpdb->update(
            $this->table(),
            [
                'visitor_id' => '',
                'ip' => '',
                'country_code' => '',
                'browser' => '',
                'user_agent' => '',
                'uri' => '',
                'path' => '',
                'query_string' => '',
                'referer' => '',
                'user_id' => 0,
            ],
            ['user_id' => $userId],
            ['%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%d'],
            ['%d']
        );

        return $updated === false ? 0 : max(0, (int) $updated);
    }

    /**
     * @return array<string,mixed>
     */
    public static function sampling(): array
    {
        return [
            'anonymous_frontend_get_rate' => self::FRONTEND_GET_SAMPLE_RATE,
            'anonymous_frontend_get_sampled' => self::FRONTEND_GET_SAMPLE_RATE > 1,
            // Kept explicit so the privacy disclosure can accurately describe
            // the current security-review behavior.
            'authenticated_frontend_recorded' => true,
            // There is intentionally no user-agent bot filter: known bots use
            // the same GET sampling policy, while non-GET requests are kept.
            'automated_user_agents_filtered' => false,
            'anonymous_non_get_sampled' => false,
            'counts_are_complete' => self::FRONTEND_GET_SAMPLE_RATE <= 1,
        ];
    }

    public static function normalizeVisitorId(string $value): string
    {
        $value = trim($value);

        return preg_match('/^sx_[a-f0-9]{32}$/', $value) === 1 ? $value : '';
    }

    /**
     * @return array<string,mixed>
     */
    public static function settings(): array
    {
        $settings = function_exists('get_option') ? get_option(self::OPTION_NAME, []) : [];
        $settings = is_array($settings) ? $settings : [];

        return [
            'enabled' => !empty($settings['enabled']),
            'retention_days' => self::RETENTION_DAYS,
            'cookie_ttl_days' => (int) (self::COOKIE_TTL / 86400),
            'cookie_name' => self::COOKIE_NAME,
            'sampling' => self::sampling(),
        ];
    }

    public static function enabled(): bool
    {
        $settings = self::settings();

        return !empty($settings['enabled']);
    }

    public static function saveSettings(bool $enabled): void
    {
        if (!function_exists('update_option')) {
            return;
        }

        update_option(self::OPTION_NAME, [
            'enabled' => $enabled,
            'retention_days' => self::RETENTION_DAYS,
            'cookie_name' => self::COOKIE_NAME,
        ], true);
        if (function_exists('wp_set_option_autoload')) {
            wp_set_option_autoload(self::OPTION_NAME, true);
        }

        self::registerHooks();
        if ($enabled) {
            return;
        }

        self::clearCleanupSchedule();
        if (function_exists('delete_transient')) {
            delete_transient('shieldx_user_profiler_cleanup_due');
        }

        self::fromWordPress()->purge();
    }

    /**
     * @param array<int,string> $dayKeys YYYY-MM-DD keys.
     * @return array<string,int>
     */
    public function dailyVisitCounts(array $dayKeys): array
    {
        $counts = [];
        foreach ($dayKeys as $dayKey) {
            $key = preg_match('/^\d{4}-\d{2}-\d{2}$/', (string) $dayKey) === 1 ? (string) $dayKey : '';
            if ($key !== '') {
                $counts[$key] = 0;
            }
        }

        if (!$this->hasDatabase() || empty($counts) || !method_exists($this->wpdb, 'prepare') || !$this->tableExists()) {
            return $counts;
        }

        $keys = array_keys($counts);
        $start = reset($keys) . ' 00:00:00';
        $rows = $this->rows($this->prepareTableSql(
            "SELECT DATE(visited_at) AS visit_day, COUNT(*) AS total
             FROM %i
             WHERE visited_at >= %s
             GROUP BY DATE(visited_at)",
            [$start]
        ));

        foreach ($rows as $row) {
            $day = (string) ($row['visit_day'] ?? '');
            if (isset($counts[$day])) {
                $counts[$day] = (int) ($row['total'] ?? 0);
            }
        }

        return $counts;
    }

    private function visitorId(): string
    {
        $existingCookie = '';
        if (isset($_COOKIE[self::COOKIE_NAME]) && is_scalar($_COOKIE[self::COOKIE_NAME])) {
            $rawCookie = function_exists('wp_unslash') ? wp_unslash($_COOKIE[self::COOKIE_NAME]) : $_COOKIE[self::COOKIE_NAME];
            $existingCookie = is_scalar($rawCookie) ? (string) $rawCookie : '';
        }

        $existing = $existingCookie !== ''
            ? self::normalizeVisitorCookie($existingCookie)
            : '';

        $visitorId = $existing !== '' ? $existing : $this->newVisitorId();

        if ($existing === '') {
            $this->setVisitorCookie($visitorId);
        }

        return $visitorId;
    }

    private function newVisitorId(): string
    {
        try {
            return 'sx_' . bin2hex(random_bytes(16));
        } catch (\Throwable $error) {
            return '';
        }
    }

    private function setVisitorCookie(string $visitorId): void
    {
        if ($visitorId === '') {
            return;
        }

        if (headers_sent()) {
            return;
        }

        $cookieValue = self::signedVisitorCookie($visitorId);
        $_COOKIE[self::COOKIE_NAME] = $cookieValue;
        $path = self::safeCookiePath();
        $secure = function_exists('is_ssl') ? is_ssl() : false;

        setcookie(self::COOKIE_NAME, $cookieValue, [
            'expires' => time() + self::COOKIE_TTL,
            'path' => $path,
            'secure' => $secure,
            'httponly' => true,
            'samesite' => 'Lax',
        ]);
    }

    private static function normalizeVisitorCookie(string $value): string
    {
        $value = trim($value);
        $parts = explode('.', $value, 2);
        if (count($parts) !== 2) {
            return '';
        }

        [$visitorId, $signature] = $parts;
        $visitorId = self::normalizeVisitorId($visitorId);
        if ($visitorId === '' || !preg_match('/^[a-f0-9]{64}$/', $signature)) {
            return '';
        }

        return hash_equals(self::visitorCookieSignature($visitorId), $signature) ? $visitorId : '';
    }

    private static function signedVisitorCookie(string $visitorId): string
    {
        $visitorId = self::normalizeVisitorId($visitorId);

        return $visitorId !== '' ? $visitorId . '.' . self::visitorCookieSignature($visitorId) : '';
    }

    private static function visitorCookieSignature(string $visitorId): string
    {
        $salt = function_exists('wp_salt') ? (string) wp_salt('auth') : '';

        return hash_hmac('sha256', $visitorId, $salt . '|shieldx_user_profiler_visitor');
    }

    private static function safeCookiePath(): string
    {
        $path = defined('COOKIEPATH') && COOKIEPATH ? (string) COOKIEPATH : '/';
        $path = preg_replace('/[\r\n\x00-\x1F\x7F]+/', '', $path);
        $path = is_string($path) ? trim($path) : '/';

        if ($path === '' || $path[0] !== '/' || strpos($path, '..') !== false || strpos($path, '\\') !== false) {
            return '/';
        }

        return $path;
    }

    /**
     * @param array<string,mixed> $request
     */
    private function shouldTrack(array $request): bool
    {
        $method = strtoupper((string) ($request['method'] ?? ''));
        if (!in_array($method, ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD'], true)) {
            return false;
        }

        $uri = (string) ($request['uri'] ?? '');
        $parts = $this->splitUri($uri);
        $path = strtolower($parts['path']);
        $assetPath = $path !== '/' ? rtrim($path, '/') : $path;

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

        return !preg_match('~\.(?:css|js|map|png|jpe?g|gif|webp|svg|ico|woff2?|ttf|eot|mp4|mp3|pdf|zip)$~i', $assetPath);
    }

    private function redactQueryString(string $query): string
    {
        if ($query === '') {
            return '';
        }

        $redacted = [];
        foreach (explode('&', $query) as $part) {
            if ($part === '') {
                $redacted[] = $part;
                continue;
            }

            [$rawKey, $rawValue] = array_pad(explode('=', $part, 2), 2, '');
            $key = rawurldecode(str_replace('+', ' ', $rawKey));
            if ($this->isSensitiveQueryKey($key)) {
                $redacted[] = $rawKey . '=redacted';
                continue;
            }

            $redacted[] = $rawValue === '' ? $rawKey : $rawKey . '=' . $rawValue;
        }

        return implode('&', $redacted);
    }

    private function isSensitiveQueryKey(string $key): bool
    {
        return preg_match('/(?:password|pass|pwd|secret|token|key|nonce|auth|session|cookie|code|signature|jwt|state|license|reset|shieldx_recovery)/i', $key) === 1;
    }

    private function isAdminRequest(string $path): bool
    {
        return $path === '/wp-admin'
            || strpos($path, '/wp-admin/') === 0
            || $path === '/wp-login.php';
    }

    private function shouldRecordSample(string $visitorId, string $path, string $method): bool
    {
        $loggedInUserId = function_exists('get_current_user_id') ? (int) get_current_user_id() : 0;
        if (self::FRONTEND_GET_SAMPLE_RATE <= 1 || strtoupper($method) !== 'GET' || $this->isAdminRequest($path) || $loggedInUserId > 0) {
            return true;
        }

        $bucket = gmdate('YmdHi');
        $hash = sprintf('%u', crc32($visitorId . '|' . $path . '|' . $bucket));

        return ((int) $hash % self::FRONTEND_GET_SAMPLE_RATE) === 0;
    }

    /**
     * @return array<int,array<string,mixed>>
     */
    private function eventsForProfile(string $visitorId, string $ip, string $browser, string $countryCode, int $limit): array
    {
        if ($visitorId === '' || $ip === '' || $browser === '') {
            return [];
        }

        return $this->rows($this->prepareTableSql(
            "SELECT visitor_id, ip, country_code, browser, user_agent, method, uri, path, query_string, referer, is_admin, user_id, visited_at
             FROM %i
             WHERE visitor_id = %s AND ip = %s AND browser = %s AND UPPER(COALESCE(NULLIF(country_code, ''), '')) = %s
             ORDER BY visited_at DESC
             LIMIT %d",
            [$visitorId, $ip, $browser, strtoupper($countryCode), max(1, min(20, $limit))]
        ));
    }

    /**
     * @return array<int,array<string,mixed>>
     */
    private function recentEvents(int $limit): array
    {
        return $this->rows($this->prepareTableSql(
            "SELECT visitor_id, ip, country_code, browser, user_agent, method, uri, path, query_string, referer, is_admin, user_id, visited_at
             FROM %i
             ORDER BY visited_at DESC
             LIMIT %d",
            [$limit]
        ));
    }

    private function cleanup(): void
    {
        if (!$this->hasDatabase() || !$this->tableExists() || !method_exists($this->wpdb, 'query') || !method_exists($this->wpdb, 'get_var')) {
            return;
        }

        $cutoff = gmdate('Y-m-d H:i:s', time() - (self::RETENTION_DAYS * 86400));
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Prepared retention cleanup deletes rows from the private SHIELD-X event table; caching is not applicable.
        $this->wpdb->query($this->prepareTableSql('DELETE FROM %i WHERE visited_at < %s', [$cutoff]));

        $count = (int) $this->wpdb->get_var($this->prepareTableSql('SELECT COUNT(*) FROM %i'));
        if ($count <= self::MAX_EVENTS) {
            return;
        }

        $overflow = max(1, $count - self::MAX_EVENTS);
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Prepared, bounded overflow cleanup deletes rows from the private SHIELD-X event table; caching is not applicable.
        $this->wpdb->query($this->prepareTableSql('DELETE FROM %i ORDER BY visited_at ASC, id ASC LIMIT %d', [$overflow]));
    }

    private function cleanupIfDue(): void
    {
        $key = 'shieldx_user_profiler_cleanup_due';
        if (function_exists('get_transient') && function_exists('set_transient')) {
            if (get_transient($key)) {
                return;
            }

            set_transient($key, '1', self::CLEANUP_INTERVAL_SECONDS);
        }

        $this->cleanup();
    }

    private function purge(): void
    {
        if (!$this->hasDatabase() || !$this->tableExists() || !method_exists($this->wpdb, 'query')) {
            return;
        }

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Explicit opt-out purge removes all rows from the private SHIELD-X event table.
        $this->wpdb->query($this->prepareTableSql('DELETE FROM %i'));
    }

    /**
     * @return array<int,array<string,mixed>>
     */
    private function rows(string $sql): array
    {
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Callers supply prepared, bounded SELECTs against the private SHIELD-X event table; request-time profiling needs current results rather than an object cache.
        $rows = $this->wpdb->get_results($sql, defined('ARRAY_A') ? ARRAY_A : 'ARRAY_A');

        return is_array($rows) ? $rows : [];
    }

    private function hasDatabase(): bool
    {
        return is_object($this->wpdb) && isset($this->wpdb->prefix);
    }

    /**
     * @return array<string,mixed>
     */
    private function unavailableSummary(string $schemaWarning): array
    {
        return [
            'available' => false,
            'enabled' => self::enabled(),
            'profiles' => [],
            'recent_events' => [],
            'total_events' => 0,
            'tracked_profiles' => 0,
            'tracked_ips' => 0,
            'retention_days' => self::RETENTION_DAYS,
            'cookie_ttl_days' => (int) (self::COOKIE_TTL / 86400),
            'schema_ready' => false,
            'schema_warning' => $schemaWarning,
            'sampling' => self::sampling(),
        ];
    }

    private function table(): string
    {
        return (string) $this->wpdb->prefix . 'shieldx_user_events';
    }

    /**
     * @param array<int,array<string,mixed>> $rows
     */
    private function bulkInsertRows(array $rows): bool
    {
        if (!method_exists($this->wpdb, 'query') || !method_exists($this->wpdb, 'prepare')) {
            return false;
        }

        $sql = $this->bulkInsertSql($rows);
        if ($sql === '') {
            return false;
        }

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- bulkInsertSql() prepares a bounded multi-row INSERT for the private SHIELD-X event table; caching writes is not applicable.
        return $this->wpdb->query($sql) !== false;
    }

    /**
     * @param array<int,array<string,mixed>> $rows
     */
    private function bulkInsertSql(array $rows): string
    {
        $valuesSql = [];
        $args = [];
        $rowPlaceholder = '(' . implode(', ', self::INSERT_FORMATS) . ')';

        foreach ($rows as $row) {
            $valuesSql[] = $rowPlaceholder;
            $normalized = $this->normalizedInsertRow($row);
            foreach (self::INSERT_COLUMNS as $column) {
                $args[] = $normalized[$column];
            }
        }

        if ($valuesSql === []) {
            return '';
        }

        $columns = [];
        foreach (self::INSERT_COLUMNS as $column) {
            $columns[] = '`' . $column . '`';
        }

        return $this->prepareTableSql(
            'INSERT INTO %i (' . implode(', ', $columns) . ') VALUES ' . implode(', ', $valuesSql),
            $args
        );
    }

    /**
     * @param array<string,mixed> $row
     * @return array<string,mixed>
     */
    private function normalizedInsertRow(array $row): array
    {
        $normalized = [];

        foreach (self::INSERT_COLUMNS as $column) {
            $value = $row[$column] ?? '';
            if ($column === 'is_admin' || $column === 'user_id') {
                $normalized[$column] = max(0, (int) $value);
                continue;
            }

            $normalized[$column] = (string) $value;
        }

        return $this->protectSensitiveRow($normalized);
    }

    /**
     * Store a stable, non-reversible IP lookup key and encrypt the raw IP plus
     * user-agent together. The existing columns therefore need no schema change
     * and profiler grouping remains efficient.
     *
     * @param array<string,mixed> $row
     * @return array<string,mixed>
     */
    private function protectSensitiveRow(array $row): array
    {
        $storedIp = (string) ($row['ip'] ?? '');
        if ($this->isProtectedIp($storedIp)) {
            return $row;
        }

        $ip = $this->truncate($storedIp, 45);
        $userAgent = $this->truncate((string) ($row['user_agent'] ?? ''), self::MAX_PROTECTED_USER_AGENT_BYTES);
        $row['ip'] = $this->protectedIpKey($ip);
        $row['user_agent'] = $this->sealedSensitivePayload($ip, $userAgent);

        return $row;
    }

    /**
     * @param array<string,mixed> $row
     * @return array<string,mixed>
     */
    private function revealSensitiveRow(array $row): array
    {
        $storedIp = (string) ($row['ip'] ?? '');
        if (!$this->isProtectedIp($storedIp)) {
            $row['ip'] = '';
            $row['user_agent'] = '';
            return $row;
        }

        $payload = $this->unsealSensitivePayload((string) ($row['user_agent'] ?? ''));
        if ($payload === []) {
            $row['ip'] = '';
            $row['user_agent'] = '';

            return $row;
        }

        $row['ip'] = (string) ($payload['ip'] ?? '');
        $row['user_agent'] = (string) ($payload['user_agent'] ?? '');

        return $row;
    }

    /**
     * @param array<int,array<string,mixed>> $rows
     * @return array<int,array<string,mixed>>
     */
    private function revealSensitiveRows(array $rows): array
    {
        foreach ($rows as $index => $row) {
            if (is_array($row)) {
                $rows[$index] = $this->revealSensitiveRow($row);
            }
        }

        return $rows;
    }

    private function isProtectedIp(string $value): bool
    {
        return strpos($value, self::PROTECTED_IP_PREFIX) === 0;
    }

    private function protectedIpKey(string $ip): string
    {
        $key = $this->sensitiveStorageKey();
        $digest = hash_hmac('sha256', $ip, $key !== '' ? $key : 'shieldx-profiler-without-wordpress-salt', true);
        $encoded = rtrim(strtr(base64_encode($digest), '+/', '-_'), '=');

        return self::PROTECTED_IP_PREFIX . substr($encoded, 0, 37);
    }

    private function sealedSensitivePayload(string $ip, string $userAgent): string
    {
        $key = $this->sensitiveStorageKey();
        if ($key === '' || !CryptoBox::isAvailable()) {
            // Never retain raw values merely because encryption is unavailable.
            return '';
        }

        for ($limit = strlen($userAgent); $limit >= 0; $limit -= 16) {
            $payload = wp_json_encode([
                'ip' => $ip,
                'user_agent' => substr($userAgent, 0, $limit),
            ], JSON_UNESCAPED_SLASHES);
            if (!is_string($payload) || $payload === '') {
                return '';
            }

            $sealed = CryptoBox::protect($payload, $key);
            if ($sealed !== '' && strlen($sealed) <= 500) {
                return $sealed;
            }
        }

        return '';
    }

    /**
     * @return array<string,string>
     */
    private function unsealSensitivePayload(string $stored): array
    {
        $key = $this->sensitiveStorageKey();
        if ($key === '' || !CryptoBox::isV2($stored)) {
            return [];
        }

        $json = CryptoBox::reveal($stored, $key);
        $decoded = $json !== '' ? Json::decodeArray($json) : null;
        if (!is_array($decoded)) {
            return [];
        }

        return [
            'ip' => $this->truncate((string) ($decoded['ip'] ?? ''), 45),
            'user_agent' => $this->truncate((string) ($decoded['user_agent'] ?? ''), self::MAX_PROTECTED_USER_AGENT_BYTES),
        ];
    }

    private function sensitiveStorageKey(): string
    {
        if (!function_exists('wp_salt')) {
            return '';
        }

        $key = (string) wp_salt('secure_auth');
        if ($key === '') {
            $key = (string) wp_salt('auth');
        }

        return $key;
    }

    /**
     * @param array<int,mixed> $args
     */
    private function prepareTableSql(string $sql, array $args = []): string
    {
        $table = $this->table();
        if ($this->supportsIdentifierPlaceholders()) {
            $prepared = $this->wpdb->prepare($sql, $table, ...$args);

            return is_string($prepared) ? $prepared : '';
        }

        $quoted = $this->quoteIdentifier($table);
        if ($quoted === '') {
            return '';
        }

        $sql = str_replace('%i', $quoted, $sql);
        if (empty($args)) {
            return $sql;
        }

        $prepared = $this->wpdb->prepare($sql, ...$args);

        return is_string($prepared) ? $prepared : '';
    }

    private function supportsIdentifierPlaceholders(): bool
    {
        return method_exists($this->wpdb, 'has_cap') && (bool) $this->wpdb->has_cap('identifier_placeholders');
    }

    private function quoteIdentifier(string $identifier): string
    {
        if (preg_match('/^[A-Za-z0-9_]+$/', $identifier) !== 1) {
            return '';
        }

        return '`' . str_replace('`', '``', $identifier) . '`';
    }

    private function tableExists(): bool
    {
        if ($this->tableExistsCache !== null) {
            return $this->tableExistsCache;
        }

        if (!method_exists($this->wpdb, 'get_var') || !method_exists($this->wpdb, 'prepare')) {
            $this->tableExistsCache = false;

            return false;
        }

        $table = $this->table();
        $found = $this->wpdb->get_var($this->wpdb->prepare('SHOW TABLES LIKE %s', $table));

        $this->tableExistsCache = (string) $found === $table;

        return $this->tableExistsCache;
    }

    private function countryCode(): string
    {
        if ($this->hasTrustedProxyCountryHeaders()) {
            foreach (['HTTP_CF_IPCOUNTRY', 'HTTP_X_COUNTRY_CODE', 'HTTP_X_GEOIP_COUNTRY_CODE'] as $header) {
                $country = $this->validCountryCode($this->serverText($header));
                if ($country !== '') {
                    return $country;
                }
            }
        }

        // This value is supplied by a server-side GeoIP integration rather
        // than an HTTP request header, so it has a different trust boundary.
        return $this->validCountryCode($this->serverText('GEOIP_COUNTRY_CODE'));
    }

    private function hasTrustedProxyCountryHeaders(): bool
    {
        if (!class_exists(ClientIpResolver::class) || !class_exists(WafSettings::class)) {
            return false;
        }

        return ClientIpResolver::isTrustedProxy($_SERVER, WafSettings::fromWordPress());
    }

    private function validCountryCode(string $country): string
    {
        $country = strtoupper(trim($country));

        return preg_match('/^[A-Z]{2}$/', $country) === 1 && $country !== 'XX' ? $country : '';
    }

    private function truncate(string $value, int $limit): string
    {
        return strlen($value) > $limit ? substr($value, 0, $limit) : $value;
    }

    /**
     * @return array{path:string,query:string}
     */
    private function splitUri(string $uri): array
    {
        $withoutFragment = explode('#', $uri, 2)[0];
        $path = $withoutFragment;
        $query = '';

        if (strpos($withoutFragment, '?') !== false) {
            [$path, $query] = explode('?', $withoutFragment, 2);
        }

        if (preg_match('~^https?://[^/]+(?P<path>/.*)?$~i', $path, $matches)) {
            $path = isset($matches['path']) && $matches['path'] !== '' ? $matches['path'] : '/';
        }

        return [
            'path' => $path !== '' ? $path : '/',
            'query' => $query,
        ];
    }

    private function rebuildUri(string $path, string $query): string
    {
        $path = $path !== '' ? $path : '/';

        return $query === '' ? $path : $path . '?' . $query;
    }

    private function redactReferrer(string $referer): string
    {
        if ($referer === '') {
            return '';
        }

        $parts = $this->splitUri($referer);
        $origin = $this->httpOrigin($referer);
        $redacted = $origin . $this->rebuildUri($parts['path'], $this->redactQueryString($parts['query']));

        return $this->truncate($redacted, 500);
    }

    private function httpOrigin(string $uri): string
    {
        try {
            if (!function_exists('wp_parse_url')) {
                return '';
            }

            $parsed = wp_parse_url($uri);
        } catch (\Throwable $error) {
            return '';
        }

        if (!is_array($parsed)) {
            return '';
        }

        $scheme = strtolower((string) ($parsed['scheme'] ?? ''));
        $host = (string) ($parsed['host'] ?? '');
        if (!in_array($scheme, ['http', 'https'], true) || $host === '' || preg_match('/[\s\\/?#@]/', $host) === 1) {
            return '';
        }

        $port = isset($parsed['port']) ? (int) $parsed['port'] : 0;
        if ($port < 0 || $port > 65535) {
            return '';
        }

        if (strpos($host, ':') !== false) {
            $host = '[' . $host . ']';
        }

        return $scheme . '://' . $host . ($port > 0 ? ':' . $port : '');
    }

    private static function ensureCleanupScheduled(): void
    {
        if (!function_exists('wp_next_scheduled') || !function_exists('wp_schedule_event') || wp_next_scheduled(self::CLEANUP_CRON_HOOK)) {
            return;
        }

        wp_schedule_event(time() + self::CLEANUP_INTERVAL_SECONDS, 'daily', self::CLEANUP_CRON_HOOK);
    }

    private static function clearCleanupSchedule(): void
    {
        if (function_exists('wp_clear_scheduled_hook')) {
            wp_clear_scheduled_hook(self::CLEANUP_CRON_HOOK);
        }
    }

    private function serverText(string $key): string
    {
        if (!isset($_SERVER[$key]) || !is_scalar($_SERVER[$key])) {
            return '';
        }

        $value = function_exists('wp_unslash') ? wp_unslash($_SERVER[$key]) : $_SERVER[$key];
        $value = is_scalar($value) ? (string) $value : '';

        return function_exists('sanitize_text_field') ? sanitize_text_field($value) : trim($value);
    }

}
