<?php
/**
 * WordPress queries based assertions.
 *
 * @package Codeception\Module
 */

namespace lucatume\WPBrowser\Module;

use ArrayIterator;
use Codeception\Exception\ModuleException;
use Codeception\Lib\ModuleContainer;
use Codeception\Module;
use lucatume\WPBrowser\Iterators\Filters\ActionsQueriesFilter;
use lucatume\WPBrowser\Iterators\Filters\ClassMethodQueriesFilter;
use lucatume\WPBrowser\Iterators\Filters\FactoryQueriesFilter;
use lucatume\WPBrowser\Iterators\Filters\FiltersQueriesFilter;
use lucatume\WPBrowser\Iterators\Filters\FunctionQueriesFilter;
use lucatume\WPBrowser\Iterators\Filters\MainStatementQueriesFilter;
use lucatume\WPBrowser\Iterators\Filters\SetupTearDownQueriesFilter;
use lucatume\WPBrowser\Utils\Arr;
use PHPUnit\Framework\Assert;
use wpdb;

/**
 * Class WPQueries
 *
 * @package Codeception\Module
 */
class WPQueries extends Module
{

    /**
     * A list of the filtered queries.
     *
     * @var array<array{0: string, 1: float, 2: string, 3: float, 4?: array<int|string,mixed>}>
     */
    protected array $filteredQueries = [];
    /**
     * @var callable[]
     */
    protected array $assertions = [];
    private ?wpdb $wpdb;

    /**
     * WPQueries constructor.
     *
     * @param ModuleContainer $moduleContainer The current module container.
     * @param array<string,mixed>|null $config The current module configuration.
     * @param wpdb|null $wpdbInstance          The current wpdb instance.
     */
    public function __construct(
        ModuleContainer $moduleContainer,
        ?array $config = null,
        ?wpdb $wpdbInstance = null
    ) {
        $wpdbInstance = $wpdbInstance ?? $GLOBALS['wpdb'] ?? null;
        $this->wpdb = $wpdbInstance;
        parent::__construct($moduleContainer, $config);
    }

    /**
     * Initializes the module.
     *
     * @throws ModuleException If the initialization fails.
     *
     */
    public function _initialize(): void
    {
        if (!(
            $this->moduleContainer->hasModule(WPLoader::class)
            || $this->moduleContainer->hasModule('WPLoader')
        )) {
            throw new ModuleException(
                __CLASS__,
                'The WPLoader module is required for WPQueries to work.'
            );
        }

        $wpdbInstance = $this->wpdbInstance ?? $GLOBALS['wpdb'] ?? null;
        if (!$wpdbInstance instanceof wpdb) {
            throw new ModuleException(
                __CLASS__,
                'The wpdb instance is not available: either provide it or make sure to load WordPress ' .
                'before using this module.'
            );
        }
        $this->wpdb = $wpdbInstance;

        if (!defined('SAVEQUERIES')) {
            define('SAVEQUERIES', true);
        } elseif (!SAVEQUERIES) {
            $message = 'The "SAVEQUERIES" should either not be set or set to "true";' .
                ' this module cannot work if "SAVEQUERIES" is not set to "true".';
            throw new ModuleException(__CLASS__, $message);
        }
    }

    /**
     * Runs before each test method.
     *
     * @throws ModuleException
     */
    public function _cleanup(): void
    {
        $this->_getWpdb()->queries = [];
    }

    /**
     * Asserts that at least one query was made during the test.
     *
     * Queries generated by `setUp`, `tearDown` and `factory` methods are excluded by default.
     *
     * @example
     * ```php
     * wp_cache_delete('page-posts', 'acme');
     * $pagePosts = $plugin->getPagePosts();
     * $I->assertQueries('Queries should be made to set the cache.')
     * ```
     *
     * @param string $message An optional message to override the default one.
     */
    public function assertQueries(string $message = ''): void
    {
        $this->readQueries();
        $message = $message ?: 'Failed asserting that queries were made.';
        Assert::assertNotEmpty($this->filteredQueries, $message);
    }

    /**
     * Reads the current queries.
     */
    private function readQueries(): void
    {
        $wpdb = $this->_getWpdb();

        if (empty($wpdb->queries)) {
            $this->filteredQueries = [];
        } else {
            $this->filteredQueries = $this->getQueries();
        }
    }

    /**
     * Returns the saved queries after filtering.
     *
     * @param wpdb|null $wpdb
     */
    public function _getFilteredQueriesIterator(?wpdb $wpdb = null): SetupTearDownQueriesFilter
    {
        if (null === $wpdb) {
            $wpdb = $this->_getWpdb();
        }

        $queriesArrayIterator = new ArrayIterator($wpdb->queries);

        $iterator = new FactoryQueriesFilter($queriesArrayIterator);
        $setupTearDownQueriesFilter = new SetupTearDownQueriesFilter($iterator);
        return $setupTearDownQueriesFilter;
    }

