<?php
/**
 * API Audit Log
 *
 * Stores a bounded, privacy-conscious log of REST API authentication and
 * write events for the `seovaultai/v1` namespace. Raw IP addresses and
 * site keys are never persisted — only salted hashes and metadata.
 *
 * Through SEC-008 this class defines the table name/column constants,
 * schema SQL, table installer, the masking helpers (`hash_ip()`,
 * `hash_user_agent()`, `sanitize_origin()`), the `insert()` write
 * primitive, `log_auth_failure()` — wired into `Router::check_site_key()`
 * for the missing-key/invalid-key paths — `log_write_success()` — wired
 * into the `rest_request_after_callbacks` filter for successful write
 * requests (POST/PUT/PATCH/DELETE) on the `seovaultai/v1` namespace —
 * and a retention/pruning step (`prune()`) run after every `insert()`
 * that caps the table at a filterable max row count and (optionally)
 * deletes rows older than a filterable retention window.
 * `log_read_success()` adds optional, off-by-default, sampled
 * `read_ok` logging for successful reads (SEC-009) — disabled unless a
 * host opts in via the `seovaultai_api_audit_log_read_success` filter.
 * `insert()` applies the {@see LOG_ENTRY_FILTER} hook so hosts can
 * redact or modify the row array immediately before it is sanitized and
 * written (SEC-011).
 *
 * @package SEOvaultSEO\Api
 */

namespace SEOvaultSEO\Api;

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

class AuditLog {

	/**
	 * Table name suffix, appended to $wpdb->prefix.
	 */
	const TABLE_SUFFIX = 'seovault_api_audit';

	/** Column: primary key. */
	const COL_ID = 'id';

	/** Column: row creation timestamp (MySQL DATETIME). */
	const COL_CREATED_AT = 'created_at';

	/** Column: REST route path. */
	const COL_ROUTE = 'route';

	/** Column: HTTP method. */
	const COL_METHOD = 'method';

	/** Column: SHA-256 hash of the requester IP (never the raw IP). */
	const COL_IP_HASH = 'ip_hash';

	/** Column: SHA-256 hash of the requester user agent. */
	const COL_USER_AGENT_HASH = 'user_agent_hash';

	/** Column: sanitized request Origin header. */
	const COL_ORIGIN = 'origin';

	/** Column: whether the request succeeded (1) or failed (0). */
	const COL_SUCCESS = 'success';

	/** Column: HTTP response status code. */
	const COL_STATUS_CODE = 'status_code';

	/** Column: machine-readable failure reason (e.g. `no_key`, `invalid_key`). */
	const COL_FAILURE_REASON = 'failure_reason';

	/** Column: event classification (`auth_fail`, `auth_ok`, `write_ok`, `lockout`, `rate_limit`). */
	const COL_EVENT_TYPE = 'event_type';

	/**
	 * Current schema version. Bump this and extend install_table() when the
	 * schema changes so dbDelta() can apply future migrations.
	 */
	const DB_VERSION = '1.0';

	/**
	 * Option name storing the installed schema version.
	 */
	const DB_VERSION_OPTION = 'seovaultseo_api_audit_db_version';

	/**
	 * Maximum length (in characters) allowed for the `user_agent_hash` input
	 * before hashing, matching common UA string caps.
	 */
	const USER_AGENT_HASH_INPUT_MAX_LENGTH = 512;

	/**
	 * Maximum length (in characters) allowed for the stored `origin` value,
	 * matching the `origin` column width.
	 */
	const ORIGIN_MAX_LENGTH = 191;

	/**
	 * Map of known auth-failure reasons to the HTTP status Router returns
	 * for that reason, used to populate `status_code` on the logged row.
	 */
	const AUTH_FAILURE_STATUS_MAP = array(
		'no_key'      => 401,
		'invalid_key' => 403,
	);

	/**
	 * Default maximum number of rows kept in the audit table.
	 * Filterable via {@see MAX_ROWS_FILTER}.
	 */
	const DEFAULT_MAX_ROWS = 500;

	/**
	 * Filter used to override {@see DEFAULT_MAX_ROWS}. Return `0` (or any
	 * non-positive value) to disable row-count-based pruning entirely.
	 */
	const MAX_ROWS_FILTER = 'seovaultai_api_audit_max_rows';

	/**
	 * Default retention window (in days) for audit rows, regardless of
	 * the row-count cap. Filterable via {@see RETENTION_DAYS_FILTER}.
	 */
	const DEFAULT_RETENTION_DAYS = 30;

	/**
	 * Filter used to override {@see DEFAULT_RETENTION_DAYS}. Return `0`
	 * (or any non-positive value) to disable age-based pruning entirely.
	 */
	const RETENTION_DAYS_FILTER = 'seovaultai_api_audit_retention_days';

	/**
	 * Filter to opt in to sampled `read_ok` logging (SEC-009). Off by
	 * default because read traffic can be far higher volume than the
	 * write/auth-failure events already logged.
	 */
	const LOG_READ_SUCCESS_FILTER = 'seovaultai_api_audit_log_read_success';

