<?php
namespace Soderlind\Plugin\WPLoupe;

/**
 * Utility functions for WP Loupe
 *
 * @package Soderlind\Plugin\WPLoupe
 * @since 0.0.1
 */
class WP_Loupe_Utils {
	private const REQUIRED_SQLITE_VERSION = '3.35.0';

	/**
	 * Resolve the post types that are indexed and searched.
	 *
	 * Reads the settings screen option, falls back to `post` and `page`, then applies
	 * the `loupe_search_post_types` filter. Every runtime component must use this so
	 * the filter has the same effect on indexing, front-end search, REST and abilities.
	 *
	 * @since 1.3.0
	 * @return array<int, string> Post type slugs.
	 */
	public static function get_indexed_post_types(): array {
		$options    = get_option( 'loupe_search_custom_post_types', [] );
		$post_types = ( ! empty( $options ) && isset( $options[ 'loupe_search_post_type_field' ] ) )
			? (array) $options[ 'loupe_search_post_type_field' ]
			: [ 'post', 'page' ];

		$post_types = apply_filters( 'loupe_search_post_types', $post_types );
		$post_types = apply_filters_deprecated( 'wp_loupe_post_types', array( $post_types ), '1.1.0', 'loupe_search_post_types' );

		return array_values( (array) $post_types );
	}

	/**
	 * Indexed post types narrowed to publicly viewable ones.
	 *
	 * Unauthenticated endpoints must scope the search itself to these post types,
	 * so hit totals and facet counts can never reveal non-public indexed content.
	 *
	 * @since 1.2.4
	 * @return array<int, string> Post type slugs.
	 */
	public static function get_public_indexed_post_types(): array {
		$public = array_filter(
			self::get_indexed_post_types(),
			static function ( $post_type ) {
				$post_type_object = get_post_type_object( $post_type );
				return $post_type_object && ! empty( $post_type_object->public );
			}
		);

		return array_values( $public );
	}

	/**
	 * Whether a post may be exposed to an unauthenticated caller.
	 *
	 * Single source of truth for the visibility gate used by the public search
	 * and get-post abilities/endpoints: the post must exist, be published, belong
	 * to a public post type, and not be password protected.
	 *
	 * @since 1.3.0
	 * @param \WP_Post|int|null $post Post object or ID.
	 * @return bool True when the post is publicly viewable.
	 */
	public static function is_publicly_viewable_post( $post ): bool {
		$post = get_post( $post );
		if ( ! $post || 'publish' !== $post->post_status ) {
			return false;
		}

		$post_type_object = get_post_type_object( $post->post_type );
		if ( ! $post_type_object || empty( $post_type_object->public ) ) {
			return false;
		}

		if ( '' !== (string) $post->post_password ) {
			return false;
		}

		return true;
	}

	/**
	 * Debug logger helper.
	 *
	 * Logs only when WP_DEBUG is enabled.
	 *
	 * @param string $message Message to log.
	 * @param string $prefix Optional prefix.
	 * @return void
	 */
	public static function debug_log( $message, $prefix = 'WP Loupe' ) {
		if ( ! ( defined( 'WP_DEBUG' ) && WP_DEBUG ) ) {
			return;
		}
		// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
		error_log( '[' . $prefix . '] ' . (string) $message );
	}

