<?php

declare(strict_types=1);

/**
 * This file is part of CodeIgniter 4 framework.
 *
 * (c) CodeIgniter Foundation <admin@codeigniter.com>
 *
 * For the full copyright and license information, please view
 * the LICENSE file that was distributed with this source code.
 */

namespace CodeIgniter\Test;

use CodeIgniter\CodeIgniter;
use CodeIgniter\Config\Factories;
use CodeIgniter\Database\BaseConnection;
use CodeIgniter\Database\MigrationRunner;
use CodeIgniter\Database\Seeder;
use CodeIgniter\Events\Events;
use CodeIgniter\Router\RouteCollection;
use CodeIgniter\Session\Handlers\ArrayHandler;
use CodeIgniter\Test\Mock\MockCache;
use CodeIgniter\Test\Mock\MockCodeIgniter;
use CodeIgniter\Test\Mock\MockEmail;
use CodeIgniter\Test\Mock\MockSession;
use Config\App;
use Config\Autoload;
use Config\Email;
use Config\Modules;
use Config\Services;
use Config\Session;
use Exception;
use PHPUnit\Framework\TestCase;

/**
 * Framework test case for PHPUnit.
 */
abstract class CIUnitTestCase extends TestCase
{
    use ReflectionHelper;

    /**
     * @var CodeIgniter
     */
    protected $app;

    /**
     * Methods to run during setUp.
     *
     * WARNING: Do not override unless you know exactly what you are doing.
     *          This property may be deprecated in the future.
     *
     * @var list<string> array of methods
     */
    protected $setUpMethods = [
        'resetFactories',
        'mockCache',
        'mockEmail',
        'mockSession',
    ];

    /**
     * Methods to run during tearDown.
     *
     * WARNING: This property may be deprecated in the future.
     *
     * @var list<string> array of methods
     */
    protected $tearDownMethods = [];

    /**
     * Store of identified traits.
     */
    private ?array $traits = null;

    // --------------------------------------------------------------------
    // Database Properties
    // --------------------------------------------------------------------

    /**
     * Should run db migration?
     *
     * @var bool
     */
    protected $migrate = true;

    /**
     * Should run db migration only once?
     *
     * @var bool
     */
    protected $migrateOnce = false;

    /**
     * Should run seeding only once?
     *
     * @var bool
     */
    protected $seedOnce = false;

    /**
     * Should the db be refreshed before test?
     *
     * @var bool
     */
    protected $refresh = true;

    /**
     * The seed file(s) used for all tests within this test case.
     * Should be fully-namespaced or relative to $basePath
     *
     * @var class-string<Seeder>|list<class-string<Seeder>>
     */
    protected $seed = '';

    /**
     * The path to the seeds directory.
     * Allows overriding the default application directories.
     *
     * @var string
     */
    protected $basePath = SUPPORTPATH . 'Database';

    /**
     * The namespace(s) to help us find the migration classes.
     * `null` is equivalent to running `spark migrate --all`.
     * Note that running "all" runs migrations in date order,
     * but specifying namespaces runs them in namespace order (then date)
     *
     * @var array|string|null
     */
    protected $namespace = 'Tests\Support';

    /**
     * The name of the database group to connect to.
     * If not present, will use the defaultGroup.
     *
     * @var non-empty-string
     */
    protected $DBGroup = 'tests';

    /**
     * Our database connection.
     *
     * @var BaseConnection
     */
    protected $db;

    /**
     * Migration Runner instance.
     *
     * @var MigrationRunner|null
     */
    protected $migrations;

    /**
     * Seeder instance
     *
     * @var Seeder
     */
    protected $seeder;

    /**
     * Stores information needed to remove any
     * rows inserted via $this->hasInDatabase();
     *
     * @var array
     */
    protected $insertCache = [];

    // --------------------------------------------------------------------
    // Feature Properties
    // --------------------------------------------------------------------

    /**
     * If present, will override application
     * routes when using call().
     *
     * @var RouteCollection|null
     */
    protected $routes;