	/**
	 * Default sample rate ("1 in N") used when read-success logging is
	 * enabled. Filterable via {@see READ_SAMPLE_RATE_FILTER}.
	 */
	const DEFAULT_READ_SAMPLE_RATE = 100;

	/**
	 * Filter used to override {@see DEFAULT_READ_SAMPLE_RATE}. A value of
	 * `1` logs every read success; `0` (or any non-positive value)
	 * disables read-success logging regardless of {@see LOG_READ_SUCCESS_FILTER}.
	 */
	const READ_SAMPLE_RATE_FILTER = 'seovaultai_api_audit_read_sample_rate';

	/**
	 * Filter applied to every audit row immediately before {@see insert()}
	 * sanitizes and writes it (SEC-011). Hosts can use this to redact or
	 * modify any insertable column value (e.g. shorten `failure_reason`,
	 * blank out `origin`) before it reaches the database. The filtered
	 * array is still subject to the usual {@see get_insertable_columns()}
	 * allowlist and per-column sanitization — unknown keys such as a raw
	 * `site_key` are never persisted even if a callback adds them.
	 */
	const LOG_ENTRY_FILTER = 'seovaultai_api_audit_log_entry';

	/**
	 * Get the fully-prefixed audit log table name.
	 *
	 * @return string
	 */
	public static function get_table_name() {
		global $wpdb;
		return $wpdb->prefix . self::TABLE_SUFFIX;
	}

	/**
	 * Whether a SQL table/column identifier is safe to interpolate.
	 *
	 * @param string $identifier Identifier to validate.
	 * @return bool
	 */
	private static function is_safe_sql_identifier( string $identifier ): bool {
		return (bool) preg_match( '/^[A-Za-z0-9_]+$/', $identifier );
	}

	/**
	 * Get the dbDelta-compatible CREATE TABLE SQL for the audit log table.
	 *
	 * dbDelta() is strict about formatting: each column must be on its own
	 * line, there must be exactly two spaces between `PRIMARY KEY` and its
	 * column list, and indexes must use the `KEY` keyword (not `INDEX`).
	 * Do not reformat this without re-testing `dbDelta()` behavior.
	 *
	 * @return string
	 */
	public static function get_schema_sql() {
		global $wpdb;

		$table           = self::get_table_name();
		$charset_collate = $wpdb->get_charset_collate();

		$columns = array(
			self::COL_ID . ' bigint(20) unsigned NOT NULL AUTO_INCREMENT',
			self::COL_CREATED_AT . ' datetime NOT NULL',
			self::COL_ROUTE . ' varchar(191) NOT NULL',
			self::COL_METHOD . ' varchar(10) NOT NULL',
			self::COL_IP_HASH . ' char(64) DEFAULT NULL',
			self::COL_USER_AGENT_HASH . ' char(64) DEFAULT NULL',
			self::COL_ORIGIN . ' varchar(191) DEFAULT NULL',
			self::COL_SUCCESS . ' tinyint(1) NOT NULL DEFAULT 0',
			self::COL_STATUS_CODE . ' smallint(5) unsigned DEFAULT NULL',
			self::COL_FAILURE_REASON . ' varchar(64) DEFAULT NULL',
			self::COL_EVENT_TYPE . ' varchar(32) NOT NULL',
			'PRIMARY KEY  (' . self::COL_ID . ')',
			'KEY ' . self::COL_CREATED_AT . ' (' . self::COL_CREATED_AT . ')',
			'KEY ' . self::COL_EVENT_TYPE . ' (' . self::COL_EVENT_TYPE . ')',
		);

		return "CREATE TABLE {$table} (\n\t" . implode( ",\n\t", $columns ) . "\n) {$charset_collate};";
	}

	/**
	 * Install or upgrade the audit log table.
	 *
	 * Safe to call on every activation (and repeatedly in tests): dbDelta()
	 * only applies the diff needed to reach the current schema and does not
	 * error or duplicate the table if it already matches.
	 *
	 * @return void
	 */
	public static function install_table() {
		if ( ! function_exists( 'dbDelta' ) ) {
			require_once ABSPATH . 'wp-admin/includes/upgrade.php';
		}

		dbDelta( self::get_schema_sql() );

		update_option( self::DB_VERSION_OPTION, self::DB_VERSION, false );
	}

	/**
	 * Hash an IP address for privacy-conscious storage.
	 *
	 * The raw IP is never persisted; only this SHA-256 hash is stored so the
	 * same address can be recognized across requests (for rate limiting and
	 * lockouts) without keeping personally identifiable data at rest.
	 *
	 * @param string $ip Raw IP address (IPv4 or IPv6).
	 * @return string SHA-256 hex digest, or empty string for an empty IP.
	 */
	public static function hash_ip( string $ip ): string {
		$ip = trim( $ip );

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

		return hash( 'sha256', $ip );
	}

