<?php

declare(strict_types=1);

namespace apexl\Io\TestInfrastructure;

use Apexl\Io\Cache\Interfaces\CacheManagerInterface;
use apexl\Io\Client;
use apexl\Io\enums\HttpMethod;
use apexl\Io\factories\ClientFactory;
use apexl\Io\factories\ContainerFactory;
use apexl\Io\interfaces\GlobalDataInterface;
use apexl\Io\interfaces\OutputManagerInterface;
use apexl\Io\modules\user\exceptions\PermissionException;
use apexl\Io\modules\user\TestInfrastructure\SpoofsGlobalUser;
use apexl\Io\services\FilePaths;
use apexl\Vault\Vault;
use DI\Bridge\Slim\Bridge;
use DI\Container;
use DI\DependencyException;
use DI\NotFoundException;
use Dotenv\Dotenv;
use Faker\Factory as FakerFactory;
use Phinx\Console\PhinxApplication;
use PHPUnit\Framework\TestCase;
use Psr\Http\Message\ResponseInterface;
use Slim\App;
use Slim\Psr7\Factory\RequestFactory;
use Slim\Psr7\Request;
use Symfony\Component\Console\Input\StringInput;
use Symfony\Component\Console\Output\ConsoleOutput;
use Symfony\Component\Console\Output\NullOutput;
use function apexl\Io\config;
use function apexl\Io\container;

abstract class FeatureTest extends TestCase
{
    use SpoofsGlobalUser;

    protected const bool USES_DATABASE = true;
    const string BASE_DIR = __DIR__ . '/../../../../../..';

    protected static ?Vault $db = null;
    protected static ?App $application = null;
    protected static bool $schemaReady = false;
    private array $spoofedServerVars = [];

    private RequestFactory $requestFactory;

    /**
     * Snapshot of GlobalData's 'entities' taken in setUp() immediately AFTER
     * addSpoofedGlobalUser(), so the restore baseline includes the spoofed
     * user. It must not be taken before the spoof: restoring a pre-spoof
     * (empty) state would force a re-spoof every test, and SpoofsGlobalUser
     * creates its user with a fixed email whose first row is committed
     * outside the per-test transaction — every re-spoof then hits the
     * unique-email collision, the insert is silently swallowed, and the
     * current user degrades to an id-less entity. Restored verbatim in
     * tearDown() so a current-user swap made by a subclass setUp() or a test
     * body can't leak into the next test class sharing this worker process.
     */
    private mixed $globalDataEntitiesAtSetUp = null;

    public static function setUpBeforeClass(): void
    {
        parent::setUpBeforeClass();

        self::ensureBootstrapped();

        // A preceding UnitTest may have swapped $GLOBALS['container'] for its own
        // stripped container (no Bridge::create, no Slim\App binding). Restore it
        // so subsequent feature tests find the bootstrapped bindings.
        $GLOBALS['container'] = self::$application->getContainer();

        if (static::USES_DATABASE && self::$db === null) {
            self::attachDatabase();
        }
    }

    /**
     * Idempotent bootstrap gate. Safe to call from data providers (which run
     * before setUpBeforeClass) so they can resolve services from the container.
     * The application + container are static, so subsequent calls are no-ops.
     */
    public static function ensureBootstrapped(): void
    {
        if (self::$application !== null) {
            return;
        }

        self::isolateTestDatabaseForParatestWorker();
        Dotenv::createImmutable(self::BASE_DIR)->load();
        self::bootstrapApplication();
    }

    /**
     * When running under paratest, each worker gets its own database so parallel
     * test processes don't clobber one another's schema + transactions.
     */
    private static function isolateTestDatabaseForParatestWorker(): void
    {
        $token = $_ENV['TEST_TOKEN'] ?? getenv('TEST_TOKEN') ?: null;
        if (!$token) {
            return;
        }

        $dbName = 'test_' . preg_replace('/[^a-zA-Z0-9_]/', '', (string) $token);
        $_ENV['DB_TEST_NAME'] = $dbName;
        putenv('DB_TEST_NAME=' . $dbName);
    }

    protected static function bootstrapApplication(): void
    {
        $application = Bridge::create(new ContainerFactory()->make(self::BASE_DIR));
        self::$application = $application;
        /** @var Container $container */
        $container = $application->getContainer();
        $GLOBALS['container'] = $container;

        $container->set(FilePaths::class, new FilePaths(self::BASE_DIR));

        $client = $container->get(ClientFactory::class)->make();
        $container->set(Client::class, $client);

        if (static::USES_DATABASE) {
            self::$db = $container->get(Vault::class);
            if (!self::$schemaReady) {
                self::migrateDatabase();
            }
        }

        //start the app (bootstrap), provide the primary config file.
        $client->bootstrap();

        if (static::USES_DATABASE && !self::$schemaReady) {
            self::seedDatabase();
            self::$schemaReady = true;
        }

    }

    protected function get(string $uri, ?int $expectCode = 200, array $headers = []): array
    {
        return $this->executeRequest(
            method: HttpMethod::GET,
            uri: $uri,
            headers: $headers,
            expectCode: $expectCode,
        );
    }

    protected function executeRequest(HttpMethod $method, string $uri, array $body = [], array $headers = [], ?int $expectCode = 200): array
    {
        $request = $this->request($method, $uri, $body, $headers);

        $response = self::$application->handle($request);

        return $this->responseToData($response, $expectCode);
    }

    protected function request(HttpMethod $method, string $uri, array $body = [], array $headers = []): Request
    {
        /** @var Request $request */
        $request = $this->requestFactory
            ->createRequest($method->name, $uri)
            ->withHeader('accept', 'application/json');

        foreach ($headers as $key => $value) {
            $request = $request->withHeader($key, $value);
        }

        if ($body) {
            $request = $request->withHeader('content-type', 'application/json');
            $request->getBody()->write(json_encode($body));
        }

        return $request;

    }

