<?php
if ( ! defined( 'ABSPATH' ) ) { exit; }

/**
 * GLSE_Fulltext - the FULLTEXT indexes the search engine relies on.
 *
 * Two indexes, because MySQL requires the MATCH column list to be identical to
 * the index definition. A single three-column index cannot serve a title-only
 * search, and "Search in Descriptions" is off by default - so without the title
 * index the fast path would never run on a stock install.
 *
 *   glse_ft_title    (post_title)
 *   glse_ft_content  (post_title, post_content, post_excerpt)
 *
 * Nothing here runs on its own. Adding an index to wp_posts rewrites a core
 * table and holds a lock for as long as that takes, so it is an explicit action
 * a merchant takes from the Performance tab, with the row count in front of
 * them.
 *
 * @since 1.7.0
 */
final class GLSE_Fulltext {

    /** Index over the title alone. */
    public const IDX_TITLE = 'glse_ft_title';

    /** Index over title, content and excerpt. */
    public const IDX_CONTENT = 'glse_ft_content';

    /** Columns each index covers, in the order MATCH must repeat them. */
    private const COLUMNS = [
        self::IDX_TITLE   => [ 'post_title' ],
        self::IDX_CONTENT => [ 'post_title', 'post_content', 'post_excerpt' ],
    ];

    /** Cache of index presence for the request. */
    private static array $present = [];

    /**
     * Every index this class manages.
     *
     * @return string[]
     */
    public static function names(): array {
        return array_keys( self::COLUMNS );
    }

    /**
     * The MATCH column list for an index, prefixed for the query alias.
     *
     * Returns an empty string for an unknown name rather than guessing: a
     * mismatched column list does not error, it silently stops using the index.
     */
    public static function match_columns( string $index, string $alias = 'p' ): string {
        if ( ! isset( self::COLUMNS[ $index ] ) ) {
            return '';
        }

        return implode( ', ', array_map(
            static fn( $c ) => $alias . '.' . $c,
            self::COLUMNS[ $index ]
        ) );
    }

    /**
     * Whether an index exists on wp_posts.
     *
     * Cached per request and in a transient: this is consulted on every search,
     * and INFORMATION_SCHEMA is not free.
     */
    public static function exists( string $index ): bool {
        if ( isset( self::$present[ $index ] ) ) {
            return self::$present[ $index ];
        }

        $cached = get_transient( 'glse_ft_' . $index );
        if ( false !== $cached ) {
            self::$present[ $index ] = '1' === $cached;
            return self::$present[ $index ];
        }

        global $wpdb;

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        $found = $wpdb->get_var( $wpdb->prepare(
            "SELECT INDEX_NAME FROM INFORMATION_SCHEMA.STATISTICS
             WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s AND INDEX_NAME = %s LIMIT 1",
            DB_NAME,
            $wpdb->posts,
            $index
        ) );

        self::$present[ $index ] = ! empty( $found );
        set_transient( 'glse_ft_' . $index, self::$present[ $index ] ? '1' : '0', DAY_IN_SECONDS );

        return self::$present[ $index ];
    }

    /**
     * Drops the cached answers after the schema changes.
     *
     * Missing this leaves the engine convinced an index it just created does
     * not exist, for a day.
     */
    public static function flush_cache(): void {
        self::$present = [];
        foreach ( self::names() as $index ) {
            delete_transient( 'glse_ft_' . $index );
        }
        // The legacy key, from before the indexes were named individually.
        delete_transient( 'glse_ft_index' );
    }

    /**
     * Whether this database can carry a FULLTEXT index on wp_posts.
     *
     * @return true|WP_Error
     */
    public static function supported() {
        global $wpdb;

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        $engine = $wpdb->get_var( $wpdb->prepare(
            "SELECT ENGINE FROM INFORMATION_SCHEMA.TABLES
             WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s LIMIT 1",
            DB_NAME,
            $wpdb->posts
        ) );

        if ( ! $engine ) {
            return new WP_Error( 'glse_ft_engine', __( 'The posts table could not be inspected.', 'glance-live-search' ) );
        }

        if ( ! in_array( strtoupper( $engine ), [ 'INNODB', 'MYISAM', 'ARIA' ], true ) ) {
            return new WP_Error(
                'glse_ft_engine',
                sprintf(
                    /* translators: %s: storage engine name, e.g. MEMORY. */
                    __( 'The %s storage engine does not support full-text indexes.', 'glance-live-search' ),
                    $engine
                )
            );
        }

        /*
         * InnoDB only gained FULLTEXT in MySQL 5.6 and MariaDB 10.0.5. Below
         * that the ALTER fails with a syntax-looking error that tells the
         * merchant nothing.
         */
        $version  = $wpdb->db_version();
        $is_maria = stripos( (string) $wpdb->db_server_info(), 'mariadb' ) !== false;
        $minimum  = $is_maria ? '10.0.5' : '5.6';

        if ( 'INNODB' === strtoupper( $engine ) && version_compare( $version, $minimum, '<' ) ) {
            return new WP_Error(
                'glse_ft_version',
                sprintf(
                    /* translators: 1: current database version, 2: minimum required. */
                    __( 'Full-text indexes on InnoDB need version %2$s or later; this server runs %1$s.', 'glance-live-search' ),
                    $version,
                    $minimum
                )
            );
        }

        return true;
    }