    /**
     * Asserts that no queries were made.
     *
     * Queries generated by `setUp`, `tearDown` and `factory` methods are excluded by default.
     *
     * @example
     * ```php
     * $posts = $this->factory()->post->create_many(3);
     * wp_cache_set('page-posts', $posts, 'acme');
     * $pagePosts = $plugin->getPagePosts();
     * $I->assertNotQueries('Queries should not be made if the cache is set.')
     * ```
     *
     * @param string $message An optional message to override the default one.
     */
    public function assertNotQueries(string $message = ''): void
    {
        $this->readQueries();
        $message = $message ?: 'Failed asserting that no queries were made.';
        Assert::assertEmpty($this->filteredQueries, $message);
    }

    /**
     * Asserts that n queries have been made.
     *
     * @example
     * ```php
     * $posts = $this->factory()->post->create_many(3);
     * $cachedUsers = $this->factory()->user->create_many(2);
     * $nonCachedUsers = $this->factory()->user->create_many(2);
     * foreach($cachedUsers as $userId){
     *      wp_cache_set('page-posts-for-user-' . $userId, $posts, 'acme');
     * }
     * // Run the same query as different users
     * foreach(array_merge($cachedUsers, $nonCachedUsers) as $userId){
     *      $pagePosts = $plugin->getPagePostsForUser($userId);
     * }
     * $I->assertCountQueries(2, 'A query should be made for each user missing cached posts.')
     * ```
     *
     * @param int $n          The expected number of queries.
     * @param string $message An optional message to override the default one.
     */
    public function assertCountQueries(int $n, string $message = ''): void
    {
        $this->readQueries();
        $message = $message ?: ('Failed asserting that ' . $n . ' queries were made.');
        Assert::assertCount($n, $this->filteredQueries, $message);
    }

    /**
     * Asserts that at least a query starting with the specified statement was made.
     *
     * Queries generated by `setUp`, `tearDown` and `factory` methods are excluded by default.
     *
     * @example
     * ```php
     * wp_cache_flush();
     * cached_get_posts($args);
     * $I->assertQueriesByStatement('SELECT');
     * ```
     *
     * @param string $statement  A simple string the statement should start with or a valid regular expression.
     *                           Regular expressions must contain delimiters.
     * @param string $message    An optional message to override the default one.
     */
    public function assertQueriesByStatement(string $statement, string $message = ''): void
    {
        $this->readQueries();
        $message = $message ?:
            ('Failed asserting that queries beginning with statement [' . $statement . '] were made.');
        $statementIterator = new MainStatementQueriesFilter(new ArrayIterator($this->filteredQueries), $statement);
        Assert::assertNotEmpty(iterator_to_array($statementIterator, false), $message);
    }

    /**
     * Asserts that at least one query has been made by the specified class method.
     *
     * Queries generated by `setUp`, `tearDown` and `factory` methods are excluded by default.
     *
     * @example
     * ```php
     * $options = new Acme\Options();
     * $options->update('showAds', false);
     * $I->assertQueriesByMethod('Acme\Options', 'update');
     * ```
     *
     * @param string $class   The fully qualified name of the class to check.
     * @param string $method  The name of the method to check.
     * @param string $message An optional message to override the default one.
     */
    public function assertQueriesByMethod(string $class, string $method, string $message = ''): void
    {
        $this->readQueries();
        $class = ltrim($class, '\\');
        $message = $message ?: ('Failed asserting that queries were made by method [' . $class . '::' . $method . ']');
        $statementIterator = new ClassMethodQueriesFilter(new ArrayIterator($this->filteredQueries), $class, $method);
        Assert::assertNotEmpty(iterator_to_array($statementIterator, false), $message);
    }

    /**
     * Asserts that no queries have been made by the specified class method.
     *
     * Queries generated by `setUp`, `tearDown` and `factory` methods are excluded by default.
     *
     * @example
     * ```php
     * $bookRepository = new Acme\BookRepository();
     * $repository->where('ID', 23)->set('title', 'Peter Pan', $deferred = true);
     * $this->assertNotQueriesByStatement('INSERT', 'Deferred write should happen on __destruct');
     * ```
     *
     * @param string $statement  A simple string the statement should start with or a valid regular expression.
     *                           Regular expressions must contain delimiters.
     * @param string $message    An optional message to override the default one.
     */
    public function assertNotQueriesByStatement(string $statement, string $message = ''): void
    {
        $message = $message ?: ('Failed asserting that no queries beginning with statement ['
            . $statement . '] were made.');
        $this->assertQueriesCountByStatement(0, $statement, $message);
    }

    /**
     * Asserts that n queries starting with the specified statement were made.
     *
     * Queries generated by `setUp`, `tearDown` and `factory` methods are excluded by default.
     *
     * @example
     * ```php
     * $bookRepository = new Acme\BookRepository();
     * $repository->where('ID', 23)->set('title', 'Peter Pan', $deferred = true);
     * $repository->where('ID', 89)->set('title', 'Moby-dick', $deferred = true);
     * $repository->where('ID', 2389)->set('title', 'The call of the wild', $deferred = false);
     * $this->assertQueriesCountByStatement(1, 'INSERT', 'Deferred write should happen on __destruct');
     * ```
     *
     * @param int $n             The expected number of queries.
     * @param string $statement  A simple string the statement should start with or a valid regular expression.
     *                           Regular expressions must contain delimiters.
     * @param string $message    An optional message to override the default one.
     */
    public function assertQueriesCountByStatement(int $n, string $statement, string $message = ''): void
    {
        $this->readQueries();
        $message = $message ?: ('Failed asserting that ' . $n . ' queries beginning with statement ['
            . $statement . '] were made.');
        $statementIterator = new MainStatementQueriesFilter(new ArrayIterator($this->filteredQueries), $statement);
        Assert::assertCount($n, iterator_to_array($statementIterator, false), $message);
    }