	/**
	 * Check if SQLite (PDO) is installed and meets version requirements.
	 *
	 * Loupe 0.13+ requires the PDO SQLite driver and SQLite >= 3.35.0.
	 *
	 * @since 0.0.1
	 * @return boolean True if SQLite is available and meets requirements
	 */
	public static function has_sqlite() {
		if ( ! function_exists( 'is_plugin_active' ) ) {
			include_once ABSPATH . 'wp-admin/includes/plugin.php';
		}

		if ( ! class_exists( '\\PDO' ) || ! extension_loaded( 'pdo_sqlite' ) ) {
			self::display_error_and_deactivate_plugin(
				__( 'SQLite PDO driver not available', 'loupe-search' ),
				sprintf(
					/* translators: %s: required sqlite version */
					__( 'Loupe Search requires the pdo_sqlite extension and SQLite version %s or newer.', 'loupe-search' ),
					self::REQUIRED_SQLITE_VERSION
				)
			);
			return false;
		}

		$drivers = [];
		try {
			$drivers = \PDO::getAvailableDrivers(); // phpcs:ignore WordPress.DB.RestrictedClasses.mysql__PDO -- PDO is required for SQLite (loupe/loupe engine); no $wpdb alternative.
		} catch ( \Throwable $e ) {
			$drivers = [];
		}
		if ( ! is_array( $drivers ) || ! in_array( 'sqlite', $drivers, true ) ) {
			self::display_error_and_deactivate_plugin(
				__( 'SQLite PDO driver not available', 'loupe-search' ),
				sprintf(
					/* translators: %s: required sqlite version */
					__( 'Loupe Search requires the pdo_sqlite extension and SQLite version %s or newer.', 'loupe-search' ),
					self::REQUIRED_SQLITE_VERSION
				)
			);
			return false;
		}

		$sqlite_version = self::get_sqlite_version_string();
		if ( ! $sqlite_version ) {
			self::display_error_and_deactivate_plugin(
				__( 'Unable to detect SQLite version', 'loupe-search' ),
				sprintf(
					/* translators: %s: required sqlite version */
					__( 'Loupe Search requires SQLite version %s or newer.', 'loupe-search' ),
					self::REQUIRED_SQLITE_VERSION
				)
			);
			return false;
		}

		if ( version_compare( $sqlite_version, self::REQUIRED_SQLITE_VERSION, '<' ) ) {
			self::display_error_and_deactivate_plugin(
				__( 'SQLite version too old', 'loupe-search' ),
				sprintf(
					/* translators: %s: required sqlite version */
					__( 'Loupe Search requires SQLite version %s or newer.', 'loupe-search' ),
					self::REQUIRED_SQLITE_VERSION
				)
			);
			return false;
		}

		return true;
	}

	/**
	 * Returns structured environment/requirements checks for the admin UI.
	 *
	 * Each check is an associative array with keys:
	 *  - id:       stable identifier (string)
	 *  - label:    human-readable label (string)
	 *  - ok:       whether the requirement is satisfied (bool)
	 *  - value:    the detected value to display (string)
	 *  - required: the required value/threshold, if any (string)
	 *
	 * @since 1.0.0
	 * @return array<int, array{id:string,label:string,ok:bool,value:string,required:string}>
	 */
	public static function get_requirements_checks(): array {
		$has_pdo        = class_exists( '\\PDO' );
		$has_pdo_sqlite = extension_loaded( 'pdo_sqlite' );
		$has_intl       = extension_loaded( 'intl' );
		$has_mbstring   = extension_loaded( 'mbstring' );

		$sqlite_version = null;
		if ( $has_pdo && $has_pdo_sqlite ) {
			$sqlite_version = self::get_sqlite_version_string();
		} elseif ( class_exists( 'SQLite3' ) ) {
			try {
				$info = \SQLite3::version();
				if ( is_array( $info ) && ! empty( $info['versionString'] ) ) {
					$sqlite_version = (string) $info['versionString'];
				}
			} catch ( \Throwable $e ) {
				$sqlite_version = null;
			}
		}

		$sqlite_ok = ( is_string( $sqlite_version ) && $sqlite_version !== '' )
			? version_compare( $sqlite_version, self::REQUIRED_SQLITE_VERSION, '>=' )
			: false;

		$yes = __( 'Installed', 'loupe-search' );
		$no  = __( 'Missing', 'loupe-search' );

		return [
			[
				'id'       => 'pdo_sqlite',
				'label'    => __( 'PDO SQLite extension', 'loupe-search' ),
				'ok'       => $has_pdo_sqlite,
				'value'    => $has_pdo_sqlite ? $yes : $no,
				'required' => __( 'Required', 'loupe-search' ),
			],
			[
				'id'       => 'sqlite',
				'label'    => __( 'SQLite version', 'loupe-search' ),
				'ok'       => $sqlite_ok,
				'value'    => $sqlite_version ? $sqlite_version : __( 'Unknown', 'loupe-search' ),
				/* translators: %s: required SQLite version */
				'required' => sprintf( __( '>= %s', 'loupe-search' ), self::REQUIRED_SQLITE_VERSION ),
			],
			[
				'id'       => 'intl',
				'label'    => __( 'intl extension', 'loupe-search' ),
				'ok'       => $has_intl,
				'value'    => $has_intl ? $yes : $no,
				'required' => __( 'Required', 'loupe-search' ),
			],
			[
				'id'       => 'mbstring',
				'label'    => __( 'mbstring extension', 'loupe-search' ),
				'ok'       => $has_mbstring,
				'value'    => $has_mbstring ? $yes : $no,
				'required' => __( 'Required', 'loupe-search' ),
			],
		];
	}