	/**
	 * Hash a user agent string for privacy-conscious storage.
	 *
	 * The input is truncated before hashing so pathologically long UA
	 * strings (spoofed or malformed) cannot cause excessive processing.
	 *
	 * @param string $ua Raw `User-Agent` header value.
	 * @return string SHA-256 hex digest, or empty string for an empty UA.
	 */
	public static function hash_user_agent( string $ua ): string {
		if ( '' === $ua ) {
			return '';
		}

		$ua = substr( $ua, 0, self::USER_AGENT_HASH_INPUT_MAX_LENGTH );

		return hash( 'sha256', $ua );
	}

	/**
	 * Sanitize a request `Origin` header value for storage.
	 *
	 * Only well-formed `http`/`https` origins are kept; anything else
	 * (missing origin, `javascript:`/`data:` schemes, malformed input) is
	 * rejected to an empty string rather than stored as-is.
	 *
	 * @param string|null $origin Raw `Origin` header value.
	 * @return string Sanitized origin (max {@see ORIGIN_MAX_LENGTH} chars), or empty string when disallowed.
	 */
	public static function sanitize_origin( ?string $origin ): string {
		if ( null === $origin || '' === trim( $origin ) ) {
			return '';
		}

		$origin = esc_url_raw( trim( $origin ) );

		if ( '' === $origin ) {
			return '';
		}

		$scheme = wp_parse_url( $origin, PHP_URL_SCHEME );

		if ( ! in_array( strtolower( (string) $scheme ), array( 'http', 'https' ), true ) ) {
			return '';
		}

		if ( strlen( $origin ) > self::ORIGIN_MAX_LENGTH ) {
			$origin = substr( $origin, 0, self::ORIGIN_MAX_LENGTH );
		}

		return $origin;
	}

	/**
	 * Get the list of column names accepted by insert(), in insert order.
	 *
	 * This is the write-side allowlist: any key present in the array passed
	 * to insert() that is not in this list — including a raw `site_key` —
	 * is silently ignored and never reaches the database.
	 *
	 * @return string[]
	 */
	public static function get_insertable_columns(): array {
		return array(
			self::COL_CREATED_AT,
			self::COL_ROUTE,
			self::COL_METHOD,
			self::COL_IP_HASH,
			self::COL_USER_AGENT_HASH,
			self::COL_ORIGIN,
			self::COL_SUCCESS,
			self::COL_STATUS_CODE,
			self::COL_FAILURE_REASON,
			self::COL_EVENT_TYPE,
		);
	}

	/**
	 * Insert a row into the audit log table.
	 *
	 * Only columns returned by {@see get_insertable_columns()} are ever
	 * written — any other array key (notably a raw `site_key`) is ignored.
	 * `route`, `method`, and `event_type` are sanitized with
	 * `sanitize_text_field()`; `status_code` is cast with `absint()`;
	 * `success` is normalized to `0`/`1`. Required NOT NULL columns
	 * (`created_at`, `route`, `method`, `event_type`) fall back to a safe
	 * default when omitted so the insert never fails on missing data.
	 *
	 * After a successful insert, {@see prune()} is called to enforce the
	 * row-count cap and retention window (SEC-008) so the table stays
	 * bounded without a separate cron job.
	 *
	 * Before any sanitization, the incoming `$row` is passed through
	 * {@see LOG_ENTRY_FILTER} so hosts can redact or modify entries
	 * (SEC-011). If a callback returns a non-array, the original `$row`
	 * is used unchanged.
	 *
	 * @param array $row Associative array keyed by the `COL_*` constants.
	 * @return int|false Inserted row ID, or false on failure.
	 */
	public static function insert( array $row ) {
		global $wpdb;

		// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- Constant value is 'seovaultai_api_audit_log_entry'.
		$filtered_row = apply_filters( self::LOG_ENTRY_FILTER, $row );
		if ( is_array( $filtered_row ) ) {
			$row = $filtered_row;
		}

		$data = array(
			self::COL_CREATED_AT => isset( $row[ self::COL_CREATED_AT ] ) ? sanitize_text_field( (string) $row[ self::COL_CREATED_AT ] ) : current_time( 'mysql' ),
			self::COL_ROUTE      => isset( $row[ self::COL_ROUTE ] ) ? sanitize_text_field( (string) $row[ self::COL_ROUTE ] ) : '',
			self::COL_METHOD     => isset( $row[ self::COL_METHOD ] ) ? sanitize_text_field( (string) $row[ self::COL_METHOD ] ) : '',
			self::COL_EVENT_TYPE => isset( $row[ self::COL_EVENT_TYPE ] ) ? sanitize_text_field( (string) $row[ self::COL_EVENT_TYPE ] ) : '',
			self::COL_SUCCESS    => empty( $row[ self::COL_SUCCESS ] ) ? 0 : 1,
		);

		$formats = array( '%s', '%s', '%s', '%s', '%d' );

		// Optional NULL-able text columns: only included (and thus only
		// written) when the caller actually provided a non-empty value.
		$optional_text_columns = array(
			self::COL_IP_HASH,
			self::COL_USER_AGENT_HASH,
			self::COL_ORIGIN,
			self::COL_FAILURE_REASON,
		);

		foreach ( $optional_text_columns as $column ) {
			if ( isset( $row[ $column ] ) && '' !== $row[ $column ] ) {
				$data[ $column ] = sanitize_text_field( (string) $row[ $column ] );
				$formats[]       = '%s';
			}
		}

		if ( isset( $row[ self::COL_STATUS_CODE ] ) && '' !== $row[ self::COL_STATUS_CODE ] ) {
			$data[ self::COL_STATUS_CODE ] = absint( $row[ self::COL_STATUS_CODE ] );
			$formats[]                     = '%d';
		}

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom plugin audit table; no WP API equivalent for inserts.
		$result = $wpdb->insert( self::get_table_name(), $data, $formats );

		if ( false === $result ) {
			return false;
		}

		$inserted_id = (int) $wpdb->insert_id;

		self::prune();

		return $inserted_id;
	}

