<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.

/**
 * DML read/read-write database handle use tests
 *
 * @package    core
 * @category   dml
 * @copyright  2018 Srdjan Janković, Catalyst IT
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 */

namespace core;

defined('MOODLE_INTERNAL') || die();

require_once(__DIR__.'/fixtures/read_replica_moodle_database_table_names.php');
require_once(__DIR__.'/fixtures/read_replica_moodle_database_special.php');
require_once(__DIR__.'/../../tests/fixtures/event_fixtures.php');

/**
 * DML read/read-write database handle use tests
 *
 * @package    core
 * @category   dml
 * @copyright  2018 Catalyst IT
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 * @covers     \moodle_read_replica_trait
 */
final class dml_read_replica_test extends \database_driver_testcase {

    /** @var float */
    static private $dbreadonlylatency = 0.8;

    /**
     * Instantiates a test database interface object.
     *
     * @param bool $wantlatency
     * @param mixed $readonly
     * @param mixed $dbclass
     * @return read_replica_moodle_database $db
     */
    public function new_db(
        $wantlatency = false,
        $readonly = [
            ['dbhost' => 'test_ro1', 'dbport' => 1, 'dbuser' => 'test1', 'dbpass' => 'test1'],
            ['dbhost' => 'test_ro2', 'dbport' => 2, 'dbuser' => 'test2', 'dbpass' => 'test2'],
            ['dbhost' => 'test_ro3', 'dbport' => 3, 'dbuser' => 'test3', 'dbpass' => 'test3'],
        ],
        $dbclass = read_replica_moodle_database::class
    ): read_replica_moodle_database {
        $dbhost = 'test_rw';
        $dbname = 'test';
        $dbuser = 'test';
        $dbpass = 'test';
        $prefix = 'test_';
        $dboptions = ['readonly' => ['instance' => $readonly, 'exclude_tables' => ['exclude']]];
        if ($wantlatency) {
            $dboptions['readonly']['latency'] = self::$dbreadonlylatency;
        }

        $db = new $dbclass();
        $db->connect($dbhost, $dbuser, $dbpass, $dbname, $prefix, $dboptions);
        return $db;
    }

    /**
     * Asert that the mock handle returned from read_replica_moodle_database methods
     * is a readonly replica handle.
     *
     * @param string $handle
     * @return void
     */
    private function assert_readonly_handle($handle): void {
        $this->assertMatchesRegularExpression('/^test_ro\d:\d:test\d:test\d$/', $handle);
    }

    /**
     * moodle_read_replica_trait::table_names() test data provider
     *
     * @return array
     * @dataProvider table_names_provider
     */
    public static function table_names_provider(): array {
        return [
            [
                "SELECT *
                 FROM {user} u
                 JOIN (
                     SELECT DISTINCT u.id FROM {user} u
                     JOIN {user_enrolments} ue1 ON ue1.userid = u.id
                     JOIN {enrol} e ON e.id = ue1.enrolid
                     WHERE u.id NOT IN (
                         SELECT DISTINCT ue.userid FROM {user_enrolments} ue
                         JOIN {enrol} e ON (e.id = ue.enrolid AND e.courseid = 1)
                         WHERE ue.status = 'active'
                           AND e.status = 'enabled'
                           AND ue.timestart < now()
                           AND (ue.timeend = 0 OR ue.timeend > now())
                     )
                 ) je ON je.id = u.id
                 JOIN (
                     SELECT DISTINCT ra.userid
                       FROM {role_assignments} ra
                      WHERE ra.roleid IN (1, 2, 3)
                        AND ra.contextid = 'ctx'
                  ) rainner ON rainner.userid = u.id
                  WHERE u.deleted = 0",
                [
                    'user',
                    'user',
                    'user_enrolments',
                    'enrol',
                    'user_enrolments',
                    'enrol',
                    'role_assignments',
                ]
            ],
        ];
    }

    /**
     * Test moodle_read_replica_trait::table_names() query parser.
     *
     * @param string $sql
     * @param array $tables
     * @return void
     * @dataProvider table_names_provider
     */
    public function test_table_names($sql, $tables): void {
        $db = new read_replica_moodle_database_table_names();

        $this->assertEquals($tables, $db->table_names($db->fix_sql_params($sql)[0]));
    }