    /**
     * Values to be set in the SESSION global
     * before running the test.
     *
     * @var array
     */
    protected $session = [];

    /**
     * Enabled auto clean op buffer after request call
     *
     * @var bool
     */
    protected $clean = true;

    /**
     * Custom request's headers
     *
     * @var array
     */
    protected $headers = [];

    /**
     * Allows for formatting the request body to what
     * the controller is going to expect
     *
     * @var string
     */
    protected $bodyFormat = '';

    /**
     * Allows for directly setting the body to what
     * it needs to be.
     *
     * @var mixed
     */
    protected $requestBody = '';

    // --------------------------------------------------------------------
    // Staging
    // --------------------------------------------------------------------

    /**
     * Load the helpers.
     */
    public static function setUpBeforeClass(): void
    {
        parent::setUpBeforeClass();

        helper(['url', 'test']);
    }

    protected function setUp(): void
    {
        parent::setUp();

        if (! $this->app instanceof CodeIgniter) {
            $this->app = $this->createApplication();
        }

        foreach ($this->setUpMethods as $method) {
            $this->{$method}();
        }

        // Check for the database trait
        if (method_exists($this, 'setUpDatabase')) {
            $this->setUpDatabase();
        }

        // Check for other trait methods
        $this->callTraitMethods('setUp');
    }

    protected function tearDown(): void
    {
        parent::tearDown();

        foreach ($this->tearDownMethods as $method) {
            $this->{$method}();
        }

        // Check for the database trait
        if (method_exists($this, 'tearDownDatabase')) {
            $this->tearDownDatabase();
        }

        // Check for other trait methods
        $this->callTraitMethods('tearDown');
    }

    /**
     * Checks for traits with corresponding
     * methods for setUp or tearDown.
     *
     * @param string $stage 'setUp' or 'tearDown'
     */
    private function callTraitMethods(string $stage): void
    {
        if ($this->traits === null) {
            $this->traits = class_uses_recursive($this);
        }

        foreach ($this->traits as $trait) {
            $method = $stage . class_basename($trait);

            if (method_exists($this, $method)) {
                $this->{$method}();
            }
        }
    }

    // --------------------------------------------------------------------
    // Mocking
    // --------------------------------------------------------------------

    /**
     * Resets shared instanced for all Factories components
     *
     * @return void
     */
    protected function resetFactories()
    {
        Factories::reset();
    }

    /**
     * Resets shared instanced for all Services
     *
     * @return void
     */
    protected function resetServices(bool $initAutoloader = true)
    {
        Services::reset($initAutoloader);
    }

    /**
     * Injects the mock Cache driver to prevent filesystem collisions
     *
     * @return void
     */
    protected function mockCache()
    {
        Services::injectMock('cache', new MockCache());
    }

    /**
     * Injects the mock email driver so no emails really send
     *
     * @return void
     */
    protected function mockEmail()
    {
        Services::injectMock('email', new MockEmail(config(Email::class)));
    }

    /**
     * Injects the mock session driver into Services
     *
     * @return void
     */
    protected function mockSession()
    {
        $_SESSION = [];

        $config  = config(Session::class);
        $session = new MockSession(new ArrayHandler($config, '0.0.0.0'), $config);

        Services::injectMock('session', $session);
    }

    // --------------------------------------------------------------------
    // Assertions
    // --------------------------------------------------------------------

    /**
     * Custom function to hook into CodeIgniter's Logging mechanism
     * to check if certain messages were logged during code execution.
     *
     * @param string|null $expectedMessage
     *
     * @return bool
     */
    public function assertLogged(string $level, $expectedMessage = null)
    {
        $result = TestLogger::didLog($level, $expectedMessage);

        $this->assertTrue($result, sprintf(
            'Failed asserting that expected message "%s" with level "%s" was logged.',
            $expectedMessage ?? '',
            $level,
        ));

        return $result;
    }