    /**
     * Asserts that no queries have been made by the specified class method.
     *
     * Queries generated by `setUp`, `tearDown` and `factory` methods are excluded by default.
     *
     * @example
     * ```php
     * $options = new Acme\Options();
     * $options->update('adsSource', 'not-a-real-url.org');
     * $I->assertNotQueriesByMethod('Acme\Options', 'update');
     * ```
     *
     * @param string $class   The fully qualified name of the class to check.
     * @param string $method  The name of the method to check.
     * @param string $message An optional message to override the default one.
     */
    public function assertNotQueriesByMethod(string $class, string $method, string $message = ''): void
    {
        $message = $message ?: ('Failed asserting that no queries were made by method [' . $class . '::'
            . $method . ']');
        $this->assertQueriesCountByMethod(0, $class, $method, $message);
    }

    /**
     * Asserts that n queries have been made by the specified class method.
     *
     * Queries generated by `setUp`, `tearDown` and `factory` methods are excluded by default.
     *
     * @example
     * ```php
     * $bookRepository = new Acme\BookRepository();
     * $repository->where('ID', 23)->commit('title', 'Peter Pan');
     * $repository->where('ID', 89)->commit('title', 'Moby-dick');
     * $repository->where('ID', 2389)->commit('title', 'The call of the wild');
     * $this->assertQueriesCountByMethod(3, 'Acme\BookRepository', 'commit');
     * ```
     *
     * @param int $n          The expected number of queries.
     * @param string $class   The fully qualified name of the class to check.
     * @param string $method  The name of the method to check.
     * @param string $message An optional message to override the default one.
     */
    public function assertQueriesCountByMethod(int $n, string $class, string $method, string $message = ''): void
    {
        $this->readQueries();
        $class = ltrim($class, '\\');
        $message = $message ?: ('Failed asserting that ' . $n . ' queries were made by method ['
            . $class . '::' . $method . ']');
        $statementIterator = new ClassMethodQueriesFilter(new ArrayIterator($this->filteredQueries), $class, $method);
        Assert::assertCount($n, iterator_to_array($statementIterator, false), $message);
    }

    /**
     * Asserts that queries were made by the specified function.
     *
     * Queries generated by `setUp`, `tearDown` and `factory` methods are excluded by default.
     *
     * @example
     * ```php
     * acme_clean_queue();
     * $this->assertQueriesByFunction('acme_clean_queue');
     * ```
     *
     * @param string $function The fully qualified name of the function to check.
     * @param string $message  An optional message to override the default one.
     */
    public function assertQueriesByFunction(string $function, string $message = ''): void
    {
        $this->readQueries();
        $message = $message ?: ('Failed asserting that queries were made by function [' . $function . ']');
        $statementIterator = new FunctionQueriesFilter(new ArrayIterator($this->filteredQueries), $function);
        Assert::assertNotEmpty(iterator_to_array($statementIterator, false), $message);
    }

    /**
     * Asserts that no queries were made by the specified function.
     *
     * Queries generated by `setUp`, `tearDown` and `factory` methods are excluded by default.
     *
     * @example
     * ```php
     * $this->assertEmpty(Acme\get_orphaned_posts());
     * Acme\delete_orphaned_posts();
     * $this->assertNotQueriesByFunction('Acme\delete_orphaned_posts');
     * ```
     *
     * @param string $function The fully qualified name of the function to check.
     * @param string $message  An optional message to override the default one.
     */
    public function assertNotQueriesByFunction(string $function, string $message = ''): void
    {
        $message = $message ?: ('Failed asserting that no queries were made by function [' . $function . ']');
        $this->assertQueriesCountByFunction(0, $function, $message);
    }

    /**
     * Asserts that n queries were made by the specified function.
     *
     * Queries generated by `setUp`, `tearDown` and `factory` methods are excluded by default.
     *
     * @example
     * ```php
     * $this->assertCount(3, Acme\get_orphaned_posts());
     * Acme\delete_orphaned_posts();
     * $this->assertQueriesCountByFunction(3, 'Acme\delete_orphaned_posts');
     * ```
     *
     * @param int $n           The expected number of queries.
     * @param string $function The function to check the queries for.
     * @param string $message  An optional message to override the default one.
     */
    public function assertQueriesCountByFunction(int $n, string $function, string $message = ''): void
    {
        $this->readQueries();
        $message = $message ?: ('Failed asserting that ' . $n . ' queries were made by function [' . $function . ']');
        $statementIterator = new FunctionQueriesFilter(new ArrayIterator($this->filteredQueries), $function);
        Assert::assertCount($n, iterator_to_array($statementIterator, false), $message);
    }