    /**
     * Test correct database handles are used in a read-read-write-read scenario.
     * Test lazy creation of the write handle.
     *
     * @return void
     */
    public function test_read_read_write_read(): void {
        $DB = $this->new_db(true);

        $this->assertEquals(0, $DB->perf_get_reads_replica());
        $this->assertNull($DB->get_dbhwrite());

        $handle = $DB->get_records('table');
        $this->assert_readonly_handle($handle);
        $readsreplica = $DB->perf_get_reads_replica();
        $this->assertGreaterThan(0, $readsreplica);
        $this->assertNull($DB->get_dbhwrite());

        $handle = $DB->get_records('table2');
        $this->assert_readonly_handle($handle);
        $readsreplica = $DB->perf_get_reads_replica();
        $this->assertGreaterThan(1, $readsreplica);
        $this->assertNull($DB->get_dbhwrite());

        $now = microtime(true);
        $handle = $DB->insert_record_raw('table', array('name' => 'blah'));
        $this->assertEquals('test_rw::test:test', $handle);

        if (microtime(true) - $now < self::$dbreadonlylatency) {
            $handle = $DB->get_records('table');
            $this->assertEquals('test_rw::test:test', $handle);
            $this->assertEquals($readsreplica, $DB->perf_get_reads_replica());

            sleep(1);
        }

        $handle = $DB->get_records('table');
        $this->assert_readonly_handle($handle);
        $this->assertEquals($readsreplica + 1, $DB->perf_get_reads_replica());
    }

    /**
     * Test correct database handles are used in a read-write-write scenario.
     *
     * @return void
     */
    public function test_read_write_write(): void {
        $DB = $this->new_db();

        $this->assertEquals(0, $DB->perf_get_reads_replica());
        $this->assertNull($DB->get_dbhwrite());

        $handle = $DB->get_records('table');
        $this->assert_readonly_handle($handle);
        $readsreplica = $DB->perf_get_reads_replica();
        $this->assertGreaterThan(0, $readsreplica);
        $this->assertNull($DB->get_dbhwrite());

        $handle = $DB->insert_record_raw('table', array('name' => 'blah'));
        $this->assertEquals('test_rw::test:test', $handle);

        $handle = $DB->update_record_raw('table', array('id' => 1, 'name' => 'blah2'));
        $this->assertEquals('test_rw::test:test', $handle);
        $this->assertEquals($readsreplica, $DB->perf_get_reads_replica());
    }

    /**
     * Test correct database handles are used in a write-read-read scenario.
     *
     * @return void
     */
    public function test_write_read_read(): void {
        $DB = $this->new_db();

        $this->assertEquals(0, $DB->perf_get_reads_replica());
        $this->assertNull($DB->get_dbhwrite());

        $handle = $DB->insert_record_raw('table', array('name' => 'blah'));
        $this->assertEquals('test_rw::test:test', $handle);
        $this->assertEquals(0, $DB->perf_get_reads_replica());

        $handle = $DB->get_records('table');
        $this->assertEquals('test_rw::test:test', $handle);
        $this->assertEquals(0, $DB->perf_get_reads_replica());

        $handle = $DB->get_records_sql("SELECT * FROM {table2} JOIN {table}");
        $this->assertEquals('test_rw::test:test', $handle);
        $this->assertEquals(0, $DB->perf_get_reads_replica());

        sleep(1);

        $handle = $DB->get_records('table');
        $this->assert_readonly_handle($handle);
        $this->assertEquals(1, $DB->perf_get_reads_replica());

        $handle = $DB->get_records('table2');
        $this->assert_readonly_handle($handle);
        $this->assertEquals(2, $DB->perf_get_reads_replica());

        $handle = $DB->get_records_sql("SELECT * FROM {table2} JOIN {table}");
        $this->assert_readonly_handle($handle);
        $this->assertEquals(3, $DB->perf_get_reads_replica());
    }

    /**
     * Test readonly handle is not used for reading from temptables.
     *
     * @return void
     */
    public function test_read_temptable(): void {
        $DB = $this->new_db();
        $DB->add_temptable('temptable1');

        $this->assertEquals(0, $DB->perf_get_reads_replica());
        $this->assertNull($DB->get_dbhwrite());

        $handle = $DB->get_records('temptable1');
        $this->assertEquals('test_rw::test:test', $handle);
        $this->assertEquals(0, $DB->perf_get_reads_replica());

        $DB->delete_temptable('temptable1');
    }

    /**
     * Test readonly handle is not used for reading from excluded tables.
     *
     * @return void
     */
    public function test_read_excluded_tables(): void {
        $DB = $this->new_db();

        $this->assertEquals(0, $DB->perf_get_reads_replica());
        $this->assertNull($DB->get_dbhwrite());

        $handle = $DB->get_records('exclude');
        $this->assertEquals('test_rw::test:test', $handle);
        $this->assertEquals(0, $DB->perf_get_reads_replica());
    }