    /**
     * The shortest word the database will index.
     *
     * Anything below it is invisible to MATCH, whatever the query says - which
     * is why the engine has to fall back rather than return nothing for "XL".
     */
    public static function min_token_size(): int {
        $cached = get_transient( 'glse_ft_min_token' );
        if ( false !== $cached ) {
            return (int) $cached;
        }

        global $wpdb;

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        $row = $wpdb->get_row( "SHOW VARIABLES LIKE 'innodb_ft_min_token_size'" );
        if ( ! $row || '' === (string) $row->Value ) {
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
            $row = $wpdb->get_row( "SHOW VARIABLES LIKE 'ft_min_word_len'" );
        }

        // 3 is the InnoDB default; assuming it when the variable is unreadable
        // errs toward the fallback, never toward an empty result set.
        $size = ( $row && (int) $row->Value > 0 ) ? (int) $row->Value : 3;

        set_transient( 'glse_ft_min_token', $size, WEEK_IN_SECONDS );

        return $size;
    }

    /**
     * Creates one index.
     *
     * @return true|WP_Error
     */
    public static function create( string $index ) {
        if ( ! isset( self::COLUMNS[ $index ] ) ) {
            return new WP_Error( 'glse_ft_unknown', __( 'Unknown index.', 'glance-live-search' ) );
        }

        $supported = self::supported();
        if ( is_wp_error( $supported ) ) {
            return $supported;
        }

        if ( self::exists( $index ) ) {
            return true;
        }

        global $wpdb;

        /*
         * The index name and columns are class constants, never user input, so
         * they are interpolated - identifiers cannot be bound as parameters.
         */
        $sql = sprintf(
            'ALTER TABLE %s ADD FULLTEXT INDEX %s (%s)',
            $wpdb->posts,
            $index,
            implode( ', ', self::COLUMNS[ $index ] )
        );

        $hidden = $wpdb->hide_errors();
        // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        $ok = $wpdb->query( $sql );
        $wpdb->show_errors( $hidden );

        self::flush_cache();

        if ( false === $ok ) {
            return new WP_Error( 'glse_ft_create', $wpdb->last_error ?: __( 'The index could not be created.', 'glance-live-search' ) );
        }

        return true;
    }

    /**
     * Drops one index.
     *
     * @return true|WP_Error
     */
    public static function drop( string $index ) {
        if ( ! isset( self::COLUMNS[ $index ] ) ) {
            return new WP_Error( 'glse_ft_unknown', __( 'Unknown index.', 'glance-live-search' ) );
        }

        if ( ! self::exists( $index ) ) {
            return true;
        }

        global $wpdb;

        $hidden = $wpdb->hide_errors();
        // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        $ok = $wpdb->query( sprintf( 'ALTER TABLE %s DROP INDEX %s', $wpdb->posts, $index ) );
        $wpdb->show_errors( $hidden );

        self::flush_cache();

        if ( false === $ok ) {
            return new WP_Error( 'glse_ft_drop', $wpdb->last_error ?: __( 'The index could not be removed.', 'glance-live-search' ) );
        }

        return true;
    }

    /**
     * Removes every index this class created.
     *
     * Called from the uninstall routine: a full-text index left on wp_posts
     * outlives the plugin that put it there and slows every write for nothing.
     */
    public static function drop_all(): void {
        foreach ( self::names() as $index ) {
            self::drop( $index );
        }
    }

    /**
     * What the Performance tab needs to describe the current state.
     *
     * @return array{supported:true|WP_Error, rows:int, min_token:int, indexes:array<string,bool>}
     */
    public static function status(): array {
        global $wpdb;

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        $rows = (int) $wpdb->get_var( $wpdb->prepare(
            "SELECT TABLE_ROWS FROM INFORMATION_SCHEMA.TABLES
             WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s LIMIT 1",
            DB_NAME,
            $wpdb->posts
        ) );

        $indexes = [];
        foreach ( self::names() as $index ) {
            $indexes[ $index ] = self::exists( $index );
        }

        return [
            'supported' => self::supported(),
            'rows'      => $rows,
            'min_token' => self::min_token_size(),
            'indexes'   => $indexes,
        ];
    }
}