    /**
     * Asserts that queries were made by the specified class method starting with the specified SQL statement.
     *
     * Queries generated by `setUp`, `tearDown` and `factory` methods are excluded by default.
     *
     * @example
     * ```php
     * Acme\BookRepository::new(['title' => 'Alice in Wonderland'])->commit();
     * $this->assertQueriesByStatementAndMethod('UPDATE', Acme\BookRepository::class, 'commit');
     * ```
     *
     * @param string $statement  A simple string the statement should start with or a valid regular expression.
     *                           Regular expressions must contain delimiters.
     * @param string $class      The fully qualified name of the class to check.
     * @param string $method     The name of the method to check.
     * @param string $message    An optional message to override the default one.
     */
    public function assertQueriesByStatementAndMethod(
        string $statement,
        string $class,
        string $method,
        string $message = ''
    ): void {
        $this->readQueries();
        $message = $message ?: ('Failed asserting that queries were made by method ['
            . $class . '::' . $method . '] containing statement [' . $statement . ']');
        $statementIterator = new MainStatementQueriesFilter(new ClassMethodQueriesFilter(
            new ArrayIterator($this->filteredQueries),
            $class,
            $method
        ), $statement);
        Assert::assertNotEmpty(iterator_to_array($statementIterator, false), $message);
    }

    /**
     * Asserts that no queries were made by the specified class method starting with the specified SQL statement.
     *
     * Queries generated by `setUp`, `tearDown` and `factory` methods are excluded by default.
     *
     * @example
     * ```php
     * Acme\BookRepository::new(['title' => 'Alice in Wonderland'])->commit();
     * $this->assertQueriesByStatementAndMethod('INSERT', Acme\BookRepository::class, 'commit');
     * ```
     *
     * @param string $statement  A simple string the statement should start with or a valid regular expression.
     *                           Regular expressions must contain delimiters.
     * @param string $class      The fully qualified name of the class to check.
     * @param string $method     The name of the method to check.
     * @param string $message    An optional message to override the default one.
     */
    public function assertNotQueriesByStatementAndMethod(
        string $statement,
        string $class,
        string $method,
        string $message = ''
    ): void {
        $message = $message ?: ('Failed asserting that no queries were made by method [' . $class . '::'
            . $method . '] containing statement [' . $statement . ']');
        $this->assertQueriesCountByStatementAndMethod(0, $statement, $class, $method, $message);
    }

    /**
     * Asserts that n queries were made by the specified class method starting with the specified SQL statement.
     *
     * Queries generated by `setUp`, `tearDown` and `factory` methods are excluded by default.
     *
     * @example
     * ```php
     * Acme\BookRepository::new(['title' => 'Alice in Wonderland'])->commit();
     * Acme\BookRepository::new(['title' => 'Moby-Dick'])->commit();
     * Acme\BookRepository::new(['title' => 'The Call of the Wild'])->commit();
     * $this->assertQueriesCountByStatementAndMethod(3, 'INSERT', Acme\BookRepository::class, 'commit');
     * ```
     *
     * @param int $n             The expected number of queries.
     * @param string $statement  A simple string the statement should start with or a valid regular expression.
     *                           Regular expressions must contain delimiters.
     * @param string $class      The fully qualified name of the class to check.
     * @param string $method     The name of the method to check.
     * @param string $message    An optional message to override the default one.
     */
    public function assertQueriesCountByStatementAndMethod(
        int $n,
        string $statement,
        string $class,
        string $method,
        string $message = ''
    ): void {
        $this->readQueries();
        $message = $message ?: ('Failed asserting that ' . $n . ' queries were made by method ['
            . $class . '::' . $method . '] containing statement [' . $statement . ']');
        $statementIterator = new MainStatementQueriesFilter(new ClassMethodQueriesFilter(
            new ArrayIterator($this->filteredQueries),
            $class,
            $method
        ), $statement);
        Assert::assertCount($n, iterator_to_array($statementIterator, false), $message);
    }

    /**
     * Asserts that queries were made by the specified function starting with the specified SQL statement.
     *
     * Queries generated by `setUp`, `tearDown` and `factory` methods are excluded by default.
     *
     * @example
     * ```php
     * wp_insert_post(['post_type' => 'book', 'post_title' => 'Alice in Wonderland']);
     * $this->assertQueriesByStatementAndFunction('INSERT', 'wp_insert_post');
     * ```
     *
     * @param string $statement  A simple string the statement should start with or a valid regular expression.
     *                           Regular expressions must contain delimiters.
     * @param string $function   The fully qualified function name.
     * @param string $message    An optional message to override the default one.
     */
    public function assertQueriesByStatementAndFunction(string $statement, string $function, string $message = ''): void
    {
        $this->readQueries();
        $message = $message ?: ('Failed asserting that queries were made by function ['
            . $function . '] containing statement [' . $statement . ']');
        $statementIterator = new MainStatementQueriesFilter(new FunctionQueriesFilter(
            new ArrayIterator($this->filteredQueries),
            $function
        ), $statement);
        Assert::assertNotEmpty(iterator_to_array($statementIterator, false), $message);
    }