	/**
	 * Get the effective max-rows cap (SEC-008).
	 *
	 * Note: uses `(int)` rather than `absint()` so a filter returning a
	 * negative value is clamped down to `0` (disables pruning) instead
	 * of being flipped to a positive cap by `absint()`'s abs-value
	 * behavior.
	 *
	 * @return int Non-negative row cap; `0` means "no row-count cap".
	 */
	public static function get_max_rows(): int {
		// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- Constant value is 'seovaultai_api_audit_max_rows'.
		return max( 0, (int) apply_filters( self::MAX_ROWS_FILTER, self::DEFAULT_MAX_ROWS ) );
	}

	/**
	 * Get the effective retention window in days (SEC-008).
	 *
	 * Note: uses `(int)` rather than `absint()`, for the same
	 * negative-value clamping reason as {@see get_max_rows()}.
	 *
	 * @return int Non-negative day count; `0` means "no age-based pruning".
	 */
	public static function get_retention_days(): int {
		// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- Constant value is 'seovaultai_api_audit_retention_days'.
		return max( 0, (int) apply_filters( self::RETENTION_DAYS_FILTER, self::DEFAULT_RETENTION_DAYS ) );
	}

	/**
	 * Get the current total row count in the audit table.
	 *
	 * @return int
	 */
	public static function get_row_count(): int {
		global $wpdb;
		$table = self::get_table_name();
		if ( ! self::is_safe_sql_identifier( $table ) ) {
			return 0;
		}

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Plugin-owned table name validated above.
		return (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table}" );
	}

	/**
	 * Maximum number of rows {@see get_recent_rows()} will ever return,
	 * regardless of the requested `$limit` (SEC-010: admin "API
	 * Activity" list shows the last 50 entries).
	 */
	const MAX_RECENT_ROWS = 50;

	/**
	 * Get the most recent audit log rows for read-only admin display (SEC-010).
	 *
	 * Only selects the columns the admin UI actually needs (time, route,
	 * method, success, status code, failure reason, event type). The
	 * hashed IP/user-agent and origin columns are intentionally left
	 * out of the query entirely — defense in depth on top of the admin
	 * UI simply not rendering them — since no raw IP is ever stored
	 * (see {@see hash_ip()}) and the admin list is documented to never
	 * display IP-derived data.
	 *
	 * @param int $limit Maximum number of rows to return; clamped to `[1, MAX_RECENT_ROWS]`.
	 * @return array[] Rows (associative arrays keyed by `COL_*` column name), newest first.
	 */
	public static function get_recent_rows( int $limit = self::MAX_RECENT_ROWS ): array {
		global $wpdb;

		$limit = max( 1, min( self::MAX_RECENT_ROWS, $limit ) );
		$table = self::get_table_name();
		if ( ! self::is_safe_sql_identifier( $table ) ) {
			return array();
		}

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Plugin-owned table name validated above; columns are fixed literals and $limit uses a placeholder.
		$rows = $wpdb->get_results( $wpdb->prepare( "SELECT created_at, route, method, success, status_code, failure_reason, event_type FROM {$table} ORDER BY id DESC LIMIT %d", $limit ), ARRAY_A );

		return is_array( $rows ) ? $rows : array();
	}

	/**
	 * Human-readable label for a stored event_type code (admin UI only).
	 *
	 * Unknown codes fall back to the raw string. Machine codes remain
	 * available via the `title` attribute in the Security tab table.
	 *
	 * @param string $event_type Stored event type (e.g. `auth_fail`).
	 * @return string
	 */
	public static function label_event_type( string $event_type ): string {
		$event_type = (string) $event_type;
		$labels     = array(
			'auth_fail'             => __( 'Failed authentication', 'seovault-ai' ),
			'lockout'               => __( 'Temporary lockout', 'seovault-ai' ),
			'lockout_warning'       => __( 'Lockout warning', 'seovault-ai' ),
			'rate_limit'            => __( 'Rate limited', 'seovault-ai' ),
			'write_ok'              => __( 'Successful write', 'seovault-ai' ),
			'read_ok'               => __( 'Successful read', 'seovault-ai' ),
			'admin_clear_lockouts'  => __( 'Cleared lockouts', 'seovault-ai' ),
		);

		return isset( $labels[ $event_type ] ) ? $labels[ $event_type ] : $event_type;
	}

