<?php

namespace WPProofShield\Security;

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


use WPProofShield\Support\Settings;

final class StatsStore {

	private ?array $geo_cache = null;

	public function __construct( private Settings $settings ) {
	}

	public function record( string $context, string $resultCode, int $difficulty, ?int $solveMs = null ): void {
		if ( ! $this->settings->isEnabled( 'logging_enabled' ) ) {
			return;
		}

		$tracked_ip = $this->settings->isEnabled( 'ip_tracing_enabled' ) ? $this->trackedIpForStats() : '';

		$stats = get_option(
			WPSH_STATS_KEY,
			array(
				'totals'     => array(
					'ok'      => 0,
					'blocked' => 0,
				),
				'by_context' => array(),
				'solve_ms'   => array(),
				'events'     => array(),
			)
		);
		$stats = $this->normalizeStoredEventIpData( $stats );

		$isBlocked = str_starts_with( $resultCode, 'wpsh_' ) && $resultCode !== 'ok';
		++$stats['totals'][ $isBlocked ? 'blocked' : 'ok' ];

		if ( ! isset( $stats['by_context'][ $context ] ) ) {
			$stats['by_context'][ $context ] = array(
				'ok'      => 0,
				'blocked' => 0,
			);
		}
		++$stats['by_context'][ $context ][ $isBlocked ? 'blocked' : 'ok' ];

		if ( $solveMs !== null ) {
			$stats['solve_ms'][] = max( 0, $solveMs );
			if ( count( $stats['solve_ms'] ) > 500 ) {
				$stats['solve_ms'] = array_slice( $stats['solve_ms'], -500 );
			}
		}

		$stats['events'][] = array(
			't'        => time(),
			'ctx'      => $context,
			'result'   => $resultCode,
			'diff'     => $difficulty,
			'solve_ms' => $solveMs !== null ? max( 0, $solveMs ) : null,
			'ip'       => $this->hashIp(),
			// Store only anonymized IPs for geo/statistics view (no raw IP persistence).
			'ip_raw'   => $tracked_ip,
		);
		if ( count( $stats['events'] ) > 2000 ) {
			$stats['events'] = array_slice( $stats['events'], -2000 );
		}

		update_option( WPSH_STATS_KEY, $stats, false );
		if ( $tracked_ip !== '' ) {
			$this->enqueueGeoLookup( $tracked_ip );
		}
	}

	public function medianSolveMs(): ?int {
		$stats = get_option( WPSH_STATS_KEY, array() );
		$times = $stats['solve_ms'] ?? array();
		if ( ! $times ) {
			return null;
		}
		sort( $times );
		return (int) $times[ (int) floor( count( $times ) / 2 ) ];
	}

	public function getSummary(): array {
		$stats = get_option( WPSH_STATS_KEY, array() );
		return array(
			'totals'          => $stats['totals'] ?? array(
				'ok'      => 0,
				'blocked' => 0,
			),
			'by_context'      => $stats['by_context'] ?? array(),
			'median_solve_ms' => $this->medianSolveMs(),
		);
	}