    /**
     * Asserts that no queries were made by the specified function starting with the specified SQL statement.
     *
     * Queries generated by `setUp`, `tearDown` and `factory` methods are excluded by default.
     *
     * @example
     * ```php
     * wp_insert_post(['ID' => $bookId, 'post_title' => 'The Call of the Wild']);
     * $this->assertNotQueriesByStatementAndFunction('INSERT', 'wp_insert_post');
     * $this->assertQueriesByStatementAndFunction('UPDATE', 'wp_insert_post');
     * ```
     *
     * @param string $statement  A simple string the statement should start with or a valid regular expression.
     *                           Regular expressions must contain delimiters.
     * @param string $function   The name of the function to check the assertions for.
     * @param string $message    An optional message to override the default one.
     */
    public function assertNotQueriesByStatementAndFunction(
        string $statement,
        string $function,
        string $message = ''
    ): void {
        $message = $message ?: ('Failed asserting that no queries were made by function ['
            . $function . '] containing statement [' . $statement . ']');
        $this->assertQueriesCountByStatementAndFunction(0, $statement, $function, $message);
    }

    /**
     * Asserts that n queries were made by the specified function starting with the specified SQL statement.
     *
     * Queries generated by `setUp`, `tearDown` and `factory` methods are excluded by default.
     *
     * @example
     * ```php
     * wp_insert_post(['post_type' => 'book', 'post_title' => 'The Call of the Wild']);
     * wp_insert_post(['post_type' => 'book', 'post_title' => 'Alice in Wonderland']);
     * wp_insert_post(['post_type' => 'book', 'post_title' => 'The Chocolate Factory']);
     * $this->assertQueriesCountByStatementAndFunction(3, 'INSERT', 'wp_insert_post');
     * ```
     *
     * @param int $n             The expected number of queries.
     * @param string $statement  A simple string the statement should start with or a valid regular expression.
     *                           Regular expressions must contain delimiters.
     * @param string $function   The fully-qualified function name.
     * @param string $message    An optional message to override the default one.
     */
    public function assertQueriesCountByStatementAndFunction(
        int $n,
        string $statement,
        string $function,
        string $message = ''
    ): void {
        $this->readQueries();
        $message = $message ?: ('Failed asserting that ' . $n . ' queries were made by method ['
            . $function . '] containing statement [' . $statement . ']');
        $statementIterator = new MainStatementQueriesFilter(new FunctionQueriesFilter(
            new ArrayIterator($this->filteredQueries),
            $function
        ), $statement);
        Assert::assertCount(
            expectedCount: $n,
            haystack: iterator_to_array($statementIterator, false),
            message: $message
        );
    }

    /**
     * Asserts that at least one query was made as a consequence of the specified action.
     *
     * Queries generated by `setUp`, `tearDown` and `factory` methods are excluded by default.
     *
     * @example
     * ```php
     * add_action( 'edit_post', function($postId){
     *         $count = get_option('acme_title_updates_count');
     *         update_option('acme_title_updates_count', ++$count);
     * } );
     * wp_update_post(['ID' => $bookId, 'post_title' => 'New Title']);
     * $this->assertQueriesByAction('edit_post');
     * ```
     *
     * @param string $action  The action name, e.g. 'init'.
     * @param string $message An optional message to override the default one.
     */
    public function assertQueriesByAction(string $action, string $message = ''): void
    {
        $this->readQueries();
        $message = $message ?: ('Failed asserting that queries were triggered by action [' . $action . ']');
        $iterator = new ActionsQueriesFilter(new ArrayIterator($this->filteredQueries), $action);
        Assert::assertNotEmpty(iterator_to_array($iterator, false), $message);
    }

    /**
     * Asserts that no queries were made as a consequence of the specified action.
     *
     * Queries generated by `setUp`, `tearDown` and `factory` methods are excluded by default.
     *
     * @example
     * ```php
     * add_action( 'edit_post', function($postId){
     *         $count = get_option('acme_title_updates_count');
     *         update_option('acme_title_updates_count', ++$count);
     * } );
     * wp_delete_post($bookId);
     * $this->assertNotQueriesByAction('edit_post');
     * ```
     *
     * @param string $action  The action name, e.g. 'init'.
     * @param string $message An optional message to override the default one.
     */
    public function assertNotQueriesByAction(string $action, string $message = ''): void
    {
        $message = $message ?: ('Failed asserting that no queries were triggered by action [' . $action . ']');
        $this->assertQueriesCountByAction(0, $action, $message);
    }