	/**
	 * Human-readable label for a stored failure_reason code (admin UI only).
	 *
	 * Unknown codes fall back to the raw string.
	 *
	 * @param string $reason Stored failure reason (e.g. `invalid_key`).
	 * @return string
	 */
	public static function label_failure_reason( string $reason ): string {
		$reason = (string) $reason;
		if ( '' === $reason ) {
			return '';
		}

		$labels = array(
			'invalid_key'      => __( 'Invalid API key', 'seovault-ai' ),
			'no_key'           => __( 'Missing API key', 'seovault-ai' ),
			'auth_lockout'     => __( 'Auth lockout', 'seovault-ai' ),
			'lockout_warning'  => __( 'Lockout warning', 'seovault-ai' ),
			'global_ip'        => __( 'Global IP limit', 'seovault-ai' ),
			'auth_fail_ip'     => __( 'Auth failure limit', 'seovault-ai' ),
			'clear_all'        => __( 'Cleared all', 'seovault-ai' ),
			'clear_ip'         => __( 'Cleared IP', 'seovault-ai' ),
		);

		return isset( $labels[ $reason ] ) ? $labels[ $reason ] : $reason;
	}

	/**
	 * Count event buckets from already-fetched audit rows for admin summary.
	 *
	 * Pure / DB-free. Does not query storage — only classifies the given rows.
	 *
	 * @param array[] $rows Rows from {@see get_recent_rows()}.
	 * @return array{total:int,auth_fail:int,lockout:int,rate_limit:int,success:int}
	 */
	public static function summarize_rows_for_display( array $rows ): array {
		$summary = array(
			'total'      => count( $rows ),
			'auth_fail'  => 0,
			'lockout'    => 0,
			'rate_limit' => 0,
			'success'    => 0,
		);

		foreach ( $rows as $row ) {
			if ( ! is_array( $row ) ) {
				continue;
			}
			$event = isset( $row[ self::COL_EVENT_TYPE ] ) ? (string) $row[ self::COL_EVENT_TYPE ] : '';
			switch ( $event ) {
				case 'auth_fail':
					++$summary['auth_fail'];
					break;
				case 'lockout':
				case 'lockout_warning':
					++$summary['lockout'];
					break;
				case 'rate_limit':
					++$summary['rate_limit'];
					break;
				case 'write_ok':
				case 'read_ok':
					++$summary['success'];
					break;
			}
		}

		return $summary;
	}

	/**
	 * Merge consecutive identical activity rows for compact admin display.
	 *
	 * Rows are considered identical when route, method, success, event_type,
	 * and failure_reason match. Each collapsed row keeps the newest
	 * `created_at` (first in a newest-first list) and adds a `count` key.
	 *
	 * Pure / DB-free — does not change stored audit data.
	 *
	 * @param array[] $rows Rows from {@see get_recent_rows()} (newest first).
	 * @return array[] Display rows with an added `count` integer (≥ 1).
	 */
	public static function collapse_consecutive_rows_for_display( array $rows ): array {
		$collapsed = array();

		foreach ( $rows as $row ) {
			if ( ! is_array( $row ) ) {
				continue;
			}

			$signature = array(
				isset( $row[ self::COL_ROUTE ] ) ? (string) $row[ self::COL_ROUTE ] : '',
				isset( $row[ self::COL_METHOD ] ) ? (string) $row[ self::COL_METHOD ] : '',
				isset( $row[ self::COL_SUCCESS ] ) ? (string) $row[ self::COL_SUCCESS ] : '',
				isset( $row[ self::COL_EVENT_TYPE ] ) ? (string) $row[ self::COL_EVENT_TYPE ] : '',
				isset( $row[ self::COL_FAILURE_REASON ] ) ? (string) $row[ self::COL_FAILURE_REASON ] : '',
			);

			$last_index = count( $collapsed ) - 1;
			if ( $last_index >= 0 ) {
				$prev           = $collapsed[ $last_index ];
				$prev_signature = array(
					isset( $prev[ self::COL_ROUTE ] ) ? (string) $prev[ self::COL_ROUTE ] : '',
					isset( $prev[ self::COL_METHOD ] ) ? (string) $prev[ self::COL_METHOD ] : '',
					isset( $prev[ self::COL_SUCCESS ] ) ? (string) $prev[ self::COL_SUCCESS ] : '',
					isset( $prev[ self::COL_EVENT_TYPE ] ) ? (string) $prev[ self::COL_EVENT_TYPE ] : '',
					isset( $prev[ self::COL_FAILURE_REASON ] ) ? (string) $prev[ self::COL_FAILURE_REASON ] : '',
				);
				if ( $signature === $prev_signature ) {
					$collapsed[ $last_index ]['count'] = (int) $collapsed[ $last_index ]['count'] + 1;
					continue;
				}
			}

			$display_row          = $row;
			$display_row['count'] = 1;
			$collapsed[]          = $display_row;
		}

		return $collapsed;
	}