	public function getDashboard( string $range = '24h' ): array {

		$stats           = get_option( WPSH_STATS_KEY, array() );
		$stats           = $this->normalizeStoredEventIpData( $stats );
		$events          = isset( $stats['events'] ) && is_array( $stats['events'] ) ? $stats['events'] : array();
		$events          = $this->normalizeLegacyNoJsEvents( $events );
		$normalizedRange = in_array( $range, array( '24h', '7d', '30d' ), true ) ? $range : '24h';
		$summary         = $this->buildRangeSummary( $events, $normalizedRange );
		$buckets         = $this->buildBuckets( $events, $normalizedRange );
		$recent          = array(
			'ok'      => 0,
			'blocked' => 0,
		);
		foreach ( $buckets as $bucket ) {
			$recent['ok']      += (int) $bucket['ok'];
			$recent['blocked'] += (int) $bucket['blocked'];
		}

		$reasons = $this->buildReasonCounts( $events, $normalizedRange );
		arsort( $reasons );
		$reasons = array_slice( $reasons, 0, 8, true );
		$ipStats = $this->buildIpStats( $events, $normalizedRange );

		$topContext      = '-';
		$topContextCount = 0;
		foreach ( ( $summary['by_context'] ?? array() ) as $context => $row ) {
			$count = (int) ( $row['ok'] ?? 0 ) + (int) ( $row['blocked'] ?? 0 );
			if ( $count > $topContextCount ) {
				$topContextCount = $count;
				$topContext      = (string) $context;
			}
		}

		$total        = (int) ( $summary['totals']['ok'] ?? 0 ) + (int) ( $summary['totals']['blocked'] ?? 0 );
		$blocked      = (int) ( $summary['totals']['blocked'] ?? 0 );
		$blockedRate  = $total > 0 ? (int) round( ( $blocked / $total ) * 100 ) : 0;
		$contactForms = $this->buildContactFormsSummary( $summary );

		return array(
			'summary'              => $summary,
			'contact_forms'        => $contactForms,
			'recent_24h'           => $recent,
			'range'                => $normalizedRange,
			'buckets'              => $buckets,
			'top_context'          => $topContext,
			'blocked_rate_percent' => $blockedRate,
			'reasons'              => $reasons,
			'ip_stats'             => $ipStats,
			'ip_tracing_enabled'   => $this->settings->isEnabled( 'ip_tracing_enabled' ),
			'private_ip_events'    => $this->countPrivateIpEvents( $ipStats ),
		);
	}

	/**
	 * @param array<int,array<string,mixed>> $events
	 * @return array<int,array<string,mixed>>
	 */
	private function normalizeLegacyNoJsEvents( array $events ): array {

		foreach ( $events as $idx => $event ) {
			if ( ! is_array( $event ) ) {
				continue;
			}
			$ctx    = isset( $event['ctx'] ) ? (string) $event['ctx'] : '';
			$result = isset( $event['result'] ) ? (string) $event['result'] : '';
			if ( $ctx === 'form:no_js_view' && $result === 'ok' ) {
				$events[ $idx ]['result'] = 'wpsh_js_required';
			}
		}

		return $events;
	}
	public function suggestedDifficulty( string $context, int $baseDifficulty ): int {
		$base      = max( 2, min( 6, $baseDifficulty ) );
		$stats     = get_option( WPSH_STATS_KEY, array() );
		$events    = isset( $stats['events'] ) && is_array( $stats['events'] ) ? $stats['events'] : array();
		$threshold = time() - HOUR_IN_SECONDS;

		$total   = 0;
		$blocked = 0;
		foreach ( $events as $event ) {
			if ( (int) ( $event['t'] ?? 0 ) < $threshold ) {
				continue;
			}
			if ( (string) ( $event['ctx'] ?? '' ) !== $context ) {
				continue;
			}

			++$total;
			$result = (string) ( $event['result'] ?? '' );
			if ( str_starts_with( $result, 'wpsh_' ) && $result !== 'ok' ) {
				++$blocked;
			}
		}

		if ( $total < 15 ) {
			return $base;
		}

		$rate = ( $blocked / $total ) * 100;
		if ( $rate >= 60 ) {
			return min( 6, $base + 1 );
		}
		if ( $rate <= 15 ) {
			return max( 2, $base - 1 );
		}

		return $base;
	}

	public function cleanup(): void {
		$stats = get_option( WPSH_STATS_KEY, null );
		if ( ! is_array( $stats ) || ! isset( $stats['events'] ) || ! is_array( $stats['events'] ) ) {
			return;
		}

		$threshold       = time() - ( 30 * DAY_IN_SECONDS );
		$stats['events'] = array_values(
			array_filter(
				$stats['events'],
				static fn ( array $event ): bool => (int) ( $event['t'] ?? 0 ) >= $threshold
			)
		);
		update_option( WPSH_STATS_KEY, $stats, false );
	}

