<?php

namespace Simple_History;

use Simple_History\Simple_History;
use Simple_History\Services\Setup_Settings_Page;
use Simple_History\Date_Helper;

/**
 * Helper functions.
 */
class Helpers {
	/**
	 * Pretty much same as wp_text_diff() but with this you can set leading and trailing context lines
	 *
	 * @since 2.0.29
	 *
	 * Original description from wp_text_diff():
	 *
	 * Displays a human readable HTML representation of the difference between two strings.
	 *
	 * The Diff is available for getting the changes between versions. The output is
	 * HTML, so the primary use is for displaying the changes. If the two strings
	 * are equivalent, then an empty string will be returned.
	 *
	 * The arguments supported and can be changed are listed below.
	 *
	 * 'title' : Default is an empty string. Titles the diff in a manner compatible
	 *      with the output.
	 * 'title_left' : Default is an empty string. Change the HTML to the left of the
	 *      title.
	 * 'title_right' : Default is an empty string. Change the HTML to the right of
	 *      the title.
	 *
	 * @see wp_parse_args() Used to change defaults to user defined settings.
	 * @uses Text_Diff
	 * @uses WP_Text_Diff_Renderer_Table
	 *
	 * @param string       $left_string "old" (left) version of string.
	 * @param string       $right_string "new" (right) version of string.
	 * @param string|array $args Optional. Change 'title', 'title_left', and 'title_right' defaults. And leading_context_lines and trailing_context_lines.
	 * @return string Empty string if strings are equivalent or HTML with differences.
	 */
	public static function text_diff( $left_string, $right_string, $args = null ) {
		$defaults = array(
			'title'                  => '',
			'title_left'             => '',
			'title_right'            => '',
			'leading_context_lines'  => 1,
			'trailing_context_lines' => 1,
		);

		$args = wp_parse_args( $args, $defaults );

		if ( ! class_exists( 'WP_Text_Diff_Renderer_Table' ) ) {
			require ABSPATH . WPINC . '/wp-diff.php';
		}

		$left_string  = normalize_whitespace( $left_string );
		$right_string = normalize_whitespace( $right_string );

		$left_lines  = explode( "\n", $left_string );
		$right_lines = explode( "\n", $right_string );
		$text_diff   = new \Text_Diff( $left_lines, $right_lines );

		$renderer                          = new \WP_Text_Diff_Renderer_Table( $args );
		$renderer->_leading_context_lines  = $args['leading_context_lines'];
		$renderer->_trailing_context_lines = $args['trailing_context_lines'];

		$diff = $renderer->render( $text_diff );

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

		$r = '';

		$r .= "<div class='SimpleHistory__diff__contents' tabindex='0'>";
		$r .= "<div class='SimpleHistory__diff__contentsInner'>";

		$r .= "<table class='diff SimpleHistory__diff'>\n";

		if ( ! empty( $args['show_split_view'] ) ) {
			$r .=
			"<col class='content diffsplit left' /><col class='content diffsplit middle' /><col class='content diffsplit right' />";
		} else {
			$r .= "<col class='content' />";
		}

		if ( $args['title'] || $args['title_left'] || $args['title_right'] ) {
			$r .= '<thead>';
		}
		if ( $args['title'] ) {
			$r .= "<tr class='diff-title'><th colspan='4'>$args[title]</th></tr>\n";
		}
		if ( $args['title_left'] || $args['title_right'] ) {
			$r .= "<tr class='diff-sub-title'>\n";
			$r .= "\t<td></td><th>$args[title_left]</th>\n";
			$r .= "\t<td></td><th>$args[title_right]</th>\n";
			$r .= "</tr>\n";
		}
		if ( $args['title'] || $args['title_left'] || $args['title_right'] ) {
			$r .= "</thead>\n";
		}

		$r .= "<tbody>\n$diff\n</tbody>\n";
		$r .= '</table>';

		$r .= '</div>';
		$r .= '</div>';

		return $r;
	}

	/**
	 * Interpolates context values into the message placeholders.
	 *
	 * @param string $message Message with placeholders.
	 * @param array  $context Context values to replace placeholders with.
	 * @param object $row Currently not always passed, because loggers need to be updated to support this...
	 */
	public static function interpolate( $message, $context = array(), $row = null ) {
		if ( ! is_array( $context ) ) {
			return $message;
		}

		/**
		 * Filters the context used to create the message from the message template.
		 * Can be used to modify the variables sent to the message template.
		 *
		 * @example Example that modifies the parameters sent to the message template.
		 *
		 * This example will change the post type from "post" or "page" or similar to "my own page type".
		 *
		 *  ```php
		 *  add_filter(
		 *      'simple_history/logger/interpolate/context',
		 *      function ( $context, $message, $row ) {
		 *
		 *          if ( empty( $row ) ) {
		 *              return $context;
		 *          }
		 *
		 *          if ( $row->logger == 'SimplePostLogger' && $row->context_message_key == 'post_updated' ) {
		 *              $context['post_type'] = 'my own page type';
		 *          }
		 *
		 *          return $context;
		 *      },
		 *      10,
		 *      3
		 *  );
		 * ```
		 *
		 * @since 2.2.4
		 *
		 * @param array $context
		 * @param string $message
		 * @param object $row The row. Not supported by all loggers.
		 */
		$context = apply_filters(
			'simple_history/logger/interpolate/context',
			$context,
			$message,
			$row
		);

		// Build a replacement array with braces around the context keys.
		$replace = array();
		foreach ( $context as $key => $val ) {
			// key ok.

			if ( ! is_string( $val ) && ! is_numeric( $val ) ) {
				// not a value we can replace.
				continue;
			}

			$replace[ '{' . $key . '}' ] = $val;
		}

		// Interpolate replacement values into the message and return.
		return strtr( $message, $replace );
	}

	/**
	 * Get the current screen object.
	 * Returns an object with all attributes empty if functions is not found or if function
	 * returns null. Makes it easier to use get_current_screen when we don't have to
	 * check for function existence and or null.
	 *
	 * @return WP_Screen|Object Current screen object or object with empty attributes when screen not defined.
	 */
	public static function get_current_screen() {
		if ( function_exists( 'get_current_screen' ) ) {
			$current_screen = get_current_screen();

			if ( $current_screen instanceof \WP_Screen ) {
				return $current_screen;
			}
		}

		// No screen found, return object with same properties but with empty values.
		return (object) array(
			'action'          => null,
			'base'            => null,
			'id'              => null,
			'is_network'      => null,
			'is_user'         => null,
			'parent_base'     => null,
			'parent_file'     => null,
			'post_type'       => null,
			'taxonomy'        => null,
			'is_block_editor' => null,
		);
	}

	/**
	 * Ensures an ip address is both a valid IP and does not fall within
	 * a private network range or is within reserved range.
	 *
	 * @param string $ip IP number.
	 * @return bool
	 */
	public static function is_valid_public_ip( $ip ) {
		return filter_var(
			$ip,
			FILTER_VALIDATE_IP,
			FILTER_FLAG_IPV4 | FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
		) !== false;
	}

	/**
	 * Works like json_encode, but adds JSON_PRETTY_PRINT.
	 *
	 * @param mixed $value array|object|string|whatever that is json_encode'able.
	 */
	public static function json_encode( $value ) {
		// phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode -- Before switching to wp_json_encode we need to verify that it's safe to use.
		return json_encode( $value, JSON_PRETTY_PRINT );
	}

	/**
	 * Returns array with headers that may contain user IP address.
	 *
	 * @since 2.0.29
	 *
	 * @return array
	 */
	public static function get_ip_number_header_names() {
		$headers = array(
			'HTTP_CLIENT_IP',
			'HTTP_X_FORWARDED_FOR',
			'HTTP_X_FORWARDED',
			'HTTP_X_CLUSTER_CLIENT_IP',
			'HTTP_FORWARDED_FOR',
			'HTTP_FORWARDED',
		);

		/**
		 * Filters the array with HTTP headers that may contain user IP address.
		 *
		 * @param array $headers
		 */
		$headers = apply_filters(
			'simple_history/ip_number_header_names',
			$headers,
		);

		return $headers;
	}

	/**
	 * Returns the context key prefix that addresses from a given header are stored under.
	 *
	 * Logger::append_remote_addr_to_context() stores each address found in a
	 * forwarding header as "<prefix><n>" — the index suffix exists because a
	 * single header can carry several comma separated addresses.
	 *
	 * This is the one place that knows the convention. The writer, the display
	 * path (get_event_ip_number_headers()) and the query (Log_Query) all build
	 * from it, so the shape can change here without them drifting apart.
	 *
	 * @since 5.30.0
	 *
	 * @param string $header_name Header name, e.g. "HTTP_X_FORWARDED_FOR".
	 * @return string Context key prefix, e.g. "_server_http_x_forwarded_for_".
	 */
	public static function get_ip_address_context_key_prefix( $header_name ) {
		return '_server_' . strtolower( $header_name ) . '_';
	}

	/**
	 * Returns the context key prefixes that hold an IP address for an event.
	 *
	 * A context key holds an IP address if it equals one of these or begins with
	 * one: the address the web server saw is stored as "_server_remote_addr", and
	 * forwarding header addresses under the per-header prefixes.
	 *
	 * Prefixes rather than exact keys, so callers do not have to guess how many
	 * addresses a header supplied. Note that matching on "_server_http_" alone
	 * would be wrong: keys such as "_server_http_referer" and
	 * "_server_http_user_agent" share that prefix and hold no IP address.
	 *
	 * Derived from get_ip_number_header_names(), so the
	 * 'simple_history/ip_number_header_names' filter applies here too and
	 * additional headers stay queryable.
	 *
	 * @since 5.30.0
	 *
	 * @return array<string> Context key prefixes.
	 */
	public static function get_ip_address_context_key_prefixes() {
		$prefixes = array( '_server_remote_addr' );

		foreach ( self::get_ip_number_header_names() as $header_name ) {
			$prefixes[] = self::get_ip_address_context_key_prefix( $header_name );
		}

		return $prefixes;
	}

	/**
	 * Returns true if $haystack ends with $needle
	 *
	 * @param string $haystack String to check.
	 * @param string $needle String to check if $haystack ends with.
	 */
	public static function ends_with( $haystack, $needle ) {
		return $needle === substr( $haystack, -strlen( $needle ) );
	}

	/**
	 * Get the Incrementor value for the cache.
	 *
	 * Based on code from https://www.tollmanz.com/invalidation-schemes/.
	 *
	 * @param bool $refresh Pass true to invalidate the cache.
	 * @return string Incrementor value, example: `68c1c8545881b`.
	 */
	public static function get_cache_incrementor( $refresh = false ) {
		$incrementor_key   = 'simple_history_incrementor';
		$incrementor_value = wp_cache_get( $incrementor_key );

		// Generate a new incrementor if it doesn't exist or if we want to refresh it.
		if ( $incrementor_value === false || $refresh ) {
			$incrementor_value = uniqid();
			wp_cache_set( $incrementor_key, $incrementor_value );
		}

		return $incrementor_value;
	}

	/**
	 * Get the cache group for the cache.
	 * Used by all function that use cache, so they use the
	 * same cache group, meaning if we invalidate the cache group
	 * all caches will be cleared/flushed.
	 *
	 * @return string
	 */
	public static function get_cache_group() {
		return 'simple-history-' . self::get_cache_incrementor();
	}

	/**
	 * Clears the cache.
	 */
	public static function clear_cache() {
		self::get_cache_incrementor( true );
	}