	/**
	 * Pure helper: given the current row count and a max-rows cap,
	 * calculate how many of the *oldest* rows must be deleted to bring
	 * the table back within the cap.
	 *
	 * Kept as a standalone, DB-free function so the "keep the newest
	 * rows" logic is unit-testable without touching the database.
	 *
	 * @param int $current_row_count Current total row count.
	 * @param int $max_rows          Row-count cap (`0` disables pruning).
	 * @return int Number of oldest rows to delete (never negative).
	 */
	public static function calculate_prune_count( int $current_row_count, int $max_rows ): int {
		if ( $max_rows <= 0 ) {
			return 0;
		}

		return max( 0, $current_row_count - $max_rows );
	}

	/**
	 * Enforce the row-count cap by deleting the oldest rows beyond it.
	 *
	 * Uses a single-table `DELETE ... ORDER BY id ASC LIMIT n` (allowed
	 * directly by MySQL, unlike a correlated subquery on the same
	 * table) so the oldest rows — by insertion order, which always
	 * matches chronological order for this append-only table — are
	 * removed first, keeping the newest {@see get_max_rows()} rows.
	 *
	 * @return void
	 */
	public static function prune_by_max_rows() {
		$max_rows = self::get_max_rows();
		if ( $max_rows <= 0 ) {
			return;
		}

		$delete_count = self::calculate_prune_count( self::get_row_count(), $max_rows );
		if ( $delete_count <= 0 ) {
			return;
		}

		global $wpdb;
		$table = self::get_table_name();
		if ( ! self::is_safe_sql_identifier( $table ) ) {
			return;
		}

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Plugin-owned table name validated above; $delete_count uses prepare placeholder.
		$wpdb->query( $wpdb->prepare( "DELETE FROM {$table} ORDER BY id ASC LIMIT %d", $delete_count ) );
	}

	/**
	 * Enforce the age-based retention window by deleting rows older
	 * than {@see get_retention_days()} days, independent of the
	 * row-count cap.
	 *
	 * @return void
	 */
	public static function prune_by_retention_days() {
		$retention_days = self::get_retention_days();
		if ( $retention_days <= 0 ) {
			return;
		}

		global $wpdb;
		$table = self::get_table_name();
		if ( ! self::is_safe_sql_identifier( $table ) ) {
			return;
		}

		$created_at_column = self::COL_CREATED_AT;
		$cutoff            = gmdate( 'Y-m-d H:i:s', current_time( 'timestamp' ) - ( $retention_days * DAY_IN_SECONDS ) ); // phpcs:ignore WordPress.DateTime.CurrentTimeTimestamp.Requested -- matches the `current_time( 'mysql' )` clock used by insert()'s created_at default.
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Plugin-owned table/column names validated above; $cutoff uses prepare placeholder.
		$wpdb->query( $wpdb->prepare( "DELETE FROM {$table} WHERE {$created_at_column} < %s", $cutoff ) );
	}

	/**
	 * Run all retention/pruning rules (SEC-008). Called automatically
	 * after every {@see insert()}.
	 *
	 * @return void
	 */
	public static function prune() {
		self::prune_by_max_rows();
		self::prune_by_retention_days();
	}