	public function processGeoQueue(): void {
		$queue = get_option( 'wpsh_geo_queue', array() );
		if ( ! is_array( $queue ) || $queue === array() ) {
			return;
		}

		$processed = 0;
		foreach ( array_keys( $queue ) as $ip ) {
			if ( ! is_string( $ip ) || $ip === '' ) {
				unset( $queue[ $ip ] );
				continue;
			}

			$this->resolveGeoForIp( $ip );
			unset( $queue[ $ip ] );
			++$processed;

			if ( $processed >= 8 ) {
				break;
			}
		}

		update_option( 'wpsh_geo_queue', $queue, false );
		if ( $queue !== array() && ! wp_next_scheduled( 'wpsh_process_geo_queue' ) ) {
			wp_schedule_single_event( time() + 30, 'wpsh_process_geo_queue' );
		}
	}

	private function buildRangeSummary( array $events, string $range ): array {
		$threshold  = $this->rangeThreshold( $range );
		$totals     = array(
			'ok'      => 0,
			'blocked' => 0,
		);
		$byContext  = array();
		$solveTimes = array();

		foreach ( $events as $event ) {
			if ( (int) ( $event['t'] ?? 0 ) < $threshold ) {
				continue;
			}

			$ctx       = (string) ( $event['ctx'] ?? '' );
			$result    = (string) ( $event['result'] ?? '' );
			$isBlocked = str_starts_with( $result, 'wpsh_' ) && $result !== 'ok';
			$bucket    = $isBlocked ? 'blocked' : 'ok';

			++$totals[ $bucket ];

			if ( ! isset( $byContext[ $ctx ] ) ) {
				$byContext[ $ctx ] = array(
					'ok'      => 0,
					'blocked' => 0,
				);
			}
			++$byContext[ $ctx ][ $bucket ];

			if ( isset( $event['solve_ms'] ) && $event['solve_ms'] !== null ) {
				$solveTimes[] = max( 0, (int) $event['solve_ms'] );
			}
		}

		sort( $solveTimes );
		$medianSolveMs = $solveTimes !== array()
			? (int) $solveTimes[ (int) floor( count( $solveTimes ) / 2 ) ]
			: $this->medianSolveMs();

		return array(
			'totals'          => $totals,
			'by_context'      => $byContext,
			'median_solve_ms' => $medianSolveMs,
		);
	}

	private function hashIp(): string {
		$ip = $this->rawIp();
		if ( $ip === '' ) {
			$ip = '0.0.0.0';
		}
		return substr( hash_hmac( 'sha256', $ip, wp_salt( 'auth' ) ), 0, 16 );
	}