    /**
     * Test readonly handle is not used during transactions.
     * Test last written time is adjusted post-transaction,
     * so the latency parameter is applied properly.
     *
     * @return void
     */
    public function test_transaction(): void {
        $DB = $this->new_db(true);

        $this->assertNull($DB->get_dbhwrite());

        $skip = false;
        $transaction = $DB->start_delegated_transaction();
        $now = microtime(true);
        $handle = $DB->get_records_sql("SELECT * FROM {table}");
        // Use rw handle during transaction.
        $this->assertEquals('test_rw::test:test', $handle);

        $handle = $DB->insert_record_raw('table', array('name' => 'blah'));
        // Introduce delay so we can check that table write timestamps
        // are adjusted properly.
        sleep(1);
        $transaction->allow_commit();
        // This condition should always evaluate true, however we need to
        // safeguard from an unaccounted delay that can break this test.
        if (microtime(true) - $now < 1 + self::$dbreadonlylatency) {
            // Not enough time passed, use rw handle.
            $handle = $DB->get_records_sql("SELECT * FROM {table}");
            $this->assertEquals('test_rw::test:test', $handle);

            // Make sure enough time passes.
            sleep(1);
        } else {
            $skip = true;
        }

        // Exceeded latency time, use ro handle.
        $handle = $DB->get_records_sql("SELECT * FROM {table}");
        $this->assert_readonly_handle($handle);

        if ($skip) {
            $this->markTestSkipped("Delay too long to test write handle immediately after transaction");
        }
    }

    /**
     * Test readonly handle is not used immediately after update
     * Test last written time is adjusted post-write,
     * so the latency parameter is applied properly.
     *
     * @return void
     */
    public function test_long_update(): void {
        $DB = $this->new_db(true);

        $this->assertNull($DB->get_dbhwrite());

        $skip = false;

        list($sql, $params, $ptype) = $DB->fix_sql_params("UPDATE {table} SET a = 1 WHERE id = 1");
        $DB->with_query_start_end($sql, $params, SQL_QUERY_UPDATE, function ($dbh) use (&$now) {
            sleep(1);
            $now = microtime(true);
        });

        // This condition should always evaluate true, however we need to
        // safeguard from an unaccounted delay that can break this test.
        if (microtime(true) - $now < self::$dbreadonlylatency) {
            // Not enough time passed, use rw handle.
            $handle = $DB->get_records_sql("SELECT * FROM {table}");
            $this->assertEquals('test_rw::test:test', $handle);

            // Make sure enough time passes.
            sleep(1);
        } else {
            $skip = true;
        }

        // Exceeded latency time, use ro handle.
        $handle = $DB->get_records_sql("SELECT * FROM {table}");
        $this->assert_readonly_handle($handle);

        if ($skip) {
            $this->markTestSkipped("Delay too long to test write handle immediately after transaction");
        }
    }

    /**
     * Test readonly handle is not used with events
     * when the latency parameter is applied properly.
     *
     * @return void
     */
    public function test_transaction_with_events(): void {
        $this->with_global_db(function () {
            global $DB;

            $DB = $this->new_db(true, ['test_ro'], read_replica_moodle_database_special::class);
            $DB->set_tables([
                'config_plugins' => [
                    'columns' => [
                        'plugin' => (object)['meta_type' => ''],
                    ]
                ]
            ]);

            $this->assertNull($DB->get_dbhwrite());

            $called = false;
            $transaction = $DB->start_delegated_transaction();
            $now = microtime(true);

            $observers = [
                [
                    'eventname'   => '\core_tests\event\unittest_executed',
                    'callback'    => function (\core_tests\event\unittest_executed $event) use ($DB, $now, &$called) {
                        $called = true;
                        $this->assertFalse($DB->is_transaction_started());

                        // This condition should always evaluate true, however we need to
                        // safeguard from an unaccounted delay that can break this test.
                        if (microtime(true) - $now < 1 + self::$dbreadonlylatency) {
                            // Not enough time passed, use rw handle.
                            $handle = $DB->get_records_sql_p("SELECT * FROM {table}");
                            $this->assertEquals('test_rw::test:test', $handle);

                            // Make sure enough time passes.
                            sleep(1);
                        } else {
                            $this->markTestSkipped("Delay too long to test write handle immediately after transaction");
                        }

                        // Exceeded latency time, use ro handle.
                        $handle = $DB->get_records_sql_p("SELECT * FROM {table}");
                        $this->assertEquals('test_ro::test:test', $handle);
                    },
                    'internal'    => 0,
                ],
            ];
            \core\event\manager::phpunit_replace_observers($observers);

            $handle = $DB->get_records_sql_p("SELECT * FROM {table}");
            // Use rw handle during transaction.
            $this->assertEquals('test_rw::test:test', $handle);

            $handle = $DB->insert_record_raw('table', array('name' => 'blah'));
            // Introduce delay so we can check that table write timestamps
            // are adjusted properly.
            sleep(1);
            $event = \core_tests\event\unittest_executed::create([
                'context' => \context_system::instance(),
                'other' => ['sample' => 1]
            ]);
            $event->trigger();
            $transaction->allow_commit();

            $this->assertTrue($called);
        });
    }