	/**
	 * Returns a short, admin-facing requirements diagnostic line.
	 *
	 * This is informational only and does not deactivate the plugin.
	 *
	 * @return string
	 */
	public static function get_requirements_diagnostic_line(): string {
		$has_pdo        = class_exists( '\\PDO' );
		$has_pdo_sqlite = extension_loaded( 'pdo_sqlite' );
		$has_intl       = extension_loaded( 'intl' );
		$has_mbstring   = extension_loaded( 'mbstring' );

		$sqlite_version = null;
		if ( $has_pdo && $has_pdo_sqlite ) {
			$sqlite_version = self::get_sqlite_version_string();
		} elseif ( class_exists( 'SQLite3' ) ) {
			try {
				$info = \SQLite3::version();
				if ( is_array( $info ) && ! empty( $info['versionString'] ) ) {
					$sqlite_version = (string) $info['versionString'];
				}
			} catch ( \Throwable $e ) {
				$sqlite_version = null;
			}
		}

		$sqlite_ok = ( is_string( $sqlite_version ) && $sqlite_version !== '' )
			? version_compare( $sqlite_version, self::REQUIRED_SQLITE_VERSION, '>=' )
			: false;

		return sprintf(
			/* translators: 1: pdo_sqlite yes/no, 2: sqlite version or ?, 3: required sqlite version, 4: ok/missing, 5: intl yes/no, 6: mbstring yes/no */
			__( 'Requirements: pdo_sqlite=%1$s; SQLite=%2$s (>= %3$s): %4$s; intl=%5$s; mbstring=%6$s', 'loupe-search' ),
			$has_pdo_sqlite ? 'yes' : 'no',
			$sqlite_version ? $sqlite_version : '?',
			self::REQUIRED_SQLITE_VERSION,
			$sqlite_ok ? 'OK' : 'MISSING/OLD',
			$has_intl ? 'yes' : 'no',
			$has_mbstring ? 'yes' : 'no'
		);
	}

	/**
	 * Returns the SQLite library version.
	 *
	 * Prefers PDO + `SELECT sqlite_version()` to match Loupe's usage.
	 * Falls back to SQLite3::version() when available.
	 *
	 * @return string|null
	 */
	private static function get_sqlite_version_string(): ?string {
		try {
			$pdo = new \PDO( 'sqlite::memory:' ); // phpcs:ignore WordPress.DB.RestrictedClasses.mysql__PDO -- PDO is required for SQLite version detection; no $wpdb alternative.
			$pdo->setAttribute( \PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION ); // phpcs:ignore WordPress.DB.RestrictedClasses.mysql__PDO -- PDO constant usage for SQLite.
			$stmt = $pdo->query( 'select sqlite_version()' );
			$ver  = $stmt ? $stmt->fetchColumn() : null;
			if ( is_string( $ver ) && $ver !== '' ) {
				return $ver;
			}
		} catch ( \Throwable $e ) {
			// Ignore and fall back.
		}

		if ( class_exists( 'SQLite3' ) ) {
			try {
				$info = \SQLite3::version();
				if ( is_array( $info ) && ! empty( $info['versionString'] ) ) {
					return (string) $info['versionString'];
				}
			} catch ( \Throwable $e ) {
				return null;
			}
		}

		return null;
	}