	/**
	 * Return a name for a callable.
	 *
	 * Examples of return values:
	 * - WP_REST_Posts_Controller::get_items
	 * - WP_REST_Users_Controller::get_items"
	 * - WP_REST_Server::get_index
	 * - Redirection_Api_Redirect::route_bulk
	 * - wpcf7_rest_create_feedback
	 * - closure
	 *
	 * Function based on code found on stack overflow:
	 * https://stackoverflow.com/questions/34324576/print-name-or-definition-of-callable-in-php
	 *
	 * @param callable $callback The callable thing to check.
	 * @return string Name of callable.
	 */
	public static function get_callable_name( $callback ) {
		if ( is_string( $callback ) ) {
			return trim( $callback );
		}

		if ( is_array( $callback ) ) {
			if ( is_object( $callback[0] ) ) {
				return sprintf( '%s::%s', get_class( $callback[0] ), trim( $callback[1] ) );
			}

			return sprintf( '%s::%s', trim( $callback[0] ), trim( $callback[1] ) );
		}

		if ( $callback instanceof \Closure ) {
			return 'closure';
		}

		return 'unknown';
	}

	/**
	 * Get number of rows, size, charset and collation of each Simple History table.
	 *
	 * @return array<string, array{table_name: string, size_in_mb: float|string, num_rows: int, charset: string, collation: string}>
	 */
	public static function get_db_table_stats() {
		switch ( Log_Query::get_db_engine() ) {
			case 'mysql':
				return self::get_db_table_stats_mysql();
			case 'sqlite':
				return self::get_db_table_stats_sqlite();
			default:
				return [];
		}
	}