    protected function responseToData(ResponseInterface $response, ?int $expectCode = 200): array
    {
        if ($expectCode !== null) {
            $this->assertEquals($expectCode, $response->getStatusCode());
        }
        $response->getBody()->rewind();

        return json_decode($response->getBody()->getContents(), true);

    }

    private static function migrateDatabase(): void
    {
        $dbName = env('DB_TEST_NAME', 'test');

        // Use a server-level PDO connection (no default DB) so we can drop/recreate
        // test_N without first connecting to it — the Vault connection above would
        // fail if the target DB doesn't exist yet (e.g. on a paratest worker's first run).
        $pdo = new \PDO(
            sprintf(
                'mysql:host=%s;port=%s',
                env('DB_TEST_HOST', 'db'),
                env('DB_TEST_PORT', '3306'),
            ),
            env('DB_TEST_USER', 'db'),
            env('DB_TEST_PASS', 'db'),
        );
        $pdo->exec(sprintf('drop database if exists `%s`', $dbName));
        $pdo->exec(sprintf('create database `%s`', $dbName));
        $pdo = null;

        self::callPhinx('migrate --configuration ' . self::phinxConfigPath());
    }

    private static function callPhinx(string $command): void
    {
        $output = filter_var(env('SHOW_MIGRATION_OUTPUT_IN_TESTS', false), FILTER_VALIDATE_BOOL) ? new ConsoleOutput(
        ) : new NullOutput();

        $app = new PhinxApplication();
        $app->setAutoExit(false);

        $app->run(
            new StringInput($command),
            $output
        );

    }

    private static function phinxConfigPath(): string
    {
        return realpath(__DIR__ . '/../../../config/phinx.php')
            ?: __DIR__ . '/../../../config/phinx.php';
    }

    private static function seedDatabase(): void
    {
        self::callPhinx('seed:run --configuration ' . self::phinxConfigPath());
    }

    private static function attachDatabase(): void
    {
        self::$db = container()->get(Vault::class);
        if (!self::$schemaReady) {
            self::migrateDatabase();
            self::seedDatabase();
            self::$schemaReady = true;
        }
    }

    public static function tearDownAfterClass(): void
    {
        // Application + DB connection are shared across all test classes in the
        // worker process; nothing to tear down per-class. PHP releases the PDO
        // connection when the worker exits.
        parent::tearDownAfterClass();
    }

    /**
     * @throws PermissionException
     * @throws NotFoundException
     * @throws DependencyException
     */
    protected function setUp(): void
    {
        // Reset dome
        container()->get(CacheManagerInterface::class)->clearAll();
        container()->set(OutputManagerInterface::class, container()->make(OutputManagerInterface::class));

        if (static::USES_DATABASE) {
            self::$db->executeSql('START TRANSACTION');
        }

        $this->addSpoofedGlobalUser();

        // GlobalData is a per-process singleton, not reset between test classes.
        // Snapshot 'entities' now — AFTER the spoof, so the spoofed user is part
        // of the restore baseline (see $globalDataEntitiesAtSetUp) — so that
        // tearDown() can put it back exactly as this test started, no matter
        // what the test (or a subclass setUp()) does to the current user.
        $this->globalDataEntitiesAtSetUp = container()->get(GlobalDataInterface::class)->getData('entities');

        $this->faker = FakerFactory::create();
        $this->requestFactory = new RequestFactory();
        $this->spoofServerVars();
    }

    private function spoofServerVars(): void
    {
        $backendDomain = config('http.backend_domain');
        $https = parse_url($backendDomain, PHP_URL_SCHEME) === 'https' ? 'on' : 'off';
        $host = parse_url($backendDomain, PHP_URL_HOST);

        foreach ([
                     'HTTPS' => $https,
                     'HTTP_HOST' => $host,

                 ] as $key => $value) {
            $this->spoofedServerVars[$key] = $_SERVER[$key] ?? null;
            $_SERVER[$key] = $value;
        }
    }

    protected function tearDown(): void
    {
        if (static::USES_DATABASE) {
            self::$db->executeSql(
                filter_var(env('COMMIT_TEST_TRANSACTIONS', false), FILTER_VALIDATE_BOOL) ? 'COMMIT' : 'ROLLBACK'
            );
        }

        // Restore GlobalData's 'entities' to the post-spoof setUp() snapshot,
        // regardless of what this test (or a subclass setUp()/tearDown()) did
        // to the current user. Without this, a test that swaps 'entities.user'
        // to an admin/god-mode account and forgets to restore it leaks that
        // identity into every later-running test class in this worker process,
        // since SpoofsGlobalUser::addSpoofedGlobalUser() only spoofs a fresh
        // user when 'entities.user' isn't already set. The baseline includes
        // the spoofed user on purpose — see $globalDataEntitiesAtSetUp.
        container()->get(GlobalDataInterface::class)->addData(
            'entities',
            $this->globalDataEntitiesAtSetUp,
            merge: false
        );

        parent::tearDown();

        $this->unspoofServerVars();
    }

    private function unspoofServerVars(): void
    {
        foreach ($this->spoofedServerVars as $key => $value) {
            if ($value === null) {
                unset($_SERVER[$key]);
            } else {
                $_SERVER[$key] = $value;
            }

            unset($this->spoofedServerVars[$key]);
        }
    }

    protected function post(string $uri, array $body = [], ?int $expectCode = 200, array $headers = []): array
    {
        return $this->executeRequest(
            method: HttpMethod::POST,
            uri: $uri,
            body: $body,
            headers: $headers,
            expectCode: $expectCode
        );
    }
}