	private function rawIp(): string {
		$ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( (string) $_SERVER['REMOTE_ADDR'] ) ) : '';
		if ( $this->isLocalHost() ) {
			$sim = isset( $_SERVER['HTTP_X_WPSH_SIM_IP'] ) ? trim( sanitize_text_field( wp_unslash( (string) $_SERVER['HTTP_X_WPSH_SIM_IP'] ) ) ) : '';
			if ( filter_var( $sim, FILTER_VALIDATE_IP ) ) {
				$ip = $sim;
			}
		}
		return filter_var( $ip, FILTER_VALIDATE_IP ) ? $ip : '';
	}

	private function trackedIpForStats(): string {
		return $this->anonymizeIp( $this->rawIp() );
	}

	private function anonymizeIp( string $ip ): string {
		if ( ! filter_var( $ip, FILTER_VALIDATE_IP ) ) {
			return '';
		}

		if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) {
			$parts = explode( '.', $ip );
			if ( count( $parts ) === 4 ) {
				$parts[3] = '0';
				return implode( '.', $parts );
			}
			return $ip;
		}

		if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 ) && function_exists( 'inet_pton' ) && function_exists( 'inet_ntop' ) ) {
			$packed = inet_pton( $ip );
			if ( $packed !== false && strlen( $packed ) === 16 ) {
				// Keep /48 (first 6 bytes), zero the remaining 10 bytes.
				$anonymized = substr( $packed, 0, 6 ) . str_repeat( "\x00", 10 );
				$text       = inet_ntop( $anonymized );
				if ( is_string( $text ) && $text !== '' ) {
					return $text;
				}
			}
		}

		return $ip;
	}

	/**
	 * Rewrites legacy raw IPs in stored events to anonymized variants.
	 *
	 * @param array<string,mixed> $stats
	 * @return array<string,mixed>
	 */
	private function normalizeStoredEventIpData( array $stats ): array {
		if ( ! isset( $stats['events'] ) || ! is_array( $stats['events'] ) ) {
			return $stats;
		}

		$changed = false;
		foreach ( $stats['events'] as $idx => $event ) {
			if ( ! is_array( $event ) ) {
				continue;
			}

			$raw = isset( $event['ip_raw'] ) ? (string) $event['ip_raw'] : '';
			if ( $raw === '' ) {
				continue;
			}

			$anon = $this->anonymizeIp( $raw );
			if ( $anon !== $raw ) {
				$stats['events'][ $idx ]['ip_raw'] = $anon;
				$changed                           = true;
			}
		}

		if ( $changed ) {
			update_option( WPSH_STATS_KEY, $stats, false );
		}

		return $stats;
	}

	private function isLocalHost(): bool {
		$host = (string) wp_parse_url( home_url( '/' ), PHP_URL_HOST );
		return in_array( $host, array( 'localhost', '127.0.0.1', '::1' ), true );
	}

	private function buildBuckets( array $events, string $range ): array {
		if ( $range === '24h' ) {
			return $this->buildTodayHourBuckets( $events );
		}

		return $this->buildDayBuckets( $events, $range === '7d' ? 7 : 30 );
	}

	private function buildTodayHourBuckets( array $events ): array {
		$now              = time();
		$todayStart       = strtotime( wp_date( 'Y-m-d 00:00:00', $now ) );
		$currentHourStart = $now - ( $now % HOUR_IN_SECONDS );

		$buckets = array();
		for ( $i = 0; $i < 24; $i++ ) {
			$ts             = $todayStart + ( $i * HOUR_IN_SECONDS );
			$buckets[ $ts ] = array(
				'ts'      => $ts,
				'label'   => wp_date( 'H:i', $ts ),
				'ok'      => 0,
				'blocked' => 0,
			);
		}

		foreach ( $events as $event ) {
			$eventTs = (int) ( $event['t'] ?? 0 );
			if ( $eventTs < $todayStart || $eventTs > ( $currentHourStart + HOUR_IN_SECONDS - 1 ) ) {
				continue;
			}

			$bucketTs = $eventTs - ( $eventTs % HOUR_IN_SECONDS );
			if ( ! isset( $buckets[ $bucketTs ] ) ) {
				continue;
			}

			$result    = (string) ( $event['result'] ?? '' );
			$isBlocked = str_starts_with( $result, 'wpsh_' ) && $result !== 'ok';
			++$buckets[ $bucketTs ][ $isBlocked ? 'blocked' : 'ok' ];
		}

		return array_values( $buckets );
	}

	private function buildHourBuckets( array $events, int $hours ): array {
		$hours            = max( 1, $hours );
		$now              = time();
		$currentHourStart = $now - ( $now % HOUR_IN_SECONDS );
		$start            = $currentHourStart - ( ( $hours - 1 ) * HOUR_IN_SECONDS );

		$buckets = array();
		for ( $i = 0; $i < $hours; $i++ ) {
			$ts             = $start + ( $i * HOUR_IN_SECONDS );
			$buckets[ $ts ] = array(
				'ts'      => $ts,
				'label'   => wp_date( 'H:i', $ts ),
				'ok'      => 0,
				'blocked' => 0,
			);
		}

		foreach ( $events as $event ) {
			$eventTs = (int) ( $event['t'] ?? 0 );
			if ( $eventTs < $start || $eventTs > ( $currentHourStart + HOUR_IN_SECONDS - 1 ) ) {
				continue;
			}

			$bucketTs = $eventTs - ( $eventTs % HOUR_IN_SECONDS );
			if ( ! isset( $buckets[ $bucketTs ] ) ) {
				continue;
			}

			$result    = (string) ( $event['result'] ?? '' );
			$isBlocked = str_starts_with( $result, 'wpsh_' ) && $result !== 'ok';
			++$buckets[ $bucketTs ][ $isBlocked ? 'blocked' : 'ok' ];
		}

		return array_values( $buckets );
	}

	private function buildDayBuckets( array $events, int $days ): array {
		$days       = max( 1, $days );
		$now        = time();
		$todayStart = strtotime( wp_date( 'Y-m-d 00:00:00', $now ) );
		$start      = $todayStart - ( ( $days - 1 ) * DAY_IN_SECONDS );

		$buckets = array();
		for ( $i = 0; $i < $days; $i++ ) {
			$ts             = $start + ( $i * DAY_IN_SECONDS );
			$buckets[ $ts ] = array(
				'ts'      => $ts,
				'label'   => wp_date( 'd.m', $ts ),
				'ok'      => 0,
				'blocked' => 0,
			);
		}

		foreach ( $events as $event ) {
			$eventTs = (int) ( $event['t'] ?? 0 );
			if ( $eventTs < $start || $eventTs > ( $todayStart + DAY_IN_SECONDS - 1 ) ) {
				continue;
			}

			$bucketTs = strtotime( wp_date( 'Y-m-d 00:00:00', $eventTs ) );
			if ( ! isset( $buckets[ $bucketTs ] ) ) {
				continue;
			}

			$result    = (string) ( $event['result'] ?? '' );
			$isBlocked = str_starts_with( $result, 'wpsh_' ) && $result !== 'ok';
			++$buckets[ $bucketTs ][ $isBlocked ? 'blocked' : 'ok' ];
		}

		return array_values( $buckets );
	}

	private function buildReasonCounts( array $events, string $range ): array {
		$threshold = $this->rangeThreshold( $range );
		$reasons   = array();
		foreach ( $events as $event ) {
			if ( (int) ( $event['t'] ?? 0 ) < $threshold ) {
				continue;
			}

			$result = (string) ( $event['result'] ?? '' );
			if ( $result === '' || $result === 'ok' ) {
				continue;
			}
			$reasons[ $result ] = ( $reasons[ $result ] ?? 0 ) + 1;
		}

		return $reasons;
	}

	private function buildIpStats( array $events, string $range ): array {
		if ( ! $this->settings->isEnabled( 'ip_tracing_enabled' ) ) {
			return array();
		}

		$threshold = $this->rangeThreshold( $range );
		$ipStats   = array();
		foreach ( $events as $event ) {
			if ( (int) ( $event['t'] ?? 0 ) < $threshold ) {
				continue;
			}

			if ( $this->isExpiredPenaltyEvent( $event ) ) {
				continue;
			}

			$ip = (string) ( $event['ip_raw'] ?? '' );
			if ( $ip === '' ) {
				continue;
			}

			if ( ! isset( $ipStats[ $ip ] ) ) {
				$ipStats[ $ip ] = array(
					'ok'          => 0,
					'blocked'     => 0,
					'strict_hits' => 0,
					'total'       => 0,
					'country'     => 'Unbekannt',
					'lat'         => null,
					'lng'         => null,
				);
			}

			$result    = (string) ( $event['result'] ?? '' );
			$isBlocked = str_starts_with( $result, 'wpsh_' ) && $result !== 'ok';
			++$ipStats[ $ip ][ $isBlocked ? 'blocked' : 'ok' ];
			++$ipStats[ $ip ]['total'];
			$diff = (int) ( $event['diff'] ?? 0 );
			if ( $diff >= 5 ) {
				++$ipStats[ $ip ]['strict_hits'];
			}
		}

		foreach ( $ipStats as $ip => $row ) {
			$blocked     = (int) ( $row['blocked'] ?? 0 );
			$strict_hits = (int) ( $row['strict_hits'] ?? 0 );
			if ( $blocked <= 0 && $strict_hits <= 0 ) {
				continue;
			}

			$geo = $this->knownGeoForIp( $ip );
			if ( $geo === null ) {
				$this->enqueueGeoLookup( $ip );
				continue;
			}

			$ipStats[ $ip ]['country'] = (string) ( $geo['country'] ?? 'Unbekannt' );
			$ipStats[ $ip ]['lat']     = isset( $geo['lat'] ) ? (float) $geo['lat'] : null;
			$ipStats[ $ip ]['lng']     = isset( $geo['lng'] ) ? (float) $geo['lng'] : null;
		}

		return $ipStats;
	}

	private function isExpiredPenaltyEvent( array $event ): bool {
		$result = (string) ( $event['result'] ?? '' );
		if ( $result !== 'wpsh_rate_limited' && $result !== 'wpsh_honeypot' ) {
			return false;
		}

		$eventTs = (int) ( $event['t'] ?? 0 );
		if ( $eventTs <= 0 ) {
			return false;
		}

		return ( time() - $eventTs ) > $this->penaltySecondsForEvent( $event );
	}

	private function penaltySecondsForEvent( array $event ): int {
		$result = (string) ( $event['result'] ?? '' );
		$ctx    = (string) ( $event['ctx'] ?? '' );

		if ( $result === 'wpsh_honeypot' ) {
			return 30 * MINUTE_IN_SECONDS;
		}

		if ( $result !== 'wpsh_rate_limited' ) {
			return DAY_IN_SECONDS;
		}

		if ( $ctx === 'login' ) {
			return $this->isLocalHost() ? 1 * MINUTE_IN_SECONDS : 10 * MINUTE_IN_SECONDS;
		}

		if ( str_starts_with( $ctx, 'form:' ) ) {
			return 10 * MINUTE_IN_SECONDS;
		}

		return 5 * MINUTE_IN_SECONDS;
	}

	private function resolveGeoForIp( string $ip ): array {
		if ( ! filter_var( $ip, FILTER_VALIDATE_IP ) ) {
			return array(
				'country' => 'Unbekannt',
				'lat'     => null,
				'lng'     => null,
			);
		}

		if ( $this->isPrivateOrReservedIp( $ip ) ) {
			return array(
				'country' => 'Lokal/Privat',
				'lat'     => null,
				'lng'     => null,
			);
		}

		$cacheKey = 'wpsh_geo_' . md5( $ip );
		$cached   = get_transient( $cacheKey );
		if ( is_array( $cached ) ) {
			return $this->normalizeGeoRow( $cached );
		}

		$persistent = $this->getPersistentGeoCacheEntry( $ip );
		if ( $persistent !== null ) {
			set_transient( $cacheKey, $persistent, 12 * HOUR_IN_SECONDS );
			return $persistent;
		}

		$providers = array(
			'https://ipapi.co/%s/json/',
			'https://ipwho.is/%s',
		);

		foreach ( $providers as $urlPattern ) {
			$url      = sprintf( $urlPattern, rawurlencode( $ip ) );
			$response = wp_remote_get(
				$url,
				array(
					'timeout' => 3,
					'headers' => array( 'Accept' => 'application/json' ),
				)
			);

			if ( is_wp_error( $response ) ) {
				continue;
			}

			$status = (int) wp_remote_retrieve_response_code( $response );
			if ( $status < 200 || $status >= 300 ) {
				continue;
			}

			$body = wp_remote_retrieve_body( $response );
			if ( ! is_string( $body ) || $body === '' ) {
				continue;
			}

			$decoded = json_decode( $body, true );
			if ( ! is_array( $decoded ) ) {
				continue;
			}

			$country = '';
			$lat     = null;
			$lng     = null;

			if ( isset( $decoded['country_name'] ) || isset( $decoded['latitude'] ) || isset( $decoded['longitude'] ) ) {
				$country = isset( $decoded['country_name'] ) ? (string) $decoded['country_name'] : '';
				$lat     = isset( $decoded['latitude'] ) && is_numeric( $decoded['latitude'] ) ? (float) $decoded['latitude'] : null;
				$lng     = isset( $decoded['longitude'] ) && is_numeric( $decoded['longitude'] ) ? (float) $decoded['longitude'] : null;
			} elseif ( array_key_exists( 'success', $decoded ) ) {
				if ( ! empty( $decoded['success'] ) ) {
					$country = isset( $decoded['country'] ) ? (string) $decoded['country'] : '';
					$lat     = isset( $decoded['latitude'] ) && is_numeric( $decoded['latitude'] ) ? (float) $decoded['latitude'] : null;
					$lng     = isset( $decoded['longitude'] ) && is_numeric( $decoded['longitude'] ) ? (float) $decoded['longitude'] : null;
				}
			}

			$normalized = array(
				'country' => $country !== '' ? $country : 'Unbekannt',
				'lat'     => $lat,
				'lng'     => $lng,
			);

			set_transient( $cacheKey, $normalized, 12 * HOUR_IN_SECONDS );
			if ( $this->isUsefulGeoResult( $normalized ) ) {
				$this->storePersistentGeoCacheEntry( $ip, $normalized );
			}
			return $normalized;
		}

		$fallback = array(
			'country' => 'Unbekannt',
			'lat'     => null,
			'lng'     => null,
		);
		$persistent = $this->getPersistentGeoCacheEntry( $ip );
		if ( $persistent !== null ) {
			set_transient( $cacheKey, $persistent, 12 * HOUR_IN_SECONDS );
			return $persistent;
		}
		set_transient( $cacheKey, $fallback, 10 * MINUTE_IN_SECONDS );
		return $fallback;
	}

	private function knownGeoForIp( string $ip ): ?array {
		if ( ! filter_var( $ip, FILTER_VALIDATE_IP ) ) {
			return array(
				'country' => 'Unbekannt',
				'lat'     => null,
				'lng'     => null,
			);
		}

		if ( $this->isPrivateOrReservedIp( $ip ) ) {
			return array(
				'country' => 'Lokal/Privat',
				'lat'     => null,
				'lng'     => null,
			);
		}

		$cacheKey = 'wpsh_geo_' . md5( $ip );
		$cached   = get_transient( $cacheKey );
		if ( is_array( $cached ) ) {
			return $this->normalizeGeoRow( $cached );
		}

		return $this->getPersistentGeoCacheEntry( $ip );
	}

	private function normalizeGeoRow( array $row ): array {
		$country = isset( $row['country'] ) ? (string) $row['country'] : 'Unbekannt';
		$lat     = isset( $row['lat'] ) && is_numeric( $row['lat'] ) ? (float) $row['lat'] : null;
		$lng     = isset( $row['lng'] ) && is_numeric( $row['lng'] ) ? (float) $row['lng'] : null;
		return array(
			'country' => $country !== '' ? $country : 'Unbekannt',
			'lat'     => $lat,
			'lng'     => $lng,
		);
	}

	private function isUsefulGeoResult( array $geo ): bool {
		$country = isset( $geo['country'] ) ? (string) $geo['country'] : '';
		return $country !== '' && $country !== 'Unbekannt';
	}

	private function enqueueGeoLookup( string $ip ): void {
		if ( ! filter_var( $ip, FILTER_VALIDATE_IP ) || $this->isPrivateOrReservedIp( $ip ) ) {
			return;
		}

		$cache_key = 'wpsh_geo_' . md5( $ip );
		$cached    = get_transient( $cache_key );
		if ( is_array( $cached ) && $this->isUsefulGeoResult( $cached ) ) {
			return;
		}

		if ( $this->getPersistentGeoCacheEntry( $ip ) !== null ) {
			return;
		}

		$queue = get_option( 'wpsh_geo_queue', array() );
		if ( ! is_array( $queue ) ) {
			$queue = array();
		}

		$queue[ $ip ] = time();
		if ( count( $queue ) > 1500 ) {
			asort( $queue );
			$queue = array_slice( $queue, -1500, null, true );
		}

		update_option( 'wpsh_geo_queue', $queue, false );
		if ( ! wp_next_scheduled( 'wpsh_process_geo_queue' ) ) {
			wp_schedule_single_event( time() + 15, 'wpsh_process_geo_queue' );
		}
	}

	private function getPersistentGeoCacheEntry( string $ip ): ?array {
		$cache = $this->persistentGeoCache();
		$key   = md5( $ip );
		if ( ! isset( $cache[ $key ] ) || ! is_array( $cache[ $key ] ) ) {
			return null;
		}

		$entry   = $cache[ $key ];
		$expires = isset( $entry['expires'] ) ? (int) $entry['expires'] : 0;
		if ( $expires > 0 && $expires < time() ) {
			unset( $cache[ $key ] );
			$this->geo_cache = $cache;
			update_option( 'wpsh_geo_cache', $cache, false );
			return null;
		}

		return $this->normalizeGeoRow( $entry );
	}

	private function storePersistentGeoCacheEntry( string $ip, array $geo ): void {
		$cache       = $this->persistentGeoCache();
		$key         = md5( $ip );
		$cache[ $key ] = array(
			'country' => isset( $geo['country'] ) ? (string) $geo['country'] : 'Unbekannt',
			'lat'     => isset( $geo['lat'] ) && is_numeric( $geo['lat'] ) ? (float) $geo['lat'] : null,
			'lng'     => isset( $geo['lng'] ) && is_numeric( $geo['lng'] ) ? (float) $geo['lng'] : null,
			'expires' => time() + ( 30 * DAY_IN_SECONDS ),
			'touched' => time(),
		);

		uasort(
			$cache,
			static function ( $a, $b ): int {
				$left  = is_array( $a ) ? (int) ( $a['touched'] ?? 0 ) : 0;
				$right = is_array( $b ) ? (int) ( $b['touched'] ?? 0 ) : 0;
				return $right <=> $left;
			}
		);
		if ( count( $cache ) > 1500 ) {
			$cache = array_slice( $cache, 0, 1500, true );
		}

		$this->geo_cache = $cache;
		update_option( 'wpsh_geo_cache', $cache, false );
	}

	private function persistentGeoCache(): array {
		if ( is_array( $this->geo_cache ) ) {
			return $this->geo_cache;
		}

		$stored = get_option( 'wpsh_geo_cache', array() );
		$cache  = is_array( $stored ) ? $stored : array();
		$now    = time();
		$dirty  = false;

		foreach ( $cache as $key => $entry ) {
			if ( ! is_array( $entry ) ) {
				unset( $cache[ $key ] );
				$dirty = true;
				continue;
			}

			$expires = isset( $entry['expires'] ) ? (int) $entry['expires'] : 0;
			if ( $expires > 0 && $expires < $now ) {
				unset( $cache[ $key ] );
				$dirty = true;
			}
		}

		if ( $dirty ) {
			update_option( 'wpsh_geo_cache', $cache, false );
		}

		$this->geo_cache = $cache;
		return $cache;
	}

	private function isPrivateOrReservedIp( string $ip ): bool {
		return filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) === false;
	}

	private function countPrivateIpEvents( array $ipStats ): int {
		$count = 0;
		foreach ( $ipStats as $ip => $row ) {
			$isPrivate = filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) === false;
			if ( ! $isPrivate ) {
				continue;
			}

			$count += (int) ( $row['ok'] ?? 0 );
			$count += (int) ( $row['blocked'] ?? 0 );
		}

		return $count;
	}

	private function rangeThreshold( string $range ): int {

		return match ( $range ) {
			'7d' => time() - ( 7 * DAY_IN_SECONDS ), '30d' => time() - ( 30 * DAY_IN_SECONDS ), default => strtotime( wp_date( 'Y-m-d 00:00:00', time() ) ),
		};
	}

	/**
	 * @param array<string,mixed> $summary
	 * @return array{ok:int,blocked:int,total:int}
	 */
	private function buildContactFormsSummary( array $summary ): array {

		$result    = array(
			'ok'      => 0,
			'blocked' => 0,
			'total'   => 0,
		);
		$byContext = isset( $summary['by_context'] ) && is_array( $summary['by_context'] ) ? $summary['by_context'] : array();
		foreach ( $byContext as $ctx => $row ) {
			if ( ! is_string( $ctx ) || ! str_starts_with( $ctx, 'form:' ) || ! is_array( $row ) ) {
				continue;
			}

			$ok                 = (int) ( $row['ok'] ?? 0 );
			$blocked            = (int) ( $row['blocked'] ?? 0 );
			$result['ok']      += $ok;
			$result['blocked'] += $blocked;
		}

		$result['total'] = $result['ok'] + $result['blocked'];
		return $result;
	}
}