    /**
     * Asserts that n queries were made as a consequence of the specified action.
     *
     * Queries generated by `setUp`, `tearDown` and `factory` methods are excluded by default.
     *
     * @example
     * ```php
     * add_action( 'edit_post', function($postId){
     *         $count = get_option('acme_title_updates_count');
     *         update_option('acme_title_updates_count', ++$count);
     * } );
     * wp_update_post(['ID' => $bookOneId, 'post_title' => 'One']);
     * wp_update_post(['ID' => $bookTwoId, 'post_title' => 'Two']);
     * wp_update_post(['ID' => $bookThreeId, 'post_title' => 'Three']);
     * $this->assertQueriesCountByAction(3, 'edit_post');
     * ```
     *
     * @param int $n          The expected number of queries.
     * @param string $action  The action name, e.g. 'init'.
     * @param string $message An optional message to override the default one.
     */
    public function assertQueriesCountByAction(int $n, string $action, string $message = ''): void
    {
        $this->readQueries();
        $message = $message ?: ('Failed asserting that ' . $n . ' queries were triggered by action [' . $action . ']');
        $iterator = new ActionsQueriesFilter(new ArrayIterator($this->filteredQueries), $action);
        Assert::assertCount($n, iterator_to_array($iterator, false), $message);
    }

    /**
     * Asserts that at least one query was made as a consequence of the specified action containing the SQL query.
     *
     * Queries generated by `setUp`, `tearDown` and `factory` methods are excluded by default.
     *
     * @example
     * ```php
     * add_action( 'edit_post', function($postId){
     *         $count = get_option('acme_title_updates_count');
     *         update_option('acme_title_updates_count', ++$count);
     * } );
     * wp_update_post(['ID' => $bookId, 'post_title' => 'New']);
     * $this->assertQueriesByStatementAndAction('UPDATE', 'edit_post');
     * ```
     *
     * @param string $statement  A simple string the statement should start with or a valid regular expression.
     *                           Regular expressions must contain delimiters.
     * @param string $action     The action name, e.g. 'init'.
     * @param string $message    An optional message to override the default one.
     */
    public function assertQueriesByStatementAndAction(string $statement, string $action, string $message = ''): void
    {
        $this->readQueries();
        $message = $message ?: ('Failed asserting that queries were triggered by action  ['
            . $action . '] containing statement [' . $statement . ']');
        $iterator = new MainStatementQueriesFilter(new ActionsQueriesFilter(
            new ArrayIterator($this->filteredQueries),
            $action
        ), $statement);
        Assert::assertNotEmpty(iterator_to_array($iterator, false), $message);
    }

    /**
     * Asserts that no queries were made as a consequence of the specified action containing the SQL query.
     *
     * Queries generated by `setUp`, `tearDown` and `factory` methods are excluded by default.
     *
     * @example
     * ```php
     * add_action( 'edit_post', function($postId){
     *         $count = get_option('acme_title_updates_count');
     *         update_option('acme_title_updates_count', ++$count);
     * } );
     * wp_delete_post($bookId);
     * $this->assertNotQueriesByStatementAndAction('DELETE', 'delete_post');
     * ```
     *
     * @param string $statement  A simple string the statement should start with or a valid regular expression.
     *                           Regular expressions must contain delimiters.
     * @param string $action     The action name, e.g. 'init'.
     * @param string $message    An optional message to override the default one.
     */
    public function assertNotQueriesByStatementAndAction(string $statement, string $action, string $message = ''): void
    {
        $message = $message ?: ('Failed asserting that no queries were triggered by action  ['
            . $action . '] containing statement [' . $statement . ']');
        $this->assertQueriesCountByStatementAndAction(0, $statement, $action, $message);
    }

    /**
     * Asserts that n queries were made as a consequence of the specified action containing the specified SQL statement.
     *
     * Queries generated by `setUp`, `tearDown` and `factory` methods are excluded by default.
     *
     * @example
     * ```php
     * add_action( 'edit_post', function($postId){
     *         $count = get_option('acme_title_updates_count');
     *         update_option('acme_title_updates_count', ++$count);
     * } );
     * wp_delete_post($bookOneId);
     * wp_delete_post($bookTwoId);
     * wp_update_post(['ID' => $bookThreeId, 'post_title' => 'New']);
     * $this->assertQueriesCountByStatementAndAction(2, 'DELETE', 'delete_post');
     * $this->assertQueriesCountByStatementAndAction(1, 'INSERT', 'edit_post');
     * ```
     *
     * @param int $n             The expected number of queries.
     * @param string $statement  A simple string the statement should start with or a valid regular expression.
     *                           Regular expressions must contain delimiters.
     * @param string $action     The action name, e.g. 'init'.
     * @param string $message    An optional message to override the default one.
     */
    public function assertQueriesCountByStatementAndAction(
        int $n,
        string $statement,
        string $action,
        string $message = ''
    ): void {
        $this->readQueries();
        $message = $message ?: ('Failed asserting that ' . $n . ' queries were triggered by action  ['
            . $action . '] containing statement [' . $statement . ']');
        $iterator = new MainStatementQueriesFilter(new ActionsQueriesFilter(
            new ArrayIterator($this->filteredQueries),
            $action
        ), $statement);
        Assert::assertCount($n, iterator_to_array($iterator, false), $message);
    }