	/**
	 * Get the current request's IP address.
	 *
	 * Reads `$_SERVER['REMOTE_ADDR']` directly (rather than `filter_input()`)
	 * so the value is consistent and testable both for real HTTP requests
	 * and inside PHPUnit's CLI test environment.
	 *
	 * @return string
	 */
	public static function get_request_ip(): string {
		$ip = isset( $_SERVER['REMOTE_ADDR'] )
			? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) )
			: '';

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

	/**
	 * Log a REST API authentication failure event (SEC-006).
	 *
	 * Called from `Router::check_site_key()` when no key is provided/stored
	 * or the provided key fails `hash_equals()`. Successful auth is not
	 * logged here (see later steps).
	 *
	 * @param \WP_REST_Request $request Current request.
	 * @param string           $reason  Machine-readable failure reason (`no_key`, `invalid_key`).
	 * @return int|false Inserted row ID, or false on failure.
	 */
	public static function log_auth_failure( \WP_REST_Request $request, string $reason ) {
		return self::insert(
			array(
				self::COL_ROUTE           => (string) $request->get_route(),
				self::COL_METHOD          => (string) $request->get_method(),
				self::COL_IP_HASH         => self::hash_ip( self::get_request_ip() ),
				self::COL_USER_AGENT_HASH => self::hash_user_agent( (string) $request->get_header( 'User-Agent' ) ),
				self::COL_ORIGIN          => self::sanitize_origin( $request->get_header( 'Origin' ) ),
				self::COL_SUCCESS         => 0,
				self::COL_STATUS_CODE     => self::AUTH_FAILURE_STATUS_MAP[ $reason ] ?? 401,
				self::COL_FAILURE_REASON  => $reason,
				self::COL_EVENT_TYPE      => 'auth_fail',
			)
		);
	}

	/**
	 * Log a rate-limit (429) event (SEC-019 / SEC-020).
	 *
	 * Called from `Router::check_site_key()` when a pre-auth limit
	 * (`global_ip` or `auth_fail_ip`) or a post-auth bucket limit
	 * (`read` / `write` / `import` / `media`) returns
	 * `seovaultai_rate_limited`.
	 *
	 * @param \WP_REST_Request $request Current request.
	 * @param string           $reason  Machine-readable limit that fired
	 *                                  (e.g. `global_ip`, `auth_fail_ip`, `write`).
	 * @return int|false Inserted row ID, or false on failure.
	 */
	public static function log_rate_limit( \WP_REST_Request $request, string $reason = 'rate_limited' ) {
		return self::insert(
			array(
				self::COL_ROUTE           => (string) $request->get_route(),
				self::COL_METHOD          => (string) $request->get_method(),
				self::COL_IP_HASH         => self::hash_ip( self::get_request_ip() ),
				self::COL_USER_AGENT_HASH => self::hash_user_agent( (string) $request->get_header( 'User-Agent' ) ),
				self::COL_ORIGIN          => self::sanitize_origin( $request->get_header( 'Origin' ) ),
				self::COL_SUCCESS         => 0,
				self::COL_STATUS_CODE     => 429,
				self::COL_FAILURE_REASON  => $reason,
				self::COL_EVENT_TYPE      => 'rate_limit',
			)
		);
	}

	/**
	 * Log a lockout-warning event (SEC-023).
	 *
	 * Fired once when the failed-auth counter reaches the warning
	 * threshold (default 10 / 15 min). Does not block the request.
	 *
	 * @param \WP_REST_Request $request Current request.
	 * @return int|false Inserted row ID, or false on failure.
	 */
	public static function log_lockout_warning( \WP_REST_Request $request ) {
		return self::insert(
			array(
				self::COL_ROUTE           => (string) $request->get_route(),
				self::COL_METHOD          => (string) $request->get_method(),
				self::COL_IP_HASH         => self::hash_ip( self::get_request_ip() ),
				self::COL_USER_AGENT_HASH => self::hash_user_agent( (string) $request->get_header( 'User-Agent' ) ),
				self::COL_ORIGIN          => self::sanitize_origin( $request->get_header( 'Origin' ) ),
				self::COL_SUCCESS         => 0,
				self::COL_STATUS_CODE     => 429,
				self::COL_FAILURE_REASON  => 'lockout_warning',
				self::COL_EVENT_TYPE      => 'lockout_warning',
			)
		);
	}

	/**
	 * Log a hard auth-lockout event (SEC-023).
	 *
	 * Fired when `seovaultai_auth_lockout` is returned (threshold reached
	 * or subsequent requests while the lockout transient is active).
	 *
	 * @param \WP_REST_Request $request Current request.
	 * @return int|false Inserted row ID, or false on failure.
	 */
	public static function log_lockout( \WP_REST_Request $request ) {
		return self::insert(
			array(
				self::COL_ROUTE           => (string) $request->get_route(),
				self::COL_METHOD          => (string) $request->get_method(),
				self::COL_IP_HASH         => self::hash_ip( self::get_request_ip() ),
				self::COL_USER_AGENT_HASH => self::hash_user_agent( (string) $request->get_header( 'User-Agent' ) ),
				self::COL_ORIGIN          => self::sanitize_origin( $request->get_header( 'Origin' ) ),
				self::COL_SUCCESS         => 0,
				self::COL_STATUS_CODE     => 429,
				self::COL_FAILURE_REASON  => 'auth_lockout',
				self::COL_EVENT_TYPE      => 'lockout',
			)
		);
	}

	/**
	 * Log an admin "clear API lockouts" action (SEC-026).
	 *
	 * @param string $scope `all` or `ip` (per-IP clear).
	 * @return int|false Inserted row ID, or false on failure.
	 */
	public static function log_admin_clear_lockouts( string $scope = 'all' ) {
		$scope = in_array( $scope, array( 'all', 'ip' ), true ) ? $scope : 'all';

		return self::insert(
			array(
				self::COL_ROUTE           => '/admin/clear_api_lockouts',
				self::COL_METHOD          => 'ADMIN',
				self::COL_IP_HASH         => self::hash_ip( self::get_request_ip() ),
				self::COL_USER_AGENT_HASH => self::hash_user_agent(
					isset( $_SERVER['HTTP_USER_AGENT'] )
						? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) )
						: ''
				),
				self::COL_ORIGIN          => '',
				self::COL_SUCCESS         => 1,
				self::COL_STATUS_CODE     => 200,
				self::COL_FAILURE_REASON  => 'clear_' . $scope,
				self::COL_EVENT_TYPE      => 'admin_clear_lockouts',
			)
		);
	}

	/**
	 * Log a successful REST API write event (SEC-007).
	 *
	 * Called from `Router::log_write_success()` (hooked onto
	 * `rest_request_after_callbacks`) once a write-method
	 * (`POST`/`PUT`/`PATCH`/`DELETE`) request to the `seovaultai/v1`
	 * namespace has passed its `permission_callback` and produced a
	 * non-error response. Read requests and failed writes are never
	 * logged here.
	 *
	 * @param \WP_REST_Request $request  Current request.
	 * @param mixed            $response The (non-error) dispatch result for the request.
	 * @return int|false Inserted row ID, or false on failure.
	 */
	public static function log_write_success( \WP_REST_Request $request, $response ) {
		$status_code = 200;
		if ( $response instanceof \WP_REST_Response ) {
			$status_code = (int) $response->get_status();
		}

		return self::insert(
			array(
				self::COL_ROUTE           => (string) $request->get_route(),
				self::COL_METHOD          => (string) $request->get_method(),
				self::COL_IP_HASH         => self::hash_ip( self::get_request_ip() ),
				self::COL_USER_AGENT_HASH => self::hash_user_agent( (string) $request->get_header( 'User-Agent' ) ),
				self::COL_ORIGIN          => self::sanitize_origin( $request->get_header( 'Origin' ) ),
				self::COL_SUCCESS         => 1,
				self::COL_STATUS_CODE     => $status_code,
				self::COL_EVENT_TYPE      => 'write_ok',
			)
		);
	}

	/**
	 * Whether sampled `read_ok` logging is enabled (SEC-009).
	 *
	 * Off by default: read traffic is typically much higher volume than
	 * the write/auth-failure events already logged, so hosts must
	 * explicitly opt in via {@see LOG_READ_SUCCESS_FILTER}.
	 *
	 * @return bool
	 */
	public static function is_read_success_logging_enabled(): bool {
		// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- Constant value is 'seovaultai_api_audit_log_read_success'.
		return (bool) apply_filters( self::LOG_READ_SUCCESS_FILTER, false );
	}

	/**
	 * Get the effective read-success sample rate ("1 in N") (SEC-009).
	 *
	 * Uses `(int)` rather than `absint()` so a filter returning a
	 * negative value is clamped to `0` (disables read-success logging)
	 * instead of being flipped positive, matching the same convention
	 * as {@see get_max_rows()} / {@see get_retention_days()}.
	 *
	 * @return int Non-negative sample rate; `0` disables read-success logging.
	 */
	public static function get_read_sample_rate(): int {
		// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- Constant value is 'seovaultai_api_audit_read_sample_rate'.
		return max( 0, (int) apply_filters( self::READ_SAMPLE_RATE_FILTER, self::DEFAULT_READ_SAMPLE_RATE ) );
	}

	/**
	 * Pure helper: decide whether the current read should be sampled
	 * for logging, given a "1 in N" sample rate.
	 *
	 * Kept as a standalone, RNG-only function (no DB/request access) so
	 * the boundary behavior is unit-testable: a rate of `1` always
	 * samples, a rate of `0` (or less) never samples. Any other rate
	 * samples with probability `1/$rate` via `wp_rand()`.
	 *
	 * @param int $rate Sample rate ("1 in N"); `0` or less means "never".
	 * @return bool
	 */
	public static function should_sample_read( int $rate ): bool {
		if ( $rate <= 0 ) {
			return false;
		}

		if ( 1 === $rate ) {
			return true;
		}

		return 1 === wp_rand( 1, $rate );
	}

	/**
	 * Log a successful REST API read event, sampled (SEC-009).
	 *
	 * Called from `Router::log_read_success()` (hooked onto
	 * `rest_request_after_callbacks`, same as {@see log_write_success()})
	 * for non-write, non-OPTIONS requests to the `seovaultai/v1`
	 * namespace that did not fail. Disabled by default; opt in via
	 * {@see LOG_READ_SUCCESS_FILTER} and tune the sample rate via
	 * {@see READ_SAMPLE_RATE_FILTER}.
	 *
	 * @param \WP_REST_Request $request  Current request.
	 * @param mixed            $response The (non-error) dispatch result for the request.
	 * @return int|false Inserted row ID, `false` on failure, or `false` when not sampled/enabled.
	 */
	public static function log_read_success( \WP_REST_Request $request, $response ) {
		if ( ! self::is_read_success_logging_enabled() ) {
			return false;
		}

		if ( ! self::should_sample_read( self::get_read_sample_rate() ) ) {
			return false;
		}

		$status_code = 200;
		if ( $response instanceof \WP_REST_Response ) {
			$status_code = (int) $response->get_status();
		}

		return self::insert(
			array(
				self::COL_ROUTE           => (string) $request->get_route(),
				self::COL_METHOD          => (string) $request->get_method(),
				self::COL_IP_HASH         => self::hash_ip( self::get_request_ip() ),
				self::COL_USER_AGENT_HASH => self::hash_user_agent( (string) $request->get_header( 'User-Agent' ) ),
				self::COL_ORIGIN          => self::sanitize_origin( $request->get_header( 'Origin' ) ),
				self::COL_SUCCESS         => 1,
				self::COL_STATUS_CODE     => $status_code,
				self::COL_EVENT_TYPE      => 'read_ok',
			)
		);
	}
}