    /**
     * Asserts that there is a log record that contains `$logMessage` in the message.
     */
    public function assertLogContains(string $level, string $logMessage, string $message = ''): void
    {
        $this->assertTrue(
            TestLogger::didLog($level, $logMessage, false),
            $message !== '' ? $message : sprintf(
                'Failed asserting that logs have a record of message containing "%s" with level "%s".',
                $logMessage,
                $level,
            ),
        );
    }

    /**
     * Hooks into CodeIgniter's Events system to check if a specific
     * event was triggered or not.
     *
     * @throws Exception
     */
    public function assertEventTriggered(string $eventName): bool
    {
        $found     = false;
        $eventName = strtolower($eventName);

        foreach (Events::getPerformanceLogs() as $log) {
            if ($log['event'] !== $eventName) {
                continue;
            }

            $found = true;
            break;
        }

        $this->assertTrue($found);

        return $found;
    }

    /**
     * Hooks into xdebug's headers capture, looking for presence of
     * a specific header emitted.
     *
     * @param string $header The leading portion of the header we are looking for
     */
    public function assertHeaderEmitted(string $header, bool $ignoreCase = false): void
    {
        $this->assertNotNull(
            $this->getHeaderEmitted($header, $ignoreCase, __METHOD__),
            "Didn't find header for {$header}",
        );
    }

    /**
     * Hooks into xdebug's headers capture, looking for absence of
     * a specific header emitted.
     *
     * @param string $header The leading portion of the header we don't want to find
     */
    public function assertHeaderNotEmitted(string $header, bool $ignoreCase = false): void
    {
        $this->assertNull(
            $this->getHeaderEmitted($header, $ignoreCase, __METHOD__),
            "Found header for {$header}",
        );
    }

    /**
     * Custom function to test that two values are "close enough".
     * This is intended for extended execution time testing,
     * where the result is close but not exactly equal to the
     * expected time, for reasons beyond our control.
     *
     * @param float|int $actual
     *
     * @return void
     *
     * @throws Exception
     */
    public function assertCloseEnough(int $expected, $actual, string $message = '', int $tolerance = 1)
    {
        $difference = abs($expected - (int) floor($actual));

        $this->assertLessThanOrEqual($tolerance, $difference, $message);
    }

    /**
     * Custom function to test that two values are "close enough".
     * This is intended for extended execution time testing,
     * where the result is close but not exactly equal to the
     * expected time, for reasons beyond our control.
     *
     * @param mixed $expected
     * @param mixed $actual
     *
     * @return bool|null
     *
     * @throws Exception
     */
    public function assertCloseEnoughString($expected, $actual, string $message = '', int $tolerance = 1)
    {
        $expected = (string) $expected;
        $actual   = (string) $actual;
        if (strlen($expected) !== strlen($actual)) {
            return false;
        }

        try {
            $expected   = (int) substr($expected, -2);
            $actual     = (int) substr($actual, -2);
            $difference = abs($expected - $actual);

            $this->assertLessThanOrEqual($tolerance, $difference, $message);
        } catch (Exception) {
            return false;
        }

        return null;
    }

    // --------------------------------------------------------------------
    // Utility
    // --------------------------------------------------------------------

    /**
     * Loads up an instance of CodeIgniter
     * and gets the environment setup.
     *
     * @return CodeIgniter
     */
    protected function createApplication()
    {
        // Initialize the autoloader.
        service('autoloader')->initialize(new Autoload(), new Modules());

        $app = new MockCodeIgniter(new App());
        $app->initialize();

        return $app;
    }

    /**
     * Return first matching emitted header.
     */
    protected function getHeaderEmitted(string $header, bool $ignoreCase = false, string $method = __METHOD__): ?string
    {
        if (! function_exists('xdebug_get_headers')) {
            $this->markTestSkipped($method . '() requires xdebug.');
        }

        foreach (xdebug_get_headers() as $emittedHeader) {
            $found = $ignoreCase
                ? (str_starts_with(strtolower($emittedHeader), strtolower($header)))
                : (str_starts_with($emittedHeader, $header));

            if ($found) {
                return $emittedHeader;
            }
        }

        return null;
    }
}