    /**
     * Asserts that at least one query was made as a consequence of the specified filter.
     *
     * Queries generated by `setUp`, `tearDown` and `factory` methods are excluded by default.
     *
     * @example
     * ```php
     * add_filter('the_title', function($title, $postId){
     *      $post = get_post($postId);
     *      if($post->post_type !== 'book'){
     *          return $title;
     *      }
     *      $new = get_option('acme_new_prefix');
     *      return "{$new} - " . $title;
     * });
     * $title = apply_filters('the_title', get_post($bookId)->post_title, $bookId);
     * $this->assertQueriesByFilter('the_title');
     * ```
     *
     * @param string $filter  The filter name, e.g. 'posts_where'.
     * @param string $message An optional message to override the default one.
     *
     */
    public function assertQueriesByFilter(string $filter, string $message = ''): void
    {
        $this->readQueries();
        $message = $message ?: ('Failed asserting that queries were triggered by filter [' . $filter . ']');
        $iterator = new FiltersQueriesFilter(new ArrayIterator($this->filteredQueries), $filter);
        Assert::assertNotEmpty(iterator_to_array($iterator, false), $message);
    }

    /**
     * Asserts that no queries were made as a consequence of the specified filter.
     *
     * Queries generated by `setUp`, `tearDown` and `factory` methods are excluded by default.
     *
     * @example
     * ```php
     * add_filter('the_title', function($title, $postId){
     *      $post = get_post($postId);
     *      if($post->post_type !== 'book'){
     *          return $title;
     *      }
     *      $new = get_option('acme_new_prefix');
     *      return "{$new} - " . $title;
     * });
     * $title = apply_filters('the_title', get_post($notABookId)->post_title, $notABookId);
     * $this->assertNotQueriesByFilter('the_title');
     * ```
     *
     * @param string $filter  The filter name, e.g. 'posts_where'.
     * @param string $message An optional message to override the default one.
     *
     */
    public function assertNotQueriesByFilter(string $filter, string $message = ''): void
    {
        $message = $message ?: ('Failed asserting that no queries were triggered by filter [' . $filter . ']');
        $this->assertQueriesCountByFilter(0, $filter, $message);
    }

    /**
     * Asserts that n queries were made as a consequence of the specified filter.
     *
     * Queries generated by `setUp`, `tearDown` and `factory` methods are excluded by default.
     *
     * @example
     * ```php
     * add_filter('the_title', function($title, $postId){
     *      $post = get_post($postId);
     *      if($post->post_type !== 'book'){
     *          return $title;
     *      }
     *      $new = get_option('acme_new_prefix');
     *      return "{$new} - " . $title;
     * });
     * $title = apply_filters('the_title', get_post($bookOneId)->post_title, $bookOneId);
     * $title = apply_filters('the_title', get_post($notABookId)->post_title, $notABookId);
     * $title = apply_filters('the_title', get_post($bookTwoId)->post_title, $bookTwoId);
     * $this->assertQueriesCountByFilter(2, 'the_title');
     * ```
     *
     * @param int $n          The expected number of queries.
     * @param string $filter  The filter name, e.g. 'posts_where'.
     * @param string $message An optional message to override the default one.
     *
     */
    public function assertQueriesCountByFilter(int $n, string $filter, string $message = ''): void
    {
        $this->readQueries();
        $message = $message ?: ('Failed asserting that ' . $n . ' queries were triggered by filter [' . $filter . ']');
        $iterator = new FiltersQueriesFilter(new ArrayIterator($this->filteredQueries), $filter);
        Assert::assertCount($n, iterator_to_array($iterator, false), $message);
    }

    /**
     * Asserts that at least one query was made as a consequence of the specified filter containing the SQL query.
     *
     * Queries generated by `setUp`, `tearDown` and `factory` methods are excluded by default.
     *
     * @example
     * ```php
     * add_filter('the_title', function($title, $postId){
     *      $post = get_post($postId);
     *      if($post->post_type !== 'book'){
     *          return $title;
     *      }
     *      $new = get_option('acme_new_prefix');
     *      return "{$new} - " . $title;
     * });
     * $title = apply_filters('the_title', get_post($bookId)->post_title, $bookId);
     * $this->assertQueriesByStatementAndFilter('SELECT', 'the_title');
     * ```
     *
     * @param string $statement A simple string the statement should start with or a valid regular expression.
     *                          Regular expressions must contain delimiters.
     * @param string $filter    The filter name, e.g. 'posts_where'.
     * @param string $message   An optional message to override the default one.
     *
     */
    public function assertQueriesByStatementAndFilter(string $statement, string $filter, string $message = ''): void
    {
        $this->readQueries();
        $message = $message ?: ('Failed asserting that queries were triggered by filter  ['
            . $filter . '] containing statement [' . $statement . ']');
        $iterator = new MainStatementQueriesFilter(new FiltersQueriesFilter(
            new ArrayIterator($this->filteredQueries),
            $filter
        ), $statement);
        Assert::assertNotEmpty(iterator_to_array($iterator, false), $message);
    }