    /**
     * Test failed readonly connection falls back to write connection.
     *
     * @return void
     */
    public function test_read_only_conn_fail(): void {
        $this->resetDebugging();

        $DB = $this->new_db(false, 'test_ro_fail');

        $this->assertEquals(0, $DB->perf_get_reads_replica());
        $this->assertNotNull($DB->get_dbhwrite());

        $handle = $DB->get_records('table');
        $this->assertEquals('test_rw::test:test', $handle);
        $readsreplica = $DB->perf_get_reads_replica();
        $this->assertEquals(0, $readsreplica);

        $debugging = array_map(function ($d) {
            return $d->message;
        }, $this->getDebuggingMessages());
        $this->resetDebugging();
        $this->assertEquals([
            'Readonly db connection failed for host test_ro_fail: test_ro_fail',
            'Readwrite db connection succeeded for host test_rw',
        ], $debugging);
    }

    /**
     * In multiple replicas scenario, test failed readonly connection falls back to
     * another readonly connection.
     *
     * @return void
     */
    public function test_read_only_conn_first_fail(): void {
        $this->resetDebugging();

        $DB = $this->new_db(false, ['test_ro_fail', 'test_ro_ok']);

        $this->assertEquals(0, $DB->perf_get_reads_replica());
        $this->assertNull($DB->get_dbhwrite());

        $handle = $DB->get_records('table');
        $this->assertEquals('test_ro_ok::test:test', $handle);
        $readsreplica = $DB->perf_get_reads_replica();
        $this->assertEquals(1, $readsreplica);

        $debugging = array_map(function ($d) {
            return $d->message;
        }, $this->getDebuggingMessages());
        $this->resetDebugging();
        $this->assertEquals([
            'Readonly db connection failed for host test_ro_fail: test_ro_fail',
            'Readonly db connection succeeded for host test_ro_ok',
        ], $debugging);
    }

    /**
     * Helper to restore global $DB
     *
     * @param callable $test
     * @return void
     */
    private function with_global_db($test) {
        global $DB;

        $dbsave = $DB;
        try {
            $test();
        }
        finally {
            $DB = $dbsave;
        }
    }

    /**
     * Test lock_db table exclusion
     *
     * @return void
     */
    public function test_lock_db(): void {
        $this->with_global_db(function () {
            global $DB;

            $DB = $this->new_db(true, ['test_ro'], read_replica_moodle_database_special::class);
            $DB->set_tables([
                'lock_db' => [
                    'columns' => [
                        'resourcekey' => (object)['meta_type' => ''],
                        'owner' => (object)['meta_type' => ''],
                    ]
                ]
            ]);

            $this->assertEquals(0, $DB->perf_get_reads_replica());
            $this->assertNull($DB->get_dbhwrite());

            $lockfactory = new \core\lock\db_record_lock_factory('default');
            if (!$lockfactory->is_available()) {
                $this->markTestSkipped("db_record_lock_factory not available");
            }

            $lock = $lockfactory->get_lock('abc', 2);
            $lock->release();
            $this->assertEquals(0, $DB->perf_get_reads_replica());
            $this->assertTrue($DB->perf_get_reads() > 0);
        });
    }

    /**
     * Test sessions table exclusion
     *
     * @return void
     */
    public function test_sessions(): void {
        $this->with_global_db(function () {
            global $DB, $CFG;

            $CFG->dbsessions = true;
            $DB = $this->new_db(true, ['test_ro'], read_replica_moodle_database_special::class);
            $DB->set_tables([
                'sessions' => [
                    'columns' => [
                        'sid' => (object)['meta_type' => ''],
                    ]
                ]
            ]);

            $this->assertEquals(0, $DB->perf_get_reads_replica());
            $this->assertNull($DB->get_dbhwrite());

            $session = new \core\session\database();
            $session->read('dummy');

            $this->assertEquals(0, $DB->perf_get_reads_replica());
            $this->assertTrue($DB->perf_get_reads() > 0);
        });

        \core\session\manager::restart_with_write_lock(false);
    }
}