	/**
	 * Display error message and deactivate plugin
	 *
	 * @since 0.0.1
	 * @param string $error_title   The error title.
	 * @param string $error_message The error message.
	 * @return void
	 */
	private static function display_error_and_deactivate_plugin( $error_title, $error_message ) {
		add_action( 'all_admin_notices', function () use ($error_title, $error_message) {
			printf(
				'<div class="notice notice-error is-dismissible"><p><strong>%1$s</strong></p><p>%2$s</p></div>',
				esc_html( $error_title ),
				esc_html( $error_message )
			);
			deactivate_plugins( LOUPE_SEARCH_FILE );
			if ( is_multisite() ) {
				deactivate_plugins( LOUPE_SEARCH_FILE, false, true );
			}
		} );
	}

	/**
	 * Debug function that uses Ray if available
	 *
	 * @since 0.0.1
	 * @param mixed $var Variable to dump.
	 * @return void
	 */
	public static function dump( $var ) {
		if ( function_exists( '\ray' ) ) {
			// add function name of calling function
			$backtrace = debug_backtrace();
			$caller    = $backtrace[ 1 ][ 'function' ];
			\ray( [ $caller, $var ] ); // phpcs:ignore
			// \ray( $var ); // phpcs:ignore
		}
	}

	// return version number based upon plugin file version
	/**
	 * Get version number based upon plugin version
	 *
	 * @since 0.0.1
	 * @return string Version number
	 */
	public static function get_version_number() {
		$plugin_data = get_plugin_data( LOUPE_SEARCH_FILE );
		$version     = $plugin_data[ 'Version' ];
		return $version;
	}

	/**
	 * Remove transients from the database
	 *
	 * @since  1.0
	 * @param  string $prefix Transient prefix to remove
	 * @return void
	 */
	public static function remove_transient( $prefix ) {

		$matches = self::get_transients( array(
			'search' => $prefix,
			'count'  => false,
			'offset' => 0,
			'number' => 1000,
		) );
		
		foreach ( $matches as $match ) {
			$transient_name = str_replace( '_transient_', '', $match->option_name );
			
			delete_transient( $transient_name );
		}
	}


	/**
	 * Get transients from the database
	 *
	 * These queries are uncached, to prevent race conditions with persistent
	 * object cache setups and the way Transients use them.
	 *
	 * @copyright wpbeginner
	 * @see https://github.com/awesomemotive/Transients-Manager/blob/master/src/TransientsManager.php#L693
	 * @since  1.0
	 * @param  array $args
	 * @return array
	 */
	private static function get_transients( $args = array() ) {
		global $wpdb;

		// Parse arguments
		$r = wp_parse_args( $args, array(
			'offset' => 0,
			'number' => 30,
			'search' => '',
			'count'  => false,
		) );

		// Escape some LIKE parts
		$esc_name = '%' . $wpdb->esc_like( '_transient_' ) . '%';
		$esc_time = '%' . $wpdb->esc_like( '_transient_timeout_' ) . '%';

		// SELECT
		$sql = array( 'SELECT' );

		// COUNT
		if ( ! empty( $r[ 'count' ] ) ) {
			$sql[] = 'count(option_id)';
		} else {
			$sql[] = '*';
		}

		// FROM
		$sql[] = "FROM {$wpdb->options} WHERE option_name LIKE %s AND option_name NOT LIKE %s";

		// Search
		if ( ! empty( $r[ 'search' ] ) ) {
			$search = '%' . $wpdb->esc_like( $r[ 'search' ] ) . '%';
			$sql[]  = $wpdb->prepare( "AND option_name LIKE %s", $search );
		}

		// Limits
		if ( empty( $r[ 'count' ] ) ) {
			$offset = absint( $r[ 'offset' ] );
			$number = absint( $r[ 'number' ] );
			$sql[]  = $wpdb->prepare( "ORDER BY option_id DESC LIMIT %d, %d", $offset, $number );
		}

		// Combine the SQL parts
		$query = implode( ' ', $sql );

		// Prepare
		$prepared = $wpdb->prepare( $query, $esc_name, $esc_time ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- query assembled from trusted parts.

		// Query
		$transients = empty( $r[ 'count' ] )
			? $wpdb->get_results( $prepared ) // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- prepared above. Rows
			: $wpdb->get_var( $prepared );    // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- prepared above. Count

		// Return transients
		return $transients;
	}
}