	/**
	 * Get number of rows, size, charset and collation of each Simple History table.
	 *
	 * Uses sqlite_master to check if tables exist (always available),
	 * then tries dbstat for size info (may not be available in wp-playground).
	 *
	 * SQLite stores all text as UTF-8 internally and has no per-table charset
	 * concept; charset is reported as 'UTF-8' and collation as 'N/A' so callers
	 * always get the same shape regardless of database engine.
	 *
	 * @return array<string, array{table_name: string, size_in_mb: float|string, num_rows: int, charset: string, collation: string}>
	 */
	public static function get_db_table_stats_sqlite() {
		/** @var \wpdb $wpdb */
		global $wpdb;
		$simple_history = Simple_History::get_instance();

		$events_table   = $simple_history->get_events_table_name();
		$contexts_table = $simple_history->get_contexts_table_name();

		// Check if tables exist using sqlite_master (always available).
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$events_exists = $wpdb->get_var(
			$wpdb->prepare(
				"SELECT name FROM sqlite_master WHERE type='table' AND name=%s",
				$events_table
			)
		);

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$contexts_exists = $wpdb->get_var(
			$wpdb->prepare(
				"SELECT name FROM sqlite_master WHERE type='table' AND name=%s",
				$contexts_table
			)
		);

		// Bail if any of the tables are missing.
		if ( ! $events_exists || ! $contexts_exists ) {
			return [];
		}

		// Try to get sizes using dbstat. The virtual table is only present
		// when SQLite was built with SQLITE_ENABLE_DBSTAT_VTAB — wp-playground's
		// PHP.wasm SQLite is not. Suppress wpdb errors around the queries so
		// `WP_DEBUG` doesn't dump the "no such table: dbstat" notice; null
		// returns are handled below (falls back to "N/A").
		$suppress_errors = $wpdb->suppress_errors();

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$events_size_result = $wpdb->get_var(
			$wpdb->prepare(
				'SELECT SUM(pgsize) / 1024.0 / 1024.0 FROM dbstat WHERE name = %s',
				$events_table
			)
		);

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$contexts_size_result = $wpdb->get_var(
			$wpdb->prepare(
				'SELECT SUM(pgsize) / 1024.0 / 1024.0 FROM dbstat WHERE name = %s',
				$contexts_table
			)
		);

		$wpdb->suppress_errors( $suppress_errors );

		// Use results if available, otherwise show N/A.
		$events_size   = $events_size_result !== null ? round( (float) $events_size_result, 2 ) : 'N/A';
		$contexts_size = $contexts_size_result !== null ? round( (float) $contexts_size_result, 2 ) : 'N/A';

		// Get row counts (always works).
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$events_rows = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$events_table}" );

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$contexts_rows = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$contexts_table}" );

		// SQLite stores all text as UTF-8 internally; there's no per-table
		// charset or collation the way MySQL has, so report the encoding once
		// and mark collation as not applicable.
		$sqlite_charset   = 'UTF-8';
		$sqlite_collation = 'N/A';

		return [
			'simple_history'          => [
				'table_name' => $events_table,
				'size_in_mb' => $events_size,
				'num_rows'   => $events_rows,
				'charset'    => $sqlite_charset,
				'collation'  => $sqlite_collation,
			],
			'simple_history_contexts' => [
				'table_name' => $contexts_table,
				'size_in_mb' => $contexts_size,
				'num_rows'   => $contexts_rows,
				'charset'    => $sqlite_charset,
				'collation'  => $sqlite_collation,
			],
		];
	}

	/**
	 * Get number of rows, size, charset and collation of each Simple History table.
	 *
	 * @return array<string, array{table_name: string, size_in_mb: float|string, num_rows: int, charset: string, collation: string}>
	 */
	public static function get_db_table_stats_mysql() {
		global $wpdb;
		$simple_history = Simple_History::get_instance();

		// Pull size + collation from information_schema.TABLES. The collation
		// (TABLE_COLLATION) gives us both the collation and the charset — the
		// charset is the prefix up to the first underscore (e.g.
		// `utf8mb4_unicode_520_ci` → `utf8mb4`).
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$table_size_result = $wpdb->get_results(
			$wpdb->prepare(
				'
					SELECT table_name AS "table_name",
					round(((data_length + index_length) / 1024 / 1024), 2) "size_in_mb",
					data_length AS "data_length",
					table_collation AS "collation"
					FROM information_schema.TABLES
					WHERE table_schema = %s
					AND table_name IN (%s, %s);
					',
				DB_NAME, // 1
				$simple_history->get_events_table_name(), // 2
				$simple_history->get_contexts_table_name() // 3
			),
			ARRAY_A
		);

		// Bail if not exactly two tables found.
		if ( count( $table_size_result ) !== 2 ) {
			return [];
		}

		// information_schema.TABLES has no guaranteed row order, so look up
		// each table by name rather than trusting positional index.
		$by_name = [];

		foreach ( $table_size_result as $row ) {
			$by_name[ $row['table_name'] ] = $row;
		}

		$events_table   = $simple_history->get_events_table_name();
		$contexts_table = $simple_history->get_contexts_table_name();

		if ( ! isset( $by_name[ $events_table ], $by_name[ $contexts_table ] ) ) {
			return [];
		}

		$table_size_result = [
			'simple_history'          => $by_name[ $events_table ],
			'simple_history_contexts' => $by_name[ $contexts_table ],
		];

		foreach ( $table_size_result as &$table ) {
			$collation          = (string) ( $table['collation'] ?? '' );
			$table['collation'] = $collation !== '' ? $collation : 'N/A';
			// Charset is the prefix before the first underscore in the
			// collation name (e.g. utf8mb4_unicode_520_ci -> utf8mb4). Some
			// collations like `binary` have no underscore — fall back to the
			// full collation name in that case.
			$charset_prefix   = $collation !== '' ? strstr( $collation, '_', true ) : '';
			$table['charset'] = $charset_prefix !== false && $charset_prefix !== ''
				? $charset_prefix
				: ( $collation !== '' ? $collation : 'N/A' );
		}

		unset( $table );

		// Get num of rows for each table.
		$table_size_result['simple_history']['num_rows'] = (int) $wpdb->get_var( "select count(*) FROM {$simple_history->get_events_table_name()}" ); // phpcs:ignore
		$table_size_result['simple_history_contexts']['num_rows'] = (int) $wpdb->get_var( "select count(*) FROM {$simple_history->get_contexts_table_name()}" ); // phpcs:ignore

		return $table_size_result;
	}

	/**
	 * Disable logging of a taxonomy, if a condition is met.
	 *
	 * An easier method that using filters manually each time.
	 *
	 * @param string $taxonomy_slug Slug of taxonomy to disable logging for.
	 * @param bool   $disable Pass true to disable logging of $taxonomy.
	 */
	public static function disable_taxonomy_log( $taxonomy_slug, $disable = false ) {
		// Bail if taxonomy should not be disabled.
		if ( ! $disable ) {
			return;
		}

		add_filter(
			'simple_history/categories_logger/skip_taxonomies',
			function ( $taxononomies_to_skip ) use ( $taxonomy_slug ) {
				$taxononomies_to_skip[] = $taxonomy_slug;
				return $taxononomies_to_skip;
			},
		);
	}

	/**
	 * Retrieve the avatar for a user who provided a user ID or email address.
	 * A modified version of the function that comes with WordPress, but we
	 * want to allow/show gravatars even if they are disabled in discussion settings
	 *
	 * @since 2.0
	 * @since 3.3 Respects gravatar setting in discussion settings.
	 *
	 * @param string       $email email address.
	 * @param string       $size Size of the avatar image.
	 * @param string       $default_value URL to a default image to use if no avatar is available.
	 * @param string|false $alt Alternative text to use in image tag. Defaults to blank.
	 * @param array        $args Avatar arguments.
	 * @return string The img element for the user's avatar
	 */
	public static function get_avatar( $email, $size = '96', $default_value = '', $alt = false, $args = array() ) {
		$args = array(
			'force_display' => false,
		);

		/**
		 * Filter to control if avatars should be displayed, even if the show_avatars option
		 * is set to false in WordPress discussion settings.
		 *
		 * @since 3.3.0
		 *
		 * @example Force display of Gravatars
		 *
		 * ```php
		 *  add_filter(
		 *      'simple_history/show_avatars',
		 *      function ( $force ) {
		 *          $force = true;
		 *          return $force;
		 *      }
		 *  );
		 * ```
		 *
		 * @param bool $force_display Force display. Default false.
		 */
		$args['force_display'] = apply_filters( 'simple_history/show_avatars', $args['force_display'] );

		return get_avatar( $email, $size, $default_value, $alt, $args );
	}

	/**
	 * Function that converts camelCase to snake_case.
	 * Used to map old functions to new ones.
	 *
	 * @since 4.3.0
	 * @param string $input For example "getLogRowHtmlOutput".
	 * @return string Modified to for example "get_log_row_html_output".
	 */
	public static function camel_case_to_snake_case( $input ) {
		return strtolower( preg_replace( '/(?<!^)[A-Z]/', '_$0', $input ) );
	}

	/**
	 * Convert a snake_case slug to a "Sentence case" label.
	 *
	 * Useful for rendering stored type/category slugs (e.g. `ai_provider`,
	 * `spam_filtering`) as something readable when no explicit translation
	 * exists. Capitalises only the first character; the rest stays lowercase
	 * so multi-word slugs read naturally ("Spam filtering", not
	 * "Spam Filtering").
	 *
	 * @since 5.28.0
	 *
	 * @param string $input snake_case identifier.
	 * @return string
	 */
	public static function snake_case_to_sentence_case( $input ) {
		return ucfirst( str_replace( '_', ' ', (string) $input ) );
	}

	/**
	 * Return a low-information identifier for a secret value, safe to log.
	 *
	 * Returns the last `$visible_suffix` characters of `$secret`, suitable for
	 * recording which credential changed without exposing the credential itself.
	 *
	 * Returns `null` when there is no value (empty input), when no characters
	 * can be safely exposed (`$visible_suffix < 1`), or when the secret is too
	 * short to expose a suffix without effectively revealing the whole value
	 * (length <= $visible_suffix). Callers should treat `null` as "do not
	 * store a partial identifier" and record only the fact of presence/length.
	 *
	 * Use for API keys, tokens, passwords — anything that should never appear
	 * verbatim in event context, debug output, or downstream UI.
	 *
	 * @param string $secret         The secret to identify. Non-strings are cast.
	 * @param int    $visible_suffix Number of trailing characters to expose.
	 *                               Defaults to 4. Values < 1 force null.
	 * @return string|null
	 */
	public static function mask_secret( $secret, $visible_suffix = 4 ) {
		$secret = (string) $secret;
		$length = strlen( $secret );

		if ( $length === 0 || $visible_suffix < 1 || $length <= $visible_suffix ) {
			return null;
		}

		return substr( $secret, -$visible_suffix );
	}

	/**
	 * Format a masked-secret suffix for human-readable display.
	 *
	 * Produces a credential-style string like `••••XXXX`: a run of Unicode
	 * bullets (U+2022) followed by the visible suffix. The bullet run is
	 * capped at `$masked_length` to avoid runaway-width strings when keys
	 * happen to be very long (the original length isn't material — the point
	 * is "looks like a credential"). Matches the visual convention used by
	 * Stripe, GitHub, OpenAI, and WordPress 7.0's own Connectors page.
	 *
	 * Returns the empty string when `$suffix` is empty so callers can render
	 * "no value" cleanly.
	 *
	 * @param string|null $suffix        Suffix to display (typically from `mask_secret()`).
	 * @param int         $masked_length Number of bullets to prepend. Defaults to 12.
	 * @return string
	 */
	public static function format_masked_secret_for_display( $suffix, $masked_length = 12 ) {
		$suffix = (string) ( $suffix ?? '' );

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

		$masked_length = max( 0, (int) $masked_length );

		return str_repeat( "\u{2022}", $masked_length ) . $suffix;
	}

	/**
	 * Anonymize IP-address using the WordPress function wp_privacy_anonymize_ip(),
	 * with addition that it replaces the last 0 with a "x" so
	 * users hopefully understand that it is a modified IP-address
	 * that is anonymized.
	 *
	 * @param string $ip_address IP-address to anonymize.
	 * @return string
	 */
	public static function privacy_anonymize_ip( $ip_address ) {
		/**
		 * Filter to control if ip addresses should be anonymized or not.
		 * Defaults to true, meaning that any IP address is anonymized.
		 *
		 * @example Disable IP anonymization.
		 *
		 * ```php
		 * add_filter( 'simple_history/privacy/anonymize_ip_address', '__return_false' );
		 * ```
		 *
		 * @since 2.22
		 *
		 * @param bool $do_anonymize true to anonymize ip address, false to keep original ip address.
		 */
		$anonymize_ip_address = apply_filters(
			'simple_history/privacy/anonymize_ip_address',
			true
		);

		if ( ! $anonymize_ip_address ) {
			return $ip_address;
		}

		$ip_address = wp_privacy_anonymize_ip( $ip_address );

		$add_char = apply_filters(
			'simple_history/privacy/add_char_to_anonymized_ip_address',
			true
		);

		$is_ipv4 = ( substr_count( $ip_address, '.' ) === 3 );
		if ( $add_char && $is_ipv4 ) {
			$ip_address = preg_replace( '/\.0$/', '.x', $ip_address );
		}

		return $ip_address;
	}

	/**
	 * Static function that receives a possible anonymized IP-address
	 * and returns a valid one, e.g. the last '.x' replaced with '.0'.
	 *
	 * Used when fetching IP-address info from ipinfo.io, or API call
	 * will fail due to malformed IP address.
	 *
	 * @param string $ip_address IP-address to get valid IP-address from.
	 * @return string
	 */
	public static function get_valid_ip_address_from_anonymized( $ip_address ) {
		$ip_address = preg_replace( '/\.x$/', '.0', $ip_address );
		return $ip_address;
	}

	/**
	 * Validate an IP address string for use in event queries.
	 *
	 * Accepts IPv4 addresses (with optional ".x" anonymized octets)
	 * and IPv6 addresses.
	 *
	 * @param string $value IP address to validate.
	 * @return bool True if the value is a valid IP address format.
	 */
	public static function is_valid_ip_address_filter( $value ) {
		// IPv4: 1-3 digits per octet, last two octets may be "x" for anonymized IPs.
		$ipv4_pattern = '/^\d{1,3}\.\d{1,3}\.[\dx]{1,3}\.[\dx]{1,3}$/';
		// IPv6: standard hex:colon notation.
		$ipv6_pattern = '/^[0-9a-fA-F:]+$/';

		return (bool) preg_match( $ipv4_pattern, $value ) || (bool) preg_match( $ipv6_pattern, $value );
	}

	/**
	 * Check if debug logging is enabled.
	 * Used by loggers to check if they should log debug messages or not.
	 *
	 * @return bool True if debug logging is enabled.
	 */
	public static function log_debug_is_enabled() {
		return defined( 'SIMPLE_HISTORY_LOG_DEBUG' ) && \SIMPLE_HISTORY_LOG_DEBUG;
	}

	/**
	 * Used by the developer of the plugin to test things.
	 * Check if Simple History dev mode is enabled.
	 *
	 * @return bool True if dev mode is enabled.
	 */
	public static function dev_mode_is_enabled() {
		return defined( 'SIMPLE_HISTORY_DEV' ) && \SIMPLE_HISTORY_DEV;
	}

	/**
	 * Wrapper around WordPress function is_plugin_active()
	 * that loads the required files if function does not exist.
	 *
	 * @param string $plugin_file_path Path to plugin file, relative to plugins dir. I.e. "simple-history/simple-history.php".
	 * @return bool True if plugin is active.
	 */
	public static function is_plugin_active( $plugin_file_path ) {
		if ( ! function_exists( 'is_plugin_active' ) ) {
			require_once ABSPATH . 'wp-admin/includes/plugin.php';
		}

		return is_plugin_active( $plugin_file_path );
	}

	/**
	 * Returns additional headers with ip numbers found in context.
	 * Additional = headers that are not the main ip number header.
	 *
	 * @since 2.0.29
	 * @param object $row Row with info.
	 * @return array Headers
	 */
	public static function get_event_ip_number_headers( $row ) {
		$ip_header_names_keys            = self::get_ip_number_header_names();
		$arr_found_additional_ip_headers = array();
		$context                         = $row->context;

		foreach ( $ip_header_names_keys as $one_ip_header_key ) {
			// Addresses are stored as "<prefix><n>", e.g.
			// _server_http_x_forwarded_for_0, _server_http_x_forwarded_for_1, ...
			$key_prefix = self::get_ip_address_context_key_prefix( $one_ip_header_key );

			foreach ( $context as $context_key => $context_val ) {
				if ( ! preg_match( '/^' . preg_quote( $key_prefix, '/' ) . '\d/', $context_key ) ) {
					continue;
				}

				$arr_found_additional_ip_headers[ $context_key ] = $context_val;
			}
		}

		return $arr_found_additional_ip_headers;
	}

	/**
	 * Sanitize checkbox inputs with value "0" or "1",
	 * so unchecked boxes get value 0 instead of null.
	 * To be used as sanitization callback for register_setting().
	 *
	 * @param string $field value of the field.
	 * @return string "1" if enabled, "0" if disabled.
	 */
	public static function sanitize_checkbox_input( $field ) {
		return $field === '1' ? '1' : '0';
	}

	/**
	 * Get shortname for a class,
	 * i.e. the unqualified class name.
	 * Example get_class_short_name( Simple_History\Services\Loggers_Loader )
	 * returns 'Loggers_Loader'.
	 *
	 * Solution from:
	 * https://stackoverflow.com/a/27457689
	 *
	 * @param object $instance Object instance to get short name for.
	 * @return string
	 */
	public static function get_class_short_name( $instance ) {
		return substr( strrchr( get_class( $instance ), '\\' ), 1 );
	}

	/**
	 * Check if the db tables required by Simple History exists.
	 *
	 * @return array Array with info about the tables and their existence.
	 */
	public static function required_tables_exist() {
		global $wpdb;

		$simple_history_instance = Simple_History::get_instance();

		$tables = array(
			[
				'table_name'   => $simple_history_instance->get_events_table_name(),
				'table_exists' => null,
			],
			[
				'table_name'   => $simple_history_instance->get_contexts_table_name(),
				'table_exists' => null,
			],
		);

		foreach ( $tables as $key => $table ) {
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
			$table_exists                   = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table['table_name'] ) );
			$tables[ $key ]['table_exists'] = $table_exists;
		}

		return $tables;
	}

	/**
	 * Output title for settings section title.
	 * with wrapper classes and markup + classes for icon appended.
	 *
	 * @param string  $title Title.
	 * @param ?string $icon_class_suffix Icon class suffix.
	 * @param ?string $title_suffix Optional HTML suffix (e.g., a premium badge). Must be pre-escaped.
	 * @return string
	 */
	public static function get_settings_section_title_output( $title, $icon_class_suffix = null, $title_suffix = null ) {
		$icon_output = '';

		if ( ! is_null( $icon_class_suffix ) ) {
			$icon_output = sprintf(
				'<span class="sh-SettingsPage-settingsSection-icon sh-Icon--%1$s"></span>',
				esc_attr( $icon_class_suffix )
			);
		}

		$suffix_output = '';
		if ( ! is_null( $title_suffix ) ) {
			// Suffix is expected to be pre-escaped HTML (e.g., a premium badge).
			$suffix_output = ' ' . $title_suffix;
		}

		return sprintf(
			'
			<span class="sh-SettingsPage-settingsSection-title">
				%2$s
				%1$s%3$s
			</span>
			',
			esc_html( $title ),
			$icon_output,
			$suffix_output
		);
	}

	/**
	 * Output title for settings field title.
	 * with wrapper classes and markup + classes for icon appended.
	 *
	 * @param string  $title Title.
	 * @param ?string $icon_class_suffix Icon class suffix.
	 * @param ?string $label_for Optional. The id of the input element to associate the label with.
	 *                           When provided, the title becomes a clickable label.
	 * @return string
	 */
	public static function get_settings_field_title_output( $title, $icon_class_suffix = null, $label_for = null ) {
		$icon_output = '';

		if ( ! is_null( $icon_class_suffix ) ) {
			$icon_output = sprintf(
				'<span class="sh-SettingsPage-settingsField-icon sh-Icon--%1$s"></span>',
				esc_attr( $icon_class_suffix )
			);
		}

		// If $label_for is provided, wrap title in a label element.
		if ( ! is_null( $label_for ) ) {
			$title_output = sprintf(
				'<label for="%1$s">%2$s</label>',
				esc_attr( $label_for ),
				esc_html( $title )
			);
		} else {
			$title_output = esc_html( $title );
		}

		return sprintf(
			'
			<span class="sh-SettingsPage-settingsField">
				%2$s
				%1$s
			</span>
			',
			$title_output,
			$icon_output
		);
	}

	/**
	 * Wrapper for \add_settings_section with added support for:
	 * - Icon before title.
	 * - Wrapper div automatically added.
	 * - Optional HTML ID attribute.
	 * - Optional HTML suffix (e.g., a premium badge).
	 *
	 * @param string       $id Slug-name to identify the section. Used in the 'id' attribute of tags.
	 * @param string|array $title Formatted title of the section. Shown as the heading for the section.
	 *                     Pass in array instead of string to use as ['Section title', 'icon-slug'].
	 *                     Or pass ['Section title', 'icon-slug', 'html-id'] to include an HTML ID attribute.
	 *                     Or pass ['Section title', 'icon-slug', 'html-id', 'suffix'] to include a suffix (e.g., premium badge).
	 * @param callable     $callback_top Function that echos out any content at the top of the section (between heading and fields).
	 * @param string       $page The slug-name of the settings page on which to show the section. Built-in pages include 'general', 'reading', 'writing', 'discussion', 'media', etc. Create your own using add_options_page().
	 * @param array        $args {
	 *     Optional. Additional arguments.
	 *
	 *     @type callable $callback_last Function that echos out any content at the end of the section (before closing wrapper div).
	 *     @type bool     $no_card       If true, removes the card styling (sh-SettingsCard class). Default false.
	 * }
	 */
	public static function add_settings_section( $id, $title, $callback_top, $page, $args = [] ) {
		// If title is array then it can be [title, icon-slug, html-id, suffix].
		$html_id = '';
		if ( is_array( $title ) ) {
			$title_text   = $title[0];
			$icon_slug    = $title[1] ?? null;
			$html_id      = $title[2] ?? '';
			$title_suffix = $title[3] ?? null;
			$title        = self::get_settings_section_title_output( $title_text, $icon_slug, $title_suffix );
		} else {
			$title = self::get_settings_section_title_output( $title );
		}

		/**
		 * In WP 6.7 after section is not always outputted.
		 * See track tickets:
		 * https://core.trac.wordpress.org/ticket/62746
		 * https://core.trac.wordpress.org/changeset/59564
		 */
		$id_attribute = '';
		if ( ! empty( $html_id ) ) {
			$id_attribute = sprintf( ' id="%s"', esc_attr( $html_id ) );
		}

		// Build after_section content.
		$after_section_content = '';
		$callback_last         = $args['callback_last'] ?? null;
		if ( is_callable( $callback_last ) ) {
			ob_start();
			call_user_func( $callback_last );
			$after_section_content = ob_get_clean();
		}
		$after_section_content .= '</div>';

		// Build wrapper class - optionally exclude card styling.
		$no_card       = $args['no_card'] ?? false;
		$wrapper_class = $no_card ? 'sh-SettingsPage-settingsSection-wrap' : 'sh-SettingsCard sh-SettingsPage-settingsSection-wrap';

		$section_args = [
			'before_section' => sprintf( '<div class="%s"%s>', esc_attr( $wrapper_class ), $id_attribute ),
			'after_section'  => $after_section_content,
		];

		add_settings_section( $id, $title, $callback_top, $page, $section_args );
	}

	/**
	 *  Add link to add-ons.
	 *
	 * @return string HTML for link to add-ons.
	 */
	public static function get_header_add_ons_link() {
		ob_start();

		?>
		<div class="sh-PageHeader-rightLink">
			<a href="<?php echo esc_url( self::get_tracking_url( 'https://simple-history.com/add-ons/', 'premium_header_addons' ) ); ?>" target="_blank">
				<span class="sh-PageHeader-settingsLinkIcon sh-Icon sh-Icon--extension"></span>
				<span class="sh-PageHeader-settingsLinkText"><?php esc_html_e( 'Add-ons', 'simple-history' ); ?></span>
			</a>
		</div>
		<?php

		return ob_get_clean();
	}

	/**
	 * Output the settings gear link for the header's left title group.
	 *
	 * Sits next to the feature-discovery bar so the settings root and its
	 * section deep-links read as one group. Rendered independently of the
	 * bar, so it remains visible even when the bar self-hides.
	 *
	 * @return string HTML for the settings gear link.
	 */
	public static function get_header_settings_link() {
		// Viewing the log and viewing the settings are separate capabilities.
		// Editors reach the log by default but not the settings page, so
		// without this they are offered a gear that lands on a permission
		// error. The divider is a border on the link itself, so dropping the
		// link leaves no stray separator behind.
		// phpcs:ignore WordPress.WP.Capabilities.Undetermined -- Dynamic capability from Helpers::get_view_settings_capability().
		if ( ! current_user_can( self::get_view_settings_capability() ) ) {
			return '';
		}

		$settings_url = Menu_Manager::get_admin_url_by_slug( Simple_History::SETTINGS_MENU_PAGE_SLUG );

		ob_start();

		?>
		<a href="<?php echo esc_url( $settings_url ); ?>" class="sh-PageHeader-settingsIcon">
			<span class="dashicons dashicons-admin-generic"></span>
			<span class="sh-PageHeader-settingsIconText"><?php esc_html_e( 'Settings', 'simple-history' ); ?></span>
		</a>
		<?php

		return ob_get_clean();
	}

	/**
	 * Output the top-right header area with Premium status or CTA.
	 *
	 * @return string HTML for the premium header link.
	 */
	public static function get_header_premium_link() {
		ob_start();

		?>
		<div class="sh-PageHeader-rightLink">
			<?php if ( self::is_premium_add_on_active() ) { ?>
				<span class="sh-PageHeader-headerBtn sh-PageHeader-headerBtn--premiumActive">
					<span class="dashicons dashicons-star-filled"></span>
					<?php esc_html_e( 'Premium active', 'simple-history' ); ?>
				</span>
			<?php } else { ?>
				<a href="<?php echo esc_url( self::get_tracking_url( 'https://simple-history.com/premium/', 'premium_header_cta' ) ); ?>" class="sh-PageHeader-headerBtn sh-PageHeader-headerBtn--getPremium" target="_blank">
					<span class="dashicons dashicons-star-filled"></span>
					<?php esc_html_e( 'Get Premium', 'simple-history' ); ?>
				</a>
			<?php } ?>
		</div>
		<?php

		return ob_get_clean();
	}

	/**
	 * Gets the pager size,
	 * i.e. the number of items to show on each page in the history
	 *
	 * @return int
	 */
	public static function get_pager_size() {
		$pager_size = get_option( 'simple_history_pager_size', 20 );

		/**
		 * Filter the pager size setting
		 *
		 * @since 2.0
		 *
		 * @param int $pager_size
		 */
		$pager_size = apply_filters( 'simple_history/pager_size', $pager_size );

		/**
		 * Filter the pager size setting for the history page
		 *
		 * @since 2.0
		 *
		 * @param int $pager_size
		 */
		$pager_size = apply_filters( 'simple_history/page_pager_size', $pager_size );

		// Ensure we return a positive int to prevent type errors and division by zero.
		return max( 1, (int) $pager_size );
	}

	/**
	 * Gets the pager size for the dashboard widget,
	 * i.e. the number of items to show on each page in the history
	 *
	 * @since 2.12
	 * @return int
	 */
	public static function get_pager_size_dashboard() {
		$pager_size = get_option( 'simple_history_pager_size_dashboard', 5 );

		/**
		 * Filter the pager size setting for the dashboard.
		 *
		 * @since 2.0
		 *
		 * @param int $pager_size
		 */
		$pager_size = apply_filters( 'simple_history/dashboard_pager_size', $pager_size );

		/**
		 * Filter the pager size setting
		 *
		 * @since 2.12
		 *
		 * @param int $pager_size
		 */
		$pager_size = apply_filters( 'simple_history/pager_size_dashboard', $pager_size );

		return $pager_size;
	}

	/**
	 * Check if the current user can clear the log.
	 *
	 * @since 2.19
	 * @return bool
	 */
	public static function user_can_clear_log() {
		/**
		 * Allows controlling who can manually clear the log.
		 * When this is true then the "Clear"-button in shown in the settings.
		 * When this is false then no button is shown.
		 *
		 * @example
		 * ```php
		 *  // Remove the "Clear"-button, so a user with admin access can not clear the log
		 *  // and wipe their mischievous behavior from the log.
		 *  add_filter(
		 *      'simple_history/user_can_clear_log',
		 *      function ( $user_can_clear_log ) {
		 *          $user_can_clear_log = false;
		 *          return $user_can_clear_log;
		 *      }
		 *  );
		 * ```
		 *
		 * @param bool $allow Whether the current user is allowed to clear the log.
		*/
		// Defaults to the settings capability rather than to true. Clearing
		// truncates the entire events and contexts tables, i.e. destroys the
		// audit trail, so it must not be authorized by nonce possession alone.
		// The filter can still open it up or lock it down further.
		// phpcs:ignore WordPress.WP.Capabilities.Undetermined -- Dynamic capability from self::get_view_settings_capability().
		return apply_filters( 'simple_history/user_can_clear_log', current_user_can( self::get_view_settings_capability() ) );
	}

	/**
	 * Removes all items from the log.
	 *
	 * @return int Number of rows removed.
	 */
	public static function clear_log() {
		global $wpdb;

		$simple_history = Simple_History::get_instance();

		$simple_history_table          = $simple_history->get_events_table_name();
		$simple_history_contexts_table = $simple_history->get_contexts_table_name();

		// Get number of rows before delete.
		$sql_num_rows = "SELECT count(id) AS num_rows FROM {$simple_history_table}";
		// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$num_rows = $wpdb->get_var( $sql_num_rows, 0 );

		// Use truncate instead of delete because it's much faster (I think, writing this much later).
		$sql = "TRUNCATE {$simple_history_table}";
		// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$wpdb->query( $sql );

		$sql = "TRUNCATE {$simple_history_contexts_table}";
		// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$wpdb->query( $sql );

		self::clear_cache();

		return $num_rows;
	}

	/**
	 * How old log entries are allowed to be.
	 * 0 = don't delete old entries.
	 *
	 * @return int Number of days.
	 */
	public static function get_clear_history_interval() {
		// Default: 30 days for fresh installs, 60 days for existing installs.
		$stored_days = get_option( 'simple_history_retention_days' );
		$days        = $stored_days !== false ? (int) $stored_days : 60;

		/**
		 * Deprecated filter name, use `simple_history/db_purge_days_interval` instead.
		 * @deprecated
		 */
		$days = (int) apply_filters( 'simple_history_db_purge_days_interval', $days );

		/**
		 * Filter to modify number of days of history to keep.
		 * Default is 30 days for new installs, 60 days for existing installs.
		 *
		 * @example Keep only the most recent 7 days in the log.
		 *
		 * ```php
		 * add_filter( "simple_history/db_purge_days_interval", function( $days ) {
		 *      $days = 7;
		 *      return $days;
		 *  } );
		 * ```
		 *
		 * @example Expand the log to keep 90 days in the log.
		 *
		 * ```php
		 * add_filter( "simple_history/db_purge_days_interval", function( $days ) {
		 *      $days = 90;
		 *      return $days;
		 *  } );
		 * ```
		 *
		 * @param int $days Number of days of history to keep
		 */
		$days = apply_filters( 'simple_history/db_purge_days_interval', $days );

		return $days;
	}

	/**
	 * Return capability required to view history = for who will the History page be added.
	 * Default capability is "edit_pages".
	 *
	 * @since 2.1.5
	 * @return string capability
	 */
	public static function get_view_history_capability() {
		$view_history_capability = 'edit_pages';

		/**
		 * Deprecated, use filter `simple_history/view_history_capability` instead.
		 */
		$view_history_capability = apply_filters( 'simple_history_view_history_capability', $view_history_capability );

		/**
		 * Filter the capability required to view main simple history page, with the activity feed.
		 * Default capability is "edit_pages".
		 *
		 * @example Change the capability required to view the log to "manage options", so only allow admins are allowed to view the history log page.
		 *
		 * ```php
		 *  add_filter(
		 *      'simple_history/view_history_capability',
		 *      function ( $capability ) {
		 *          $capability = 'manage_options';
		 *          return $capability;
		 *      }
		 *  );
		 * ```
		 *
		 * @param string $view_history_capability
		 */
		$view_history_capability = apply_filters( 'simple_history/view_history_capability', $view_history_capability );

		return $view_history_capability;
	}

	/**
	 * Whether the current user may look up another user's identifying details.
	 *
	 * Scoped to lookups that answer an arbitrary user id — the user card. That
	 * endpoint could be walked from id 1 upwards to collect the login, email
	 * and roles of every account on the site, including users who never appear
	 * in the log, which is why it needs a capability of its own.
	 *
	 * Deliberately not applied to the initiator shown beside each event. That
	 * is the person who performed an event the reader is already allowed to
	 * see, so it is context for visible activity rather than a directory, and
	 * it is what makes two people sharing a display name tellable apart.
	 *
	 * Defaults to "list_users", which is what WordPress itself requires to see
	 * another user's email, and which editors do not hold.
	 *
	 * @since 5.30.0
	 * @return bool
	 */
	public static function current_user_can_view_user_pii() {
		/**
		 * Filters whether the current user may see other users' login, email and roles.
		 *
		 * @since 5.30.0
		 *
		 * @param bool $can_view Defaults to current_user_can( 'list_users' ).
		 */
		return apply_filters( 'simple_history/user_can_view_user_pii', current_user_can( 'list_users' ) );
	}

	/**
	 * Return capability required to view settings.
	 * Default capability is "manage_options",
	 * but can be modified using filter.
	 *
	 * @since 2.1.5
	 * @return string capability
	 */
	public static function get_view_settings_capability() {
		$view_settings_capability = 'manage_options';

		/**
		 * Old filter name, use `simple_history/view_settings_capability` instead.
		 */
		$view_settings_capability = apply_filters( 'simple_history_view_settings_capability', $view_settings_capability );

		/**
		 * Filters the capability required to view the settings page.
		 *
		 * @example Change capability required to view the
		 *
		 * ```php
		 *  add_filter(
		 *      'simple_history/view_settings_capability',
		 *      function ( $capability ) {
		 *
		 *          $capability = 'manage_options';
		 *          return $capability;
		 *      }
		 *  );
		 * ```
		 *
		 * @param string $view_settings_capability
		 */
		$view_settings_capability = apply_filters( 'simple_history/view_settings_capability', $view_settings_capability );

		return $view_settings_capability;
	}

	/**
	 * Check if the current page is any of the pages that belong
	 * to Simple History.
	 *
	 * Since it uses current_screen() is must be called
	 * after the 'admin_menu' action has been fired.
	 *
	 * @return bool
	 */
	public static function is_on_our_own_pages() {
		// Check if we are on an admin page with Simple History content.
		// All Simple History admin pages have a ?page=simple_history_... query arg.
		// where page is the slug of the registered page.
		$all_menu_pages_slugs = Simple_History::get_instance()->get_menu_manager()->get_all_slugs();

		// phpcs:ignore WordPress.Security.NonceVerification.Recommended
		$page = isset( $_GET['page'] ) ? sanitize_text_field( wp_unslash( $_GET['page'] ) ) : null;

		if ( $page && in_array( $page, $all_menu_pages_slugs, true ) ) {
			return true;
		}

		$current_screen = self::get_current_screen();

		// We are on a Simple History page if we are on dashboard and the setting is set to show on dashboard.
		return $current_screen->base === 'dashboard' && self::setting_show_on_dashboard();
	}

	/**
	 * Check if the database has any data, i.e. at least 1 row.
	 *
	 * @since 2.1.6
	 * @return bool True if database is not empty, false if database is empty = contains no data
	 */
	public static function db_has_data() {
		global $wpdb;

		$simple_history = Simple_History::get_instance();

		$table_name = $simple_history->get_events_table_name();

		$sql_data_exists = "SELECT id AS id_exists FROM {$table_name} LIMIT 1";
		// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		return (bool) $wpdb->get_var( $sql_data_exists, 0 );
	}

	/**
	 * Get setting if plugin should be visible on dashboard.
	 * Defaults to true
	 *
	 * @return bool
	 */
	public static function setting_show_on_dashboard() {
		$show_on_dashboard = get_option( 'simple_history_show_on_dashboard', 1 );

		$show_on_dashboard = apply_filters( 'simple_history_show_on_dashboard', $show_on_dashboard );

		/**
		 * Filter if Simple History should be shown on the dashboard.
		 *
		 * @param int $show_on_dashboard If 1 then show on dashboard, if 0 then do not show on dashboard.
		 */
		$show_on_dashboard = apply_filters( 'simple_history/show_on_dashboard', $show_on_dashboard );

		return (bool) $show_on_dashboard;
	}

	/**
	 * Should Simple History be shown as a page inside the dashboard menu.
	 *
	 * Defaults to true.
	 *
	 * @deprecated 5.7.0
	 * @return bool
	 */
	public static function setting_show_as_page() {
		$setting = get_option( 'simple_history_show_as_page', 1 );
		$setting = apply_filters( 'simple_history_show_as_page', $setting );

		return (bool) $setting;
	}

	/**
	 * Should Simple History be shown as a page in the main menu, at top level,
	 * next to pages, tools, settings, etc.
	 *
	 * Defaults to true.
	 *
	 * @return bool
	 */
	public static function setting_show_as_menu_page() {
		/**
		 * Filter if Simple History should be shown as a page in the main admin menu.
		 *
		 * @since 5.5.2
		 */
		$setting = apply_filters( 'simple_history/show_admin_menu_page', true );

		return (bool) $setting;
	}

	/**
	 * Returns the location of the main simple history menu page.
	 *
	 * Valid locations are:
	 * - 'top' = Below dashboard and Jetpack and similar top level menu items.
	 * - 'bottom' = Below settings and similar bottom level menu items.
	 * - 'inside_tools' = Inside the tools menu.
	 * - 'inside_dashboard' = Inside the settings menu.
	 *
	 * Defaults to 'top'.
	 *
	 * @return string Location of the main menu page.
	 */
	public static function get_menu_page_location() {
		$option_slug = 'simple_history_menu_page_location';
		$setting     = get_option( $option_slug );

		// If it does not exist, then default so the option can auto-load.
		if ( $setting === false ) {
			$setting = 'top';
			update_option( $option_slug, $setting, true );
		}

		/**
		 * Filter to control the placement of Simple History in the Admin Menu.
		 * Valid locations:
		 * - 'top' for placement close to dashboard at top of main menu
		 * - 'bottom' for placement near below settings
		 * - 'inside_tools' for placement inside the tools menu
		 * - 'inside_dashboard' for placement inside the dashboard menu
		 *
		 * @since 5.5.2
		 *
		 * @param string $setting Location slug.
		 */
		$setting = apply_filters( 'simple_history/admin_menu_location', $setting );

		return $setting;
	}

	/**
	 * Returns true if Simple History can be shown in the admin bar
	 *
	 * @return bool
	 */
	public static function setting_show_in_admin_bar() {
		$setting = get_option( 'simple_history_show_in_admin_bar', 1 );
		$setting = apply_filters( 'simple_history_show_in_admin_bar', $setting );

		/**
		 * Filter if Simple History should be shown in the admin bar.
		 *
		 * @since 5.5.1
		 */
		$setting = apply_filters( 'simple_history/show_in_admin_bar', $setting );

		return (bool) $setting;
	}

	/**
	 * Returns true if Detective Mode is active.
	 *
	 * Default is false.
	 *
	 * @return bool
	 */
	public static function detective_mode_is_enabled() {
		return (bool) apply_filters(
			'simple_history/detective_mode_enabled',
			get_option( 'simple_history_detective_mode_enabled', 0 )
		);
	}

	/**
	 * Returns true if Experimental Features is active.
	 *
	 * Default is false.
	 *
	 * @return bool
	 */
	public static function experimental_features_is_enabled() {
		return (bool) apply_filters(
			'simple_history/experimental_features_enabled',
			get_option( 'simple_history_experimental_features_enabled', 0 )
		);
	}

	/**
	 * Returns true if event reactions are enabled.
	 *
	 * Enabled by default; can be turned off in Settings → General. The reaction
	 * button is shown discreetly on hover, so it is unobtrusive even on
	 * single-user sites.
	 *
	 * @return bool
	 */
	public static function reactions_are_enabled() {
		return (bool) apply_filters(
			'simple_history/reactions_enabled',
			get_option( 'simple_history_reactions_enabled', '1' )
		);
	}

	/**
	 * Render a stored JSON diff (from jfcherng/php-diff JsonHtml renderer) to side-by-side HTML.
	 *
	 * @param string $json_diff_string JSON-encoded diff array.
	 * @return string HTML output, or empty string on failure.
	 */
	public static function render_json_diff_to_html( $json_diff_string ) {
		$renderer_class = 'Simple_History\Vendor\Jfcherng\Diff\Renderer\Html\SideBySide';

		if ( ! class_exists( $renderer_class ) ) {
			return '';
		}

		try {
			$diff_array = json_decode( $json_diff_string, true );

			if ( ! is_array( $diff_array ) || empty( $diff_array ) ) {
				return '';
			}

			$renderer = new $renderer_class(
				[
					'showHeader'    => false,
					'lineNumbers'   => false,
					'separateBlock' => true,
				]
			);

			$html = $renderer->renderArray( $diff_array );

			if ( empty( $html ) ) {
				return '';
			}

			// Sanitize renderer output as defense-in-depth against tampered stored data.
			$html = wp_kses_post( $html );

			return '<div class="SimpleHistory__diff__contents" tabindex="0">'
				. '<div class="SimpleHistory__diff__contentsInner">'
				. $html
				. '</div>'
				. '</div>';
		} catch ( \Exception $e ) {
			return '';
		}
	}

	/**
	 * Get number of events the last n days.
	 *
	 * Respects user permissions - only counts events from loggers the current user can view.
	 * Uses WordPress timezone for date calculations.
	 *
	 * @param int $period_days Number of days to get events for.
	 * @return int Number of events user can view.
	 */
	public static function get_num_events_last_n_days( $period_days = Date_Helper::DAYS_PER_MONTH ) {
		$cache_key   = 'num_events_last_n_days_' . $period_days . '_user_' . get_current_user_id();
		$cache_group = self::get_cache_group();
		$cached      = wp_cache_get( $cache_key, $cache_group );

		if ( $cached !== false ) {
			return (int) $cached;
		}

		global $wpdb;
		$simple_history              = Simple_History::get_instance();
		$sqlStringLoggersUserCanRead = $simple_history->get_loggers_that_user_can_read( null, 'sql' );

		$sql = sprintf(
			'
                SELECT count(*)
                FROM %1$s
                WHERE date >= FROM_UNIXTIME(%2$d)
                AND logger IN %3$s
            ',
			$simple_history->get_events_table_name(),
			Date_Helper::get_last_n_days_start_timestamp( $period_days ),
			$sqlStringLoggersUserCanRead
		);

		// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$count = (int) $wpdb->get_var( $sql );

		wp_cache_set( $cache_key, $count, $cache_group );

		return $count;
	}

	/**
	 * Get number of events today (WordPress timezone-aware).
	 *
	 * Convenience wrapper for get_num_events_last_n_days(1).
	 * Counts individual events from midnight today (00:00:00) in WordPress timezone.
	 * Respects user permissions - only counts events from loggers the current user can view.
	 *
	 * @return int Number of events today that user can view.
	 */
	public static function get_num_events_today() {
		return self::get_num_events_last_n_days( 1 );
	}

	/**
	 * Get current number of events in database.
	 *
	 * Counts all events currently stored in the database.
	 * Respects user permissions - only counts events from loggers the current user can view.
	 *
	 * @return int Number of events currently in database that user can view.
	 */
	public static function get_current_database_events_count() {
		$cache_key   = 'db_events_count_user_' . get_current_user_id();
		$cache_group = self::get_cache_group();
		$cached      = wp_cache_get( $cache_key, $cache_group );

		if ( $cached !== false ) {
			return (int) $cached;
		}

		global $wpdb;
		$simple_history              = Simple_History::get_instance();
		$sqlStringLoggersUserCanRead = $simple_history->get_loggers_that_user_can_read( null, 'sql' );

		$sql = sprintf(
			'
                SELECT count(*)
                FROM %1$s
                WHERE logger IN %2$s
            ',
			$simple_history->get_events_table_name(),
			$sqlStringLoggersUserCanRead
		);

		// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$count = (int) $wpdb->get_var( $sql );

		wp_cache_set( $cache_key, $count, $cache_group );

		return $count;
	}

	/**
	 * Get number of events per day the last n days.
	 *
	 * Respects user permissions - only counts events from loggers the current user can view.
	 * Uses WordPress timezone for date calculations.
	 *
	 * @param int $period_days Number of days to get events for.
	 * @return array Array with date as key and number of events user can view as value.
	 */
	public static function get_num_events_per_day_last_n_days( $period_days = Date_Helper::DAYS_PER_MONTH ) {
		$cache_key   = 'events_per_day_last_n_days_' . $period_days . '_user_' . get_current_user_id();
		$cache_group = self::get_cache_group();
		$cached      = wp_cache_get( $cache_key, $cache_group );

		if ( $cached !== false ) {
			return $cached;
		}

		/** @var \wpdb $wpdb */
		global $wpdb;

		$simple_history              = Simple_History::get_instance();
		$sqlStringLoggersUserCanRead = $simple_history->get_loggers_that_user_can_read( null, 'sql' );
		$db_engine                   = Log_Query::get_db_engine();

		// Get WordPress timezone offset for converting dates from GMT to local timezone.
		// Database stores dates in GMT, but we need to group by dates in WordPress timezone.
		$wp_timezone       = wp_timezone();
		$wp_offset_seconds = $wp_timezone->getOffset( new \DateTime( 'now', $wp_timezone ) );

		$sql = null;

		if ( $db_engine === 'mysql' ) {
			// Use DATE_ADD with INTERVAL to convert from GMT to WordPress timezone.
			// This is more reliable than CONVERT_TZ which requires timezone tables.
			// Note: We use seconds because timezone offsets can be fractional hours (e.g., +5:30 for India).
			$sql = sprintf(
				'
					SELECT
						date_format(DATE_ADD(date, INTERVAL %4$d SECOND), "%%Y-%%m-%%d") AS yearDate,
						count(date) AS count
					FROM
						%1$s
					WHERE
						date >= FROM_UNIXTIME(%2$d)
						AND logger IN %3$s
					GROUP BY yearDate
					ORDER BY yearDate ASC
				',
				$simple_history->get_events_table_name(),
				Date_Helper::get_last_n_days_start_timestamp( $period_days ),
				$sqlStringLoggersUserCanRead,
				$wp_offset_seconds
			);
		} elseif ( $db_engine === 'sqlite' ) {
			// SQLite: Convert from GMT to WordPress timezone by adding offset seconds.
			$sql = sprintf(
				'
					SELECT
						strftime("%%Y-%%m-%%d", datetime(date, "%4$s seconds")) AS yearDate,
						count(date) AS count
					FROM
						%1$s
					WHERE
						unixepoch(date) >= %2$d
						AND logger IN %3$s
					GROUP BY yearDate
					ORDER BY yearDate ASC
				',
				$simple_history->get_events_table_name(),
				Date_Helper::get_last_n_days_start_timestamp( $period_days ),
				$sqlStringLoggersUserCanRead,
				$wp_offset_seconds >= 0 ? "+{$wp_offset_seconds}" : $wp_offset_seconds
			);
		}

		// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$results = $wpdb->get_results( $sql );

		// Don't cache failed queries (null result on DB error).
		if ( $results !== null ) {
			wp_cache_set( $cache_key, $results, $cache_group );
		}

		return $results;
	}

	/**
	 * Get number of unique events the last n days.
	 *
	 * @param int $days Number of days to get events for.
	 * @return int Number of unique events.
	 */
	public static function get_unique_events_for_days( $days = 7 ) {
		global $wpdb;
		$simple_history = Simple_History::get_instance();

		$days       = (int) $days;
		$table_name = $simple_history->get_events_table_name();
		$cache_key  = 'sh_' . md5( __METHOD__ . $days );
		$numEvents  = get_transient( $cache_key );

		if ( $numEvents === false ) {
			// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
			$sql = $wpdb->prepare(
				"
                SELECT count( DISTINCT occasionsID )
                FROM $table_name
                WHERE date >= DATE_ADD(CURDATE(), INTERVAL -%d DAY)
            	",
				$days
			);
			// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared

			// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
			$numEvents = $wpdb->get_var( $sql );

			// Don't cache failed queries (null result when table doesn't exist).
			if ( $numEvents !== null ) {
				set_transient( $cache_key, $numEvents, HOUR_IN_SECONDS );
			}
		}

		// Always return int to prevent type errors in arithmetic operations.
		return (int) $numEvents;
	}

	/**
	 * Check if the current request is a request made from WP CLI.
	 *
	 * @return bool
	 */
	public static function is_wp_cli() {
		$is_wp_cli = defined( 'WP_CLI' ) && WP_CLI;

		/**
		 * Filter whether the current request should be treated as a WP-CLI request.
		 *
		 * Default value is `defined( 'WP_CLI' ) && WP_CLI`. Hooking this filter lets
		 * tests simulate a CLI context without defining the process-global WP_CLI
		 * constant (which can't be undefined and would leak between tests in a
		 * single PHPUnit process).
		 *
		 * @since 5.28.0
		 *
		 * @param bool $is_wp_cli Whether the current request is a WP-CLI request.
		 */
		return (bool) apply_filters( 'simple_history/is_wp_cli', $is_wp_cli );
	}

	/**
	 * Check if the current request is a REST API request.
	 *
	 * Checks REST_REQUEST (WordPress core) and REST_API_REQUEST (a
	 * Jetpack-specific constant, not a documented WordPress contract — kept
	 * for parity with pre-existing checks in the codebase).
	 *
	 * @return bool
	 */
	public static function is_rest_request() {
		$is_rest_request = ( defined( 'REST_REQUEST' ) && REST_REQUEST )
			|| ( defined( 'REST_API_REQUEST' ) && REST_API_REQUEST );

		/**
		 * Filter whether the current request should be treated as a REST API request.
		 *
		 * Default value is true when REST_REQUEST or REST_API_REQUEST is defined and
		 * truthy. Hooking this filter lets tests simulate a REST context without
		 * defining the process-global REST_REQUEST constant (which can't be undefined
		 * and would leak between tests in a single PHPUnit process).
		 *
		 * @since 5.28.0
		 *
		 * @param bool $is_rest_request Whether the current request is a REST API request.
		 */
		return (bool) apply_filters( 'simple_history/is_rest_request', $is_rest_request );
	}

	/**
	 * Calculates what to show in the date filter dropdown.
	 * Returns an array with keys and values:
	 * - "arr_days_and_pages": Array with debug info about how many days and pages to show.
	 * - "daysToShow": Optimal number of days to show, regarding to number of items in the log, to prevent the initial query from being too slow.
	 * - "result_months": Array with unique months, so the date dropdown can show only months with events.
	 *
	 * @return array
	 */
	public static function get_data_for_date_filter() {
		global $wpdb;

		$simple_history = Simple_History::get_instance();

		// Start months filter.
		$table_name                   = $simple_history->get_events_table_name();
		$loggers_user_can_read_sql_in = $simple_history->get_loggers_that_user_can_read( null, 'sql' );

		// Get unique months.
		$loggers_slugs = $simple_history->get_loggers_that_user_can_read( null, 'slugs' );
		$cache_key     = 'sh_filter_unique_months_' . md5( implode( ',', $loggers_slugs ) );
		$result_months = get_transient( $cache_key );

		if ( $result_months === false ) {
			$sql_dates = sprintf(
				'
				SELECT DISTINCT ( date_format(DATE, "%%Y-%%m") ) AS yearMonth
				FROM %s
				WHERE logger IN %s
				ORDER BY yearMonth DESC
				',
				$table_name, // 1
				$loggers_user_can_read_sql_in // 2
			);

			// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
			$result_months = $wpdb->get_results( $sql_dates );

			set_transient( $cache_key, $result_months, HOUR_IN_SECONDS );
		}

		$arr_days_and_pages = array();

		// Default month = current month
		// Mainly for performance reasons, since often
		// it's not the users intention to view all events,
		// but just the latest.

		// Determine if we limit the date range by default.
		$daysToShow = 1;

		// Start with the latest day.
		$numEvents = self::get_unique_events_for_days( $daysToShow );
		$numPages  = $numEvents / self::get_pager_size();

		$arr_days_and_pages[] = array(
			'daysToShow' => $daysToShow,
			'numPages'   => $numPages,
		);

		// Example on my server with lots of brute force attacks (causing log to not load)
		// 166434 / 15 = 11 000 pages for last 7 days
		// 1 day = 3051 / 15 = 203 pages = still much but better than 11000 pages!
		if ( $numPages < 20 ) {
			// Not that many things the last day. Let's try to expand to 7 days instead.
			$daysToShow = 7;
			$numEvents  = self::get_unique_events_for_days( $daysToShow );
			$numPages   = $numEvents / self::get_pager_size();

			$arr_days_and_pages[] = array(
				'daysToShow' => $daysToShow,
				'numPages'   => $numPages,
			);

			if ( $numPages < 20 ) {
				// Not that many things the last 7 days. Let's try to expand to 14 days instead.
				$daysToShow = 14;
				$numEvents  = self::get_unique_events_for_days( $daysToShow );
				$numPages   = $numEvents / self::get_pager_size();

				$arr_days_and_pages[] = array(
					'daysToShow' => $daysToShow,
					'numPages'   => $numPages,
				);

				if ( $numPages < 20 ) {
					// Not many things the last 14 days either. Let try with 30 days.
					$daysToShow = 30;
					$numEvents  = self::get_unique_events_for_days( $daysToShow );
					$numPages   = $numEvents / self::get_pager_size();

					$arr_days_and_pages[] = array(
						'daysToShow' => $daysToShow,
						'numPages'   => $numPages,
					);

					// If 30 days gives a big amount of pages, go back to 14 days.
					if ( $numPages > 1000 ) {
						$daysToShow = 14;
					}
				}
			}
		}

		return [
			'arr_days_and_pages' => $arr_days_and_pages,
			'daysToShow'         => $daysToShow,
			'result_months'      => $result_months,
		];
	}

	/**
	 * How often the script checks for new rows.
	 *
	 * @return int Interval in milliseconds.
	 */
	public static function get_new_events_check_interval() {
		/**
		 * Filter the interval for how often the script checks for new rows.
		 *
		 * Set to 0 to disable the notifier.
		 *
		 * @example Change the interval from the default 10000 ms (10 seconds) to a minute (300000 ms).
		 * // Change the interval from the default 10000 ms (10 seconds) to a minute (300000 ms).
		 * ```php
		 * add_filter(
		 *   'SimpleHistoryNewRowsNotifier/interval',
		 *   function( $interval ) {
		 *     $interval = 300000;
		 *     return $interval;
		 *   }
		 * );
		 * ```
		 *
		 * @param int $interval Interval in milliseconds.
		 */
		return (int) apply_filters( 'SimpleHistoryNewRowsNotifier/interval', 10000 );
	}

	/**
	 * Increase the total number of logged events.
	 * Used to keep track of how many events have been logged since the plugin was installed.
	 */
	public static function increase_total_logged_events_count() {
		// Don't log when updating widgets,
		// because it causes error "widget_setting_too_many_options".
		// Bug report: https://github.com/bonny/WordPress-Simple-History/issues/498.
		if ( doing_action( 'wp_ajax_update-widget' ) ) {
			return;
		}

		update_option(
			'simple_history_total_logged_events_count',
			self::get_total_logged_events_count() + 1,
			false
		);
	}

	/**
	 * Get the total number of logged events.
	 * Used to keep track of how many events have been logged since the plugin was installed.
	 *
	 * @return int
	 */
	public static function get_total_logged_events_count() {
		return (int) get_option( 'simple_history_total_logged_events_count', 0 );
	}

	/**
	 * Get plugin install date as GMT.
	 *
	 * @return string|false Date as GMT or false if not set.
	 */
	public static function get_plugin_install_date() {
		return get_option( 'simple_history_install_date_gmt' );
	}

	/**
	 * Escape a string to be used in a CSV context, by adding an apostrophe if field
	 * begins with '=', '+', '-', or '@'.
	 *
	 * Function taken from the Jetpack Plugin, Copyright Automattic.
	 *
	 * @see https://www.drupal.org/project/webform/issues/3157877#:~:text=At%20present%2C%20the%20best%20defence%20strategy%20we%20are%20aware%20of%20is%20prefixing%20cells%20that%20start%20with%20%E2%80%98%3D%E2%80%99%20%2C%20%27%2B%27%20or%20%27%2D%27%20with%20an%20apostrophe.
	 * @see https://github.com/Automattic/jetpack/blob/d4068d52c35a30edc01b9356a4764132aeb532fd/projects/packages/forms/src/contact-form/class-contact-form-plugin.php#L1854
	 * @param string $field - the CSV field.
	 * @return string CSV field with escaped characters.
	 */
	public static function esc_csv_field( $field ) {
		// Bail if not string.
		if ( ! is_string( $field ) ) {
			return '';
		}

		$active_content_triggers = array( '=', '+', '-', '@' );

		if ( in_array( substr( $field, 0, 1 ), $active_content_triggers, true ) ) {
			$field = "'" . $field;
		}

		return $field;
	}

	/**
	 * Determine if promo boxes should be shown.
	 *
	 * @return bool True if promo boxes should be shown, false otherwise.
	 */
	public static function show_promo_boxes() {
		$show_promo_boxes = true;

		// Hide if Premium add-on is active.
		if ( self::is_premium_add_on_active() ) {
			$show_promo_boxes = false;
		}

		// Hide if Extended Settings is active.
		if ( self::is_extended_settings_add_on_active() ) {
			$show_promo_boxes = false;
		}

		/**
		 * Filter to determine if promo boxes should be shown.
		 *
		 * @param bool $show_promo_boxes True if promo boxes should be shown, false otherwise.
		 */
		$show_promo_boxes = apply_filters( 'simple_history/show_promo_boxes', $show_promo_boxes );

		return $show_promo_boxes;
	}

	/**
	 * Get HTML for a premium feature teaser box.
	 *
	 * Note: When echoing the output, wrap it in wp_kses_post() to satisfy PHPCS:
	 * echo wp_kses_post( Helpers::get_premium_feature_teaser( ... ) );
	 *
	 * @param string       $title The title/heading for the premium feature.
	 * @param string|array $description The description of the premium feature, or an array of feature strings for a bullet list.
	 * @param string       $tracking_param The tracking parameter for the "Learn More" link (e.g., 'premium_feeds_settings').
	 * @param string       $button_text Optional. The text for the button. Default: 'Learn More'.
	 * @return string HTML for the premium teaser box, or empty string if promo boxes should not be shown.
	 */
	public static function get_premium_feature_teaser( $title, $description, $tracking_param, $button_text = '' ) {
		// Only show premium teaser if premium is not installed.
		if ( ! self::show_promo_boxes() ) {
			return '';
		}

		if ( empty( $button_text ) ) {
			$button_text = __( 'Get Premium', 'simple-history' );
		}

		$premium_url = self::get_tracking_url( 'https://simple-history.com/add-ons/premium/', $tracking_param );

		ob_start();
		?>
		<div class="sh-PremiumFeatureTeaser">
			<p class="sh-PremiumFeatureTeaser-title">
				<span class="sh-Badge sh-Badge--premium"><?php esc_html_e( 'Premium', 'simple-history' ); ?></span>
				<strong><?php echo esc_html( $title ); ?></strong>
			</p>

			<?php
			if ( is_array( $description ) ) {
				?>
				<ul class="sh-PremiumFeatureTeaser-features">
					<?php
					foreach ( $description as $feature ) {
						?>
						<li>
							<span class="dashicons dashicons-yes"></span>
							<?php echo esc_html( $feature ); ?>
						</li>
						<?php
					}
					?>
				</ul>
				<?php
			} else {
				?>
				<p>
					<?php echo esc_html( $description ); ?>
				</p>
				<?php
			}
			?>

			<p class="sh-PremiumFeatureTeaser-ctaLinkContainer">
				<a href="<?php echo esc_url( $premium_url ); ?>"
					target="_blank"
					class="sh-PremiumFeatureTeaser-ctaLink">
					<?php echo esc_html( $button_text ); ?>
				</a>
			</p>
		</div>
		<?php
		return ob_get_clean();
	}

	/**
	 * Check if premium add-on is active.
	 *
	 * @return bool True if premium add-on is active, false otherwise.
	 */
	public static function is_premium_add_on_active() {
		return self::is_plugin_active( 'simple-history-premium/simple-history-premium.php' );
	}

	/**
	 * Check if Extended Settings add-on is active.
	 */
	public static function is_extended_settings_add_on_active() {
		return self::is_plugin_active( 'simple-history-extended-settings/index.php' );
	}

	/**
	 * Get the URL to the admin page where user views the history feed.
	 *
	 * Can not use `menu_page_url()` because it only works within the admin area.
	 * But we want to be able to link to history page also from front end.
	 *
	 * Calls to this from the Admin Bar Quick View also happens before menu is registered.
	 *
	 * @return string URL to admin page, for example http://wordpress-stable.test/wordpress/wp-admin/index.php?page=simple_history_page.
	 */
	public static function get_history_admin_url() {
		$history_page_location = self::get_menu_page_location();

		if ( in_array( $history_page_location, array( 'top', 'bottom' ), true ) ) {
			return admin_url( 'admin.php?page=' . Simple_History::MENU_PAGE_SLUG );
		}

		if ( $history_page_location === 'inside_tools' ) {
			return admin_url( 'tools.php?page=' . Simple_History::MENU_PAGE_SLUG );
		}

		if ( $history_page_location === 'inside_dashboard' ) {
			return admin_url( 'index.php?page=' . Simple_History::MENU_PAGE_SLUG );
		}

		// Fallback if no match found.
		return admin_url( 'admin.php?page=' . Simple_History::MENU_PAGE_SLUG );
	}

	/**
	 * Get a filtered URL to the history admin page.
	 *
	 * Builds a URL with optional filters pre-applied: date range, context,
	 * message types, and expanded filter panel. Handles the `messages` JSON
	 * parameter correctly (add_query_arg double-encodes JSON, so it's appended manually).
	 *
	 * @param array $args {
	 *     Optional. Filter arguments.
	 *
	 *     @type string $date         Date filter. E.g. 'allDates', 'lastdays:30', 'month:2025-04'.
	 *     @type string $context      Context filter. E.g. 'post_id:123'.
	 *     @type bool   $show_filters Whether to expand the filter panel. Default false.
	 *     @type array  $messages     Array of message filter objects, each with 'value' and 'search_options' keys.
	 *                                Example: [ [ 'value' => 'Posts', 'search_options' => [ 'SimplePostLogger:post_created' ] ] ]
	 * }
	 * @return string Full admin URL with filter parameters.
	 */
	public static function get_filtered_history_url( $args = array() ) {
		$query_args = array();

		if ( ! empty( $args['date'] ) ) {
			$query_args['date'] = $args['date'];
		}

		if ( ! empty( $args['context'] ) ) {
			$query_args['context'] = $args['context'];
		}

		if ( ! empty( $args['show_filters'] ) ) {
			$query_args['show-filters'] = '1';
		}

		$url = add_query_arg( $query_args, self::get_history_admin_url() );

		// Append messages param manually — add_query_arg double-encodes JSON.
		if ( ! empty( $args['messages'] ) ) {
			$url .= '&messages=' . rawurlencode( wp_json_encode( $args['messages'] ) );
		}

		return $url;
	}

	/**
	 * Get URL for settings page.
	 *
	 * Uses the same menu location logic as the main history page.
	 *
	 * @return string URL for settings page.
	 */
	public static function get_settings_page_url() {
		$history_page_location = self::get_menu_page_location();

		if ( in_array( $history_page_location, array( 'top', 'bottom' ), true ) ) {
			return admin_url( 'admin.php?page=' . Simple_History::SETTINGS_MENU_PAGE_SLUG );
		}

		if ( in_array( $history_page_location, array( 'inside_tools', 'inside_dashboard' ), true ) ) {
			return admin_url( 'options-general.php?page=' . Simple_History::SETTINGS_MENU_PAGE_SLUG );
		}

		// Fallback if no match found.
		return admin_url( 'admin.php?page=' . Simple_History::SETTINGS_MENU_PAGE_SLUG );
	}


	/**
	 * Get URL for a main tab in the settings page.
	 *
	 * @param string $tab_slug Slug for the tab.
	 * @return string URL for the tab, unescaped.
	 */
	public static function get_settings_page_tab_url( $tab_slug ) {
		return add_query_arg(
			[
				'selected-tab' => $tab_slug,
			],
			self::get_settings_page_url()
		);
	}

	/**
	 * Get URL for a sub-tab in the settings page.
	 *
	 * @param string $sub_tab_slug Slug for the sub-tab.
	 * @return string URL for the sub-tab, unescaped.
	 */
	public static function get_settings_page_sub_tab_url( $sub_tab_slug ) {
		return add_query_arg(
			[
				'selected-tab'     => Setup_Settings_Page::SETTINGS_GENERAL_SUBTAB_SLUG,
				'selected-sub-tab' => $sub_tab_slug,
			],
			self::get_settings_page_url()
		);
	}

	/**
	 * Checks if an event exists in the database.
	 * Does not check permissions.
	 *
	 * @param int $event_id Event ID.
	 * @return bool True if event exists, false otherwise.
	 */
	public static function event_exists( $event_id ) {
		global $wpdb;
		$simple_history    = Simple_History::get_instance();
		$events_table_name = $simple_history->get_events_table_name();

		// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		return (bool) $wpdb->get_var(
			$wpdb->prepare(
				'SELECT COUNT(*) FROM %i WHERE id = %d',
				$events_table_name,
				$event_id
			)
		);
	}

	/**
	 * Count events in the database.
	 *
	 * Returns a raw count of events, without any permission checks.
	 * Useful for tests and internal statistics.
	 *
	 * @since 5.21.0
	 *
	 * @param array $args {
	 *     Optional. Arguments to filter the count.
	 *
	 *     @type string $logger Only count events from this logger.
	 *     @type string $level  Only count events with this level.
	 * }
	 * @return int Number of events matching the criteria.
	 */
	public static function count_events( $args = [] ) {
		global $wpdb;
		$simple_history    = Simple_History::get_instance();
		$events_table_name = $simple_history->get_events_table_name();

		$where_clauses = [];
		$where_values  = [];

		if ( ! empty( $args['logger'] ) ) {
			$where_clauses[] = 'logger = %s';
			$where_values[]  = $args['logger'];
		}

		if ( ! empty( $args['level'] ) ) {
			$where_clauses[] = 'level = %s';
			$where_values[]  = $args['level'];
		}

		$where_sql = '';
		if ( ! empty( $where_clauses ) ) {
			$where_sql = 'WHERE ' . implode( ' AND ', $where_clauses );
		}

		$sql = "SELECT COUNT(*) FROM {$events_table_name} {$where_sql}";

		if ( ! empty( $where_values ) ) {
			// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
			$sql = $wpdb->prepare( $sql, $where_values );
		}

		// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		return (int) $wpdb->get_var( $sql );
	}

	/**
	 * Returns markup for a tooltip with help text icon.
	 *
	 * @param string $tooltip_text The text to display in the tooltip.
	 * @return string
	 */
	public static function get_tooltip_html( $tooltip_text ) {
		$tooltip_text = trim( $tooltip_text );

		if ( empty( $tooltip_text ) ) {
			return '';
		}

		ob_start();
		?>
		<span class="sh-Icon sh-Icon--help sh-TooltipIcon" title="<?php echo esc_attr( $tooltip_text ); ?>"></span>
		<?php
		return trim( ob_get_clean() );
	}

	/**
	 * Get all sticky event IDs.
	 *
	 * @return array<int> Array of sticky event IDs.
	 */
	public static function get_sticky_event_ids() {
		global $wpdb;

		$simple_history = Simple_History::get_instance();
		$contexts_table = $simple_history->get_contexts_table_name();

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$results = $wpdb->get_col(
			$wpdb->prepare(
				'SELECT history_id FROM %i WHERE `key` = %s',
				$contexts_table,
				'_sticky'
			)
		);

		return array_map( 'intval', $results );
	}

	/**
	 * Generate a URL to the events log with filters applied.
	 *
	 * This helper function creates properly formatted and encoded URLs for linking
	 * to the events log with various filters applied. It can be used anywhere in the
	 * plugin where you need to link to filtered event results.
	 *
	 * IMPORTANT: When outputting the returned URL in HTML, you MUST use esc_url()
	 * for security, per WordPress coding standards:
	 *
	 *     echo '<a href="' . esc_url( $url ) . '">Link</a>';
	 *
	 * @param array $args {
	 *     Optional. Array of filter arguments.
	 *
	 *     @type array|int    $users      Array of user data with 'id', 'display_name', 'user_email' keys,
	 *                                     or array of such arrays for multiple users,
	 *                                     or single user ID as integer.
	 *     @type string       $date       Date filter string (e.g., 'lastdays:30', 'lastdays:7').
	 *     @type string       $search     Search text to filter containing words.
	 *     @type array        $loglevels  Array of log level filters.
	 *     @type array        $messages   Array of message type filters.
	 *     @type array        $initiators Array of initiator filters.
	 *     @type string       $context    Context filter string.
	 * }
	 * @return string The filtered events log URL (must be escaped with esc_url() before output).
	 */
	public static function get_filtered_events_url( $args = [] ) {
		$query_args = [
			'page' => 'simple_history_admin_menu_page',
		];

		// Handle users filter.
		if ( ! empty( $args['users'] ) ) {
			$users_filter = [];

			// If it's a single user ID (integer), convert to array format.
			if ( is_int( $args['users'] ) ) {
				$user = get_userdata( $args['users'] );
				if ( $user ) {
					$users_filter[] = [
						'id'    => (string) $user->ID,
						'value' => $user->display_name . ' (' . $user->user_email . ')',
					];
				}
			} elseif ( is_array( $args['users'] ) ) {
				// Check if it's a single user array (has 'id' key) or array of users.
				if ( isset( $args['users']['id'] ) ) {
					// Single user array.
					$user_value = $args['users']['display_name'];
					if ( ! empty( $args['users']['user_email'] ) ) {
						$user_value .= ' (' . $args['users']['user_email'] . ')';
					}

					$users_filter[] = [
						'id'    => (string) $args['users']['id'],
						'value' => $user_value,
					];
				} else {
					// Array of users.
					foreach ( $args['users'] as $user ) {
						$user_value = $user['display_name'];
						if ( ! empty( $user['user_email'] ) ) {
							$user_value .= ' (' . $user['user_email'] . ')';
						}

						$users_filter[] = [
							'id'    => (string) $user['id'],
							'value' => $user_value,
						];
					}
				}
			}

			if ( ! empty( $users_filter ) ) {
				$users_json          = wp_json_encode( $users_filter );
				$query_args['users'] = $users_json;
			}
		}

		// Handle date filter.
		if ( ! empty( $args['date'] ) ) {
			$query_args['date'] = $args['date'];
		}

		// Handle search filter.
		if ( ! empty( $args['search'] ) ) {
			$query_args['search'] = $args['search'];
		}

		// Handle log levels filter.
		if ( ! empty( $args['loglevels'] ) && is_array( $args['loglevels'] ) ) {
			$query_args['loglevels'] = wp_json_encode( $args['loglevels'] );
		}

		// Handle message types filter.
		if ( ! empty( $args['messages'] ) && is_array( $args['messages'] ) ) {
			$query_args['messages'] = wp_json_encode( $args['messages'] );
		}

		// Handle initiators filter.
		if ( ! empty( $args['initiators'] ) && is_array( $args['initiators'] ) ) {
			$query_args['initiators'] = wp_json_encode( $args['initiators'] );
		}

		// Handle context filter.
		if ( ! empty( $args['context'] ) ) {
			$query_args['context'] = $args['context'];
		}

		// Handle exclusion/negative filters.
		if ( ! empty( $args['exclude_search'] ) ) {
			$query_args['exclude-search'] = $args['exclude_search'];
		}

		if ( ! empty( $args['exclude_loglevels'] ) && is_array( $args['exclude_loglevels'] ) ) {
			$query_args['exclude-levels'] = wp_json_encode( $args['exclude_loglevels'] );
		}

		if ( ! empty( $args['exclude_loggers'] ) && is_array( $args['exclude_loggers'] ) ) {
			$query_args['exclude-loggers'] = wp_json_encode( $args['exclude_loggers'] );
		}

		if ( ! empty( $args['exclude_messages'] ) && is_array( $args['exclude_messages'] ) ) {
			$query_args['exclude-messages'] = wp_json_encode( $args['exclude_messages'] );
		}

		if ( ! empty( $args['exclude_users'] ) && is_array( $args['exclude_users'] ) ) {
			$query_args['exclude-users'] = wp_json_encode( $args['exclude_users'] );
		}

		if ( ! empty( $args['exclude_initiators'] ) && is_array( $args['exclude_initiators'] ) ) {
			$query_args['exclude-initiator'] = wp_json_encode( $args['exclude_initiators'] );
		}

		if ( ! empty( $args['exclude_context'] ) ) {
			$query_args['exclude-context'] = $args['exclude_context'];
		}

		// Build the URL manually to properly encode JSON parameters.
		$base_url  = admin_url( 'admin.php' );
		$url_parts = [];

		foreach ( $query_args as $key => $value ) {
			$url_parts[] = $key . '=' . rawurlencode( $value );
		}

		return $base_url . '?' . implode( '&', $url_parts );
	}

	/**
	 * Remove 4-byte UTF-8 characters (emojis and other supplementary plane chars)
	 * from a string. This is needed because the database tables use utf8 charset
	 * which only supports 3-byte characters.
	 *
	 * @since 5.19.0
	 * @param mixed $value The value to sanitize.
	 * @return mixed The value with 4-byte characters removed if string, otherwise unchanged.
	 */
	public static function strip_4_byte_chars( $value ) {
		if ( ! is_string( $value ) ) {
			return $value;
		}

		return preg_replace( '/[\x{10000}-\x{10FFFF}]/u', '', $value );
	}

	/**
	 * Build tracking URL with standardized UTM parameters for analytics.
	 *
	 * Creates consistent tracking URLs for monitoring which features generate
	 * user interest in premium functionality. UTM parameters follow a structured
	 * naming convention for easy analysis in Google Analytics.
	 *
	 * Uses utm_campaign as the primary tracking parameter because it appears in
	 * standard GA4 Traffic Acquisition reports (unlike utm_content which requires
	 * Custom Explorations to view).
	 *
	 * @since 5.0.0
	 *
	 * @param string $url Base URL to add tracking parameters to.
	 * @param string $utm_campaign Campaign identifier in format: {category}_{location}_{action}.
	 *                             Examples:
	 *                             - 'premium_dashboard_sidebar' - Sidebar promo on main dashboard
	 *                             - 'premium_stats_daterange' - Date range feature in stats
	 *                             - 'premium_export_banner' - Export page premium promo
	 *                             - 'premium_events_ipaddress' - IP address Google Maps feature
	 *                             - 'docs_filter_help' - Documentation for filter help.
	 * @param string $utm_source Traffic source. Default: 'wpadmin'. Alternative: 'wordpress_admin'.
	 * @param string $utm_medium Traffic medium. Default: 'plugin'. Alternative: 'Simple_History'.
	 * @param string $utm_content Optional content identifier for A/B testing variations.
	 *                            Only use when testing different versions of the SAME feature.
	 *                            Examples: 'blue_button', 'variant_a', 'top_link'.
	 *                            Leave empty for standard tracking.
	 * @return string URL with UTM tracking parameters appended.
	 */
	public static function get_tracking_url( $url, $utm_campaign, $utm_source = 'wpadmin', $utm_medium = 'plugin', $utm_content = '' ) {
		$params = [
			'utm_source'   => $utm_source,
			'utm_medium'   => $utm_medium,
			'utm_campaign' => $utm_campaign,
		];

		// Only add utm_content if provided (for A/B testing).
		if ( ! empty( $utm_content ) ) {
			$params['utm_content'] = $utm_content;
		}

		return add_query_arg( $params, $url );
	}

	/**
	 * Sanitize an error message for storage and display.
	 *
	 * Strips HTML tags, decodes HTML entities, normalizes whitespace,
	 * and truncates to a reasonable length. Useful for cleaning up
	 * error messages that may contain HTML (like WordPress database errors).
	 *
	 * @since 5.6.0
	 * @param string $message    The raw error message.
	 * @param int    $max_length Maximum length before truncation. Default 500.
	 * @return string The sanitized error message.
	 */
	public static function sanitize_error_message( $message, $max_length = 500 ) {
		// Strip HTML tags.
		$clean = wp_strip_all_tags( $message );

		// Decode HTML entities.
		$clean = html_entity_decode( $clean, ENT_QUOTES, 'UTF-8' );

		// Normalize whitespace (collapse multiple spaces/newlines to single space).
		$clean = preg_replace( '/\s+/', ' ', $clean );

		// Trim whitespace.
		$clean = trim( $clean );

		// Truncate to reasonable length.
		if ( strlen( $clean ) > $max_length ) {
			$clean = substr( $clean, 0, $max_length - 3 ) . '...';
		}

		return $clean;
	}

	/**
	 * Get the primary role of a user.
	 *
	 * WordPress allows multiple roles per user. This function returns
	 * the first valid role, following the pattern used in user-edit.php.
	 *
	 * @since 5.6.0
	 * @param int|\WP_User|null $user User ID, WP_User object, or null for current user.
	 * @return string The primary role slug, or empty string if no valid role found.
	 */
	public static function get_user_primary_role( $user = null ) {
		if ( $user === null ) {
			$user = wp_get_current_user();
		} elseif ( is_numeric( $user ) ) {
			$user = get_user_by( 'id', $user );
		}

		if ( ! $user instanceof \WP_User || empty( $user->roles ) ) {
			return '';
		}

		// Validate roles against registered roles (as done in user-edit.php).
		$wp_roles    = wp_roles();
		$valid_roles = array_intersect(
			array_values( (array) $user->roles ),
			array_keys( (array) $wp_roles->roles )
		);

		$primary_role = array_shift( $valid_roles );
		return $primary_role !== null ? (string) $primary_role : '';
	}

	/**
	 * Check if the current server is running Windows.
	 *
	 * @return bool True if running on Windows.
	 */
	public static function is_windows() {
		return strtoupper( substr( PHP_OS, 0, 3 ) ) === 'WIN';
	}
}