    /**
     * Asserts that no queries were made as a consequence of the specified filter containing the specified SQL query.
     *
     * Queries generated by `setUp`, `tearDown` and `factory` methods are excluded by default.
     *
     * @example
     * ```php
     * add_filter('the_title', function($title, $postId){
     *      $post = get_post($postId);
     *      if($post->post_type !== 'book'){
     *          return $title;
     *      }
     *      $new = get_option('acme_new_prefix');
     *      return "{$new} - " . $title;
     * });
     * $title = apply_filters('the_title', get_post($notABookId)->post_title, $notABookId);
     * $this->assertNotQueriesByStatementAndFilter('SELECT', 'the_title');
     * ```
     *
     * @param string $statement  A simple string the statement should start with or a valid regular expression.
     *                           Regular expressions must contain delimiters.
     * @param string $filter     The filter name, e.g. 'posts_where'.
     * @param string $message    An optional message to override the default one.
     *
     */
    public function assertNotQueriesByStatementAndFilter(string $statement, string $filter, string $message = ''): void
    {
        $message = $message ?: ('Failed asserting that no queries were triggered by filter  ['
            . $filter . '] containing statement [' . $statement . ']');
        $this->assertQueriesCountByStatementAndFilter(0, $statement, $filter, $message);
    }

    /**
     * Asserts that n queries were made as a consequence of the specified filter containing the specified SQL statement.
     *
     * Queries generated by `setUp`, `tearDown` and `factory` methods are excluded by default.
     *
     * @example
     * ```php
     * add_filter('the_title', function($title, $postId){
     *      $post = get_post($postId);
     *      if($post->post_type !== 'book'){
     *          return $title;
     *      }
     *      $new = get_option('acme_new_prefix');
     *      return "{$new} - " . $title;
     * });
     * // Warm up the cache.
     * $title = apply_filters('the_title', get_post($bookOneId)->post_title, $bookOneId);
     * // Cache is warmed up now.
     * $title = apply_filters('the_title', get_post($bookTwoId)->post_title, $bookTwoId);
     * $title = apply_filters('the_title', get_post($bookThreeId)->post_title, $bookThreeId);
     * $this->assertQueriesCountByStatementAndFilter(1, 'SELECT', 'the_title');
     * ```
     *
     * @param int $n             The expected number of queries.
     * @param string $statement  A simple string the statement should start with or a valid regular expression.
     *                           Regular expressions must contain delimiters.
     * @param string $filter     The filter name, e.g. 'posts_where'.
     * @param string $message    An optional message to override the default one.
     *
     */
    public function assertQueriesCountByStatementAndFilter(
        int $n,
        string $statement,
        string $filter,
        string $message = ''
    ): void {
        $this->readQueries();
        $message = $message ?: ('Failed asserting that ' . $n . ' queries were triggered by filter  ['
            . $filter . '] containing statement [' . $statement . ']');
        $iterator = new MainStatementQueriesFilter(new FiltersQueriesFilter(
            new ArrayIterator($this->filteredQueries),
            $filter
        ), $statement);
        Assert::assertCount($n, iterator_to_array($iterator, false), $message);
    }

    /**
     * Returns the current number of queries.
     * Set-up and tear-down queries performed by the test case are filtered out.
     *
     * @example
     * ```php
     * // In a WPTestCase, using the global $wpdb object.
     * $queriesCount = $this->queries()->countQueries();
     * // In a WPTestCase, using a custom $wpdb object.
     * $queriesCount = $this->queries()->countQueries($customWdbb);
     * ```
     *
     * @param wpdb|null $wpdb A specific instance of the `wpdb` class or `null` to use the global one.
     *
     * @return int The current count of performed queries.
     */
    public function countQueries(?wpdb $wpdb = null): int
    {
        return count($this->getQueries($wpdb));
    }

    /**
     * Returns the queries currently performed by the global database object or the specified one.
     * Set-up and tear-down queries performed by the test case are filtered out.
     *
     * @example
     * ```php
     * // In a WPTestCase, using the global $wpdb object.
     * $queries = $this->queries()->getQueries();
     * // In a WPTestCase, using a custom $wpdb object.
     * $queries = $this->queries()->getQueries($customWdbb);
     * ```
     *
     * @param wpdb|null $wpdb A specific instance of the `wpdb` class or `null` to use the global one.
     *
     * @return array{0: string, 1: float, 2: string, 3: float, 4?: array<int|string,mixed>}[] An array of queries.
     */
    public function getQueries(?wpdb $wpdb = null): array
    {
        /** @var array{0: string, 1: float, 2: string, 3: float, 4?: array<int|string,mixed>}[] $logicQueries */
        $logicQueries = iterator_to_array($this->_getFilteredQueriesIterator($wpdb), false);
        return array_filter(
            $logicQueries,
            static fn($query) => is_array($query) && Arr::hasShape($query, ['string', 'numeric', 'string', 'numeric'])
        );
    }

    /**
     * @throws ModuleException
     */
    public function _getWpdb(): wpdb
    {
        if (!$this->wpdb instanceof wpdb) {
            throw new ModuleException(
                __CLASS__,
                'The wpdb object is not available. Too early?'
            );
        }

        return $this->wpdb;
    }
}
