<?php

namespace Tests\Unit\Platform\Modules\Installer\Runtime;

use Appwrite\Platform\Installer\Runtime\Config;
use Appwrite\Platform\Installer\Runtime\State;
use Appwrite\Platform\Installer\Server;
use PHPUnit\Framework\TestCase;

class StateTest extends TestCase
{
    protected ?State $state = null;
    private string $tempDir;
    private array $progressFiles = [];
    private ?string $savedEnv = null;

    protected function setUp(): void
    {
        $this->tempDir = sys_get_temp_dir() . '/appwrite-installer-test-' . uniqid();
        mkdir($this->tempDir, 0755, true);

        $this->state = new State();

        // Preserve env state
        $env = getenv('APPWRITE_INSTALLER_CONFIG');
        $this->savedEnv = $env !== false ? $env : null;
    }

    protected function tearDown(): void
    {
        // Clean up temp files
        $files = glob($this->tempDir . '/*');
        if (is_array($files)) {
            foreach ($files as $file) {
                @unlink($file);
            }
        }
        @rmdir($this->tempDir);

        // Clean up progress files
        foreach ($this->progressFiles as $file) {
            @unlink($file);
        }

        // Clean up lock file
        @unlink(Server::INSTALLER_LOCK_FILE);
        @unlink(Server::INSTALLER_CONFIG_FILE);

        // Restore env state
        if ($this->savedEnv !== null) {
            putenv('APPWRITE_INSTALLER_CONFIG=' . $this->savedEnv);
        } else {
            putenv('APPWRITE_INSTALLER_CONFIG');
        }

        $this->state = null;
    }

    private function trackProgressFile(string $installId): void
    {
        $this->progressFiles[] = $this->state->progressFilePath($installId);
    }

    public function testSanitizeInstallIdWithValidId(): void
    {
        $this->assertEquals('abc123', $this->state->sanitizeInstallId('abc123'));
    }

    public function testSanitizeInstallIdWithSpecialChars(): void
    {
        $this->assertEquals('abc123', $this->state->sanitizeInstallId('abc!@#123'));
    }

    public function testSanitizeInstallIdWithHyphensAndUnderscores(): void
    {
        $this->assertEquals('abc-123_def', $this->state->sanitizeInstallId('abc-123_def'));
    }

    public function testSanitizeInstallIdTruncatesTo64Chars(): void
    {
        $long = str_repeat('a', 100);
        $this->assertEquals(64, strlen($this->state->sanitizeInstallId($long)));
    }

    public function testSanitizeInstallIdWithEmptyString(): void
    {
        $this->assertEquals('', $this->state->sanitizeInstallId(''));
    }

    public function testSanitizeInstallIdWithNonString(): void
    {
        $this->assertEquals('', $this->state->sanitizeInstallId(123));
        $this->assertEquals('', $this->state->sanitizeInstallId(null));
    }

    public function testHashSensitiveValueProducesConsistentHash(): void
    {
        $hash1 = $this->state->hashSensitiveValue('secret');
        $hash2 = $this->state->hashSensitiveValue('secret');
        $this->assertEquals($hash1, $hash2);
    }

    public function testHashSensitiveValueDifferentInputsDifferentHashes(): void
    {
        $hash1 = $this->state->hashSensitiveValue('secret1');
        $hash2 = $this->state->hashSensitiveValue('secret2');
        $this->assertNotEquals($hash1, $hash2);
    }

    public function testHashSensitiveValueTrimsWhitespace(): void
    {
        $hash1 = $this->state->hashSensitiveValue('secret');
        $hash2 = $this->state->hashSensitiveValue('  secret  ');
        $this->assertEquals($hash1, $hash2);
    }

    public function testHashSensitiveValueEmptyStringReturnsEmpty(): void
    {
        $this->assertEquals('', $this->state->hashSensitiveValue(''));
        $this->assertEquals('', $this->state->hashSensitiveValue('   '));
    }

    public function testHashSensitiveValueReturnsSha256(): void
    {
        $hash = $this->state->hashSensitiveValue('test');
        $this->assertEquals(64, strlen($hash)); // SHA-256 produces 64 hex chars
        $this->assertMatchesRegularExpression('/^[a-f0-9]{64}$/', $hash);
    }

    public function testIsValidPortWithValidPorts(): void
    {
        $this->assertTrue($this->state->isValidPort('1'));
        $this->assertTrue($this->state->isValidPort('80'));
        $this->assertTrue($this->state->isValidPort('443'));
        $this->assertTrue($this->state->isValidPort('8080'));
        $this->assertTrue($this->state->isValidPort('65535'));
    }

    public function testIsValidPortWithInvalidPorts(): void
    {
        $this->assertFalse($this->state->isValidPort('0'));
        $this->assertFalse($this->state->isValidPort('65536'));
        $this->assertFalse($this->state->isValidPort('-1'));
        $this->assertFalse($this->state->isValidPort('abc'));
        $this->assertFalse($this->state->isValidPort(''));
        $this->assertFalse($this->state->isValidPort('80.5'));
        $this->assertFalse($this->state->isValidPort('80abc'));
    }

    public function testIsValidPortWithIntegerInput(): void
    {
        $this->assertTrue($this->state->isValidPort(80));
        $this->assertTrue($this->state->isValidPort(443));
        $this->assertFalse($this->state->isValidPort(0));
    }

    public function testIsValidEmailAddressWithValidEmails(): void
    {
        $this->assertTrue($this->state->isValidEmailAddress('user@example.com'));
        $this->assertTrue($this->state->isValidEmailAddress('test.user@domain.org'));
        $this->assertTrue($this->state->isValidEmailAddress('admin+tag@example.co.uk'));
    }

    public function testIsValidEmailAddressWithInvalidEmails(): void
    {
        $this->assertFalse($this->state->isValidEmailAddress(''));
        $this->assertFalse($this->state->isValidEmailAddress('notanemail'));
        $this->assertFalse($this->state->isValidEmailAddress('@domain.com'));
        $this->assertFalse($this->state->isValidEmailAddress('user@'));
    }

    public function testIsValidPasswordWithValidPasswords(): void
    {
        $this->assertTrue($this->state->isValidPassword('12345678'));
        $this->assertTrue($this->state->isValidPassword('abcdefgh'));
        $this->assertTrue($this->state->isValidPassword('P@ssw0rd!'));
    }

    public function testIsValidPasswordWithInvalidPasswords(): void
    {
        $this->assertFalse($this->state->isValidPassword(''));
        $this->assertFalse($this->state->isValidPassword('short'));
        $this->assertFalse($this->state->isValidPassword('1234567')); // 7 chars
        $this->assertFalse($this->state->isValidPassword('        ')); // 8 spaces, no non-whitespace
    }

    public function testIsValidSecretKeyWithValidKeys(): void
    {
        $this->assertTrue($this->state->isValidSecretKey('a'));
        $this->assertTrue($this->state->isValidSecretKey('my-secret-key'));
        $this->assertTrue($this->state->isValidSecretKey(str_repeat('x', 64)));
    }

    public function testIsValidSecretKeyWithInvalidKeys(): void
    {
        $this->assertFalse($this->state->isValidSecretKey(''));
        $this->assertFalse($this->state->isValidSecretKey(str_repeat('x', 65)));
    }

    public function testIsValidAccountNameWithValidNames(): void
    {
        $this->assertTrue($this->state->isValidAccountName('John'));
        $this->assertTrue($this->state->isValidAccountName('a'));
    }

    public function testIsValidAccountNameWithInvalidNames(): void
    {
        $this->assertFalse($this->state->isValidAccountName(''));
        $this->assertFalse($this->state->isValidAccountName('   '));
    }

    public function testIsValidAppDomainInputWithValidDomains(): void
    {
        $this->assertTrue($this->state->isValidAppDomainInput('localhost'));
        $this->assertTrue($this->state->isValidAppDomainInput('example.com'));
        $this->assertTrue($this->state->isValidAppDomainInput('sub.example.com'));
        $this->assertTrue($this->state->isValidAppDomainInput('127.0.0.1'));
        $this->assertTrue($this->state->isValidAppDomainInput('192.168.1.1'));
    }

    public function testIsValidAppDomainInputWithPort(): void
    {
        $this->assertTrue($this->state->isValidAppDomainInput('localhost:8080'));
        $this->assertTrue($this->state->isValidAppDomainInput('example.com:443'));
        $this->assertTrue($this->state->isValidAppDomainInput('127.0.0.1:3000'));
    }

    public function testIsValidAppDomainInputWithIpv6(): void
    {
        $this->assertTrue($this->state->isValidAppDomainInput('[::1]'));
        $this->assertTrue($this->state->isValidAppDomainInput('[::1]:8080'));
    }

    public function testIsValidAppDomainInputWithInvalidDomains(): void
    {
        $this->assertFalse($this->state->isValidAppDomainInput(''));
        $this->assertFalse($this->state->isValidAppDomainInput('   '));
        $this->assertFalse($this->state->isValidAppDomainInput('localhost:99999'));
        $this->assertFalse($this->state->isValidAppDomainInput('localhost:0'));
        $this->assertFalse($this->state->isValidAppDomainInput('host:port:extra'));
    }

    public function testIsValidDatabaseAdapterWithValidAdapters(): void
    {
        $this->assertTrue($this->state->isValidDatabaseAdapter('mongodb'));
        $this->assertTrue($this->state->isValidDatabaseAdapter('mariadb'));
        $this->assertTrue($this->state->isValidDatabaseAdapter('postgresql'));
    }

    public function testIsValidDatabaseAdapterWithInvalidAdapters(): void
    {
        $this->assertFalse($this->state->isValidDatabaseAdapter(''));
        $this->assertFalse($this->state->isValidDatabaseAdapter('mysql'));
        $this->assertFalse($this->state->isValidDatabaseAdapter('postgres'));
        $this->assertFalse($this->state->isValidDatabaseAdapter('PostgreSQL'));
        $this->assertFalse($this->state->isValidDatabaseAdapter('MongoDB')); // case sensitive
    }

    public function testProgressFilePathFormat(): void
    {
        $path = $this->state->progressFilePath('test123');
        $this->assertStringContainsString('appwrite-install-test123.json', $path);
        $this->assertStringStartsWith(sys_get_temp_dir(), $path);
    }

    public function testReadProgressFileReturnsDefaultForMissing(): void
    {
        $data = $this->state->readProgressFile('nonexistent-id-' . uniqid());
        $this->assertArrayHasKey('installId', $data);
        $this->assertArrayHasKey('steps', $data);
        $this->assertEmpty($data['steps']);
    }

    public function testWriteAndReadProgressFile(): void
    {
        $installId = 'test-' . uniqid();

        $this->state->writeProgressFile($installId, [
            'step' => Server::STEP_ENV_VARS,
            'status' => Server::STATUS_IN_PROGRESS,
            'message' => 'Writing environment variables',
            'updatedAt' => time(),
        ]);

        $data = $this->state->readProgressFile($installId);
        $this->assertArrayHasKey('steps', $data);
        $this->assertArrayHasKey(Server::STEP_ENV_VARS, $data['steps']);
        $this->assertEquals(Server::STATUS_IN_PROGRESS, $data['steps'][Server::STEP_ENV_VARS]['status']);
        $this->assertEquals('Writing environment variables', $data['steps'][Server::STEP_ENV_VARS]['message']);

        // Cleanup
        @unlink($this->state->progressFilePath($installId));
    }

    public function testWriteProgressFileAccumulatesSteps(): void
    {
        $installId = 'test-multi-' . uniqid();

        $this->state->writeProgressFile($installId, [
            'step' => Server::STEP_ENV_VARS,
            'status' => Server::STATUS_COMPLETED,
            'message' => 'Done',
            'updatedAt' => time(),
        ]);

        $this->state->writeProgressFile($installId, [
            'step' => Server::STEP_DOCKER_COMPOSE,
            'status' => Server::STATUS_IN_PROGRESS,
            'message' => 'Generating compose file',
            'updatedAt' => time(),
        ]);

        $data = $this->state->readProgressFile($installId);
        $this->assertCount(2, $data['steps']);
        $this->assertArrayHasKey(Server::STEP_ENV_VARS, $data['steps']);
        $this->assertArrayHasKey(Server::STEP_DOCKER_COMPOSE, $data['steps']);

        // Cleanup
        @unlink($this->state->progressFilePath($installId));
    }

    public function testWriteProgressFileStoresPayload(): void
    {
        $installId = 'test-payload-' . uniqid();

        $this->state->writeProgressFile($installId, [
            'payload' => [
                'httpPort' => '80',
                'httpsPort' => '443',
                'database' => 'mariadb',
            ],
            'step' => 'start',
            'status' => Server::STATUS_IN_PROGRESS,
            'message' => 'Started',
            'updatedAt' => time(),
        ]);

        $data = $this->state->readProgressFile($installId);
        $this->assertArrayHasKey('payload', $data);
        $this->assertEquals('80', $data['payload']['httpPort']);
        $this->assertEquals('443', $data['payload']['httpsPort']);
        $this->assertEquals('mariadb', $data['payload']['database']);
        $this->assertArrayHasKey('startedAt', $data);

        // Cleanup
        @unlink($this->state->progressFilePath($installId));
    }

    public function testWriteProgressFileStoresErrorMessage(): void
    {
        $installId = 'test-error-' . uniqid();

        $this->state->writeProgressFile($installId, [
            'step' => Server::STEP_DOCKER_CONTAINERS,
            'status' => Server::STATUS_ERROR,
            'message' => 'Container failed to start',
            'updatedAt' => time(),
        ]);

        $data = $this->state->readProgressFile($installId);
        $this->assertArrayHasKey('error', $data);
        $this->assertEquals('Container failed to start', $data['error']);

        // Cleanup
        @unlink($this->state->progressFilePath($installId));
    }

    public function testWriteProgressFileStoresDetails(): void
    {
        $installId = 'test-details-' . uniqid();

        $this->state->writeProgressFile($installId, [
            'step' => Server::STEP_DOCKER_COMPOSE,
            'status' => Server::STATUS_COMPLETED,
            'message' => 'Done',
            'details' => ['composeFile' => '/path/to/docker-compose.yml'],
            'updatedAt' => time(),
        ]);

        $data = $this->state->readProgressFile($installId);
        $this->assertArrayHasKey('details', $data);
        $this->assertArrayHasKey(Server::STEP_DOCKER_COMPOSE, $data['details']);
        $this->assertEquals('/path/to/docker-compose.yml', $data['details'][Server::STEP_DOCKER_COMPOSE]['composeFile']);

        // Cleanup
        @unlink($this->state->progressFilePath($installId));
    }

    public function testBuildConfigReturnsConfigInstance(): void
    {
        // Clear env to avoid interference
        putenv('APPWRITE_INSTALLER_CONFIG');

        $config = $this->state->buildConfig([], false);
        $this->assertInstanceOf(Config::class, $config);
    }

    public function testBuildConfigAppliesOverrides(): void
    {
        putenv('APPWRITE_INSTALLER_CONFIG');

        $config = $this->state->buildConfig(['defaultHttpPort' => '9090'], false);
        $this->assertEquals('9090', $config->getDefaultHttpPort());
    }

    public function testBuildConfigFromEnvVar(): void
    {
        $envData = json_encode([
            'defaultHttpPort' => '8888',
            'isUpgrade' => true,
        ]);
        putenv('APPWRITE_INSTALLER_CONFIG=' . $envData);

        $config = $this->state->buildConfig([], true);
        $this->assertEquals('8888', $config->getDefaultHttpPort());
        $this->assertTrue($config->isUpgrade());

        // Cleanup
        putenv('APPWRITE_INSTALLER_CONFIG');
    }

    public function testBuildConfigOverridesEnv(): void
    {
        $envData = json_encode(['defaultHttpPort' => '8888']);
        putenv('APPWRITE_INSTALLER_CONFIG=' . $envData);

        $config = $this->state->buildConfig(['defaultHttpPort' => '7777'], true);
        $this->assertEquals('7777', $config->getDefaultHttpPort());

        // Cleanup
        putenv('APPWRITE_INSTALLER_CONFIG');
    }

    public function testSanitizeInstallIdWithOnlySpecialChars(): void
    {
        $this->assertEquals('', $this->state->sanitizeInstallId('!@#$%^&*()'));
    }

    public function testSanitizeInstallIdWithUnicode(): void
    {
        // Unicode letters are stripped byte-by-byte, only ASCII alphanum + hyphen + underscore kept
        // 'é' is 2 bytes (0xC3 0xA9), both stripped => 'héllo' becomes 'hllo'
        $this->assertEquals('hllo', $this->state->sanitizeInstallId('héllo'));
    }

    public function testSanitizeInstallIdWithExactly64Chars(): void
    {
        $exact = str_repeat('b', 64);
        $this->assertEquals($exact, $this->state->sanitizeInstallId($exact));
        $this->assertEquals(64, strlen($this->state->sanitizeInstallId($exact)));
    }

    public function testSanitizeInstallIdWithBooleanInput(): void
    {
        $this->assertEquals('', $this->state->sanitizeInstallId(true));
        $this->assertEquals('', $this->state->sanitizeInstallId(false));
    }

    public function testSanitizeInstallIdWithArrayInput(): void
    {
        $this->assertEquals('', $this->state->sanitizeInstallId([]));
    }

    public function testSanitizeInstallIdPreservesCase(): void
    {
        $this->assertEquals('AbCdEf', $this->state->sanitizeInstallId('AbCdEf'));
    }

    public function testIsValidPortBoundaryValues(): void
    {
        $this->assertTrue($this->state->isValidPort('1'));
        $this->assertTrue($this->state->isValidPort('65535'));
        $this->assertFalse($this->state->isValidPort('0'));
        $this->assertFalse($this->state->isValidPort('65536'));
    }

    public function testIsValidPortWithLeadingZeros(): void
    {
        // '080' is digits-only and parses to 80 which is in range
        $this->assertTrue($this->state->isValidPort('080'));
        // '00' parses to 0, which is out of range
        $this->assertFalse($this->state->isValidPort('00'));
    }

    public function testIsValidPortWithWhitespace(): void
    {
        // Contains non-digit characters
        $this->assertFalse($this->state->isValidPort(' 80'));
        $this->assertFalse($this->state->isValidPort('80 '));
        $this->assertFalse($this->state->isValidPort(' 80 '));
    }

    public function testIsValidPortWithNegativeNumber(): void
    {
        $this->assertFalse($this->state->isValidPort('-80'));
        $this->assertFalse($this->state->isValidPort('-1'));
    }

    public function testIsValidPortWithVeryLargeNumber(): void
    {
        $this->assertFalse($this->state->isValidPort('999999'));
        $this->assertFalse($this->state->isValidPort('100000'));
    }

    public function testIsValidPasswordExactly8Chars(): void
    {
        $this->assertTrue($this->state->isValidPassword('12345678'));
        $this->assertFalse($this->state->isValidPassword('1234567'));
    }

    public function testIsValidPasswordWithTabsAndNewlines(): void
    {
        // Tabs/newlines count as whitespace, but need at least one non-whitespace
        $this->assertFalse($this->state->isValidPassword("\t\t\t\t\t\t\t\t")); // 8 tabs
        $this->assertTrue($this->state->isValidPassword("\t\t\t\ttest")); // mixed
    }

    public function testIsValidPasswordWithMixedWhitespaceAndChars(): void
    {
        $this->assertTrue($this->state->isValidPassword('   a    ')); // has non-whitespace
    }

    public function testIsValidSecretKeyExactly64Chars(): void
    {
        $this->assertTrue($this->state->isValidSecretKey(str_repeat('a', 64)));
    }

    public function testIsValidSecretKeyWithWhitespace(): void
    {
        // Whitespace-only is still non-empty and <= 64 chars
        $this->assertTrue($this->state->isValidSecretKey(' '));
        $this->assertTrue($this->state->isValidSecretKey('   '));
    }

    public function testIsValidAppDomainInputWithEmptyPort(): void
    {
        // "host:" splits to ['host', ''] - empty port with null check
        $this->assertTrue($this->state->isValidAppDomainInput('localhost:'));
    }

    public function testIsValidAppDomainInputWithIpv4Address(): void
    {
        $this->assertTrue($this->state->isValidAppDomainInput('10.0.0.1'));
        $this->assertTrue($this->state->isValidAppDomainInput('255.255.255.255'));
        $this->assertTrue($this->state->isValidAppDomainInput('0.0.0.0'));
    }

    public function testIsValidAppDomainInputIpv6WithoutBrackets(): void
    {
        // Raw IPv6 without brackets: "::1" has two colons, so count($parts) > 2 => false
        $this->assertFalse($this->state->isValidAppDomainInput('::1'));
        $this->assertFalse($this->state->isValidAppDomainInput('fe80::1'));
    }

    public function testIsValidAppDomainInputIpv6MalformedBrackets(): void
    {
        $this->assertFalse($this->state->isValidAppDomainInput('['));
        $this->assertFalse($this->state->isValidAppDomainInput('[]'));
        $this->assertFalse($this->state->isValidAppDomainInput('[invalid'));
    }

    public function testIsValidAppDomainInputWithSubdomains(): void
    {
        $this->assertTrue($this->state->isValidAppDomainInput('a.b.c.d.example.com'));
        $this->assertTrue($this->state->isValidAppDomainInput('my-app.example.io:8080'));
    }

    public function testIsValidAppDomainInputWithInvalidPortNumber(): void
    {
        $this->assertFalse($this->state->isValidAppDomainInput('localhost:abc'));
        $this->assertFalse($this->state->isValidAppDomainInput('localhost:70000'));
        $this->assertFalse($this->state->isValidAppDomainInput('[::1]:70000'));
    }

    public function testIsValidDatabaseAdapterWithWhitespace(): void
    {
        $this->assertFalse($this->state->isValidDatabaseAdapter(' mongodb'));
        $this->assertFalse($this->state->isValidDatabaseAdapter('mariadb '));
        $this->assertFalse($this->state->isValidDatabaseAdapter(' postgresql'));
    }

    public function testIsValidDatabaseAdapterCaseSensitivity(): void
    {
        $this->assertFalse($this->state->isValidDatabaseAdapter('MongoDB'));
        $this->assertFalse($this->state->isValidDatabaseAdapter('MariaDB'));
        $this->assertFalse($this->state->isValidDatabaseAdapter('PostgreSQL'));
        $this->assertFalse($this->state->isValidDatabaseAdapter('MONGODB'));
    }

    public function testReadProgressFileWithCorruptedJson(): void
    {
        $installId = 'test-corrupt-' . uniqid();
        $this->trackProgressFile($installId);
        $path = $this->state->progressFilePath($installId);
        file_put_contents($path, 'not valid json {{{');

        $data = $this->state->readProgressFile($installId);
        $this->assertArrayHasKey('installId', $data);
        $this->assertArrayHasKey('steps', $data);
        $this->assertEmpty($data['steps']);
    }

    public function testReadProgressFileWithEmptyFile(): void
    {
        $installId = 'test-empty-' . uniqid();
        $this->trackProgressFile($installId);
        $path = $this->state->progressFilePath($installId);
        file_put_contents($path, '');

        $data = $this->state->readProgressFile($installId);
        $this->assertArrayHasKey('installId', $data);
        $this->assertEmpty($data['steps']);
    }

    public function testReadProgressFileWithJsonScalar(): void
    {
        $installId = 'test-scalar-' . uniqid();
        $this->trackProgressFile($installId);
        $path = $this->state->progressFilePath($installId);
        file_put_contents($path, '"just a string"');

        $data = $this->state->readProgressFile($installId);
        $this->assertEmpty($data['steps']);
    }

    public function testWriteProgressFileOverwritesExistingStep(): void
    {
        $installId = 'test-overwrite-' . uniqid();
        $this->trackProgressFile($installId);

        $this->state->writeProgressFile($installId, [
            'step' => Server::STEP_ENV_VARS,
            'status' => Server::STATUS_IN_PROGRESS,
            'message' => 'Working...',
            'updatedAt' => time(),
        ]);

        $this->state->writeProgressFile($installId, [
            'step' => Server::STEP_ENV_VARS,
            'status' => Server::STATUS_COMPLETED,
            'message' => 'Done!',
            'updatedAt' => time(),
        ]);

        $data = $this->state->readProgressFile($installId);
        $this->assertCount(1, $data['steps']); // Still 1 step, overwritten
        $this->assertEquals(Server::STATUS_COMPLETED, $data['steps'][Server::STEP_ENV_VARS]['status']);
        $this->assertEquals('Done!', $data['steps'][Server::STEP_ENV_VARS]['message']);
    }

    public function testWriteProgressFileWithEmptyStep(): void
    {
        $installId = 'test-emptystep-' . uniqid();
        $this->trackProgressFile($installId);

        $this->state->writeProgressFile($installId, [
            'step' => '',
            'status' => Server::STATUS_IN_PROGRESS,
            'message' => 'No step name',
            'updatedAt' => time(),
        ]);

        $data = $this->state->readProgressFile($installId);
        // Empty step name treated as falsy, should not add to steps
        $this->assertEmpty($data['steps']);
    }

    public function testWriteProgressFilePreservesPayloadAcrossWrites(): void
    {
        $installId = 'test-persist-' . uniqid();
        $this->trackProgressFile($installId);

        $this->state->writeProgressFile($installId, [
            'payload' => ['httpPort' => '80', 'database' => 'mongodb'],
            'step' => 'start',
            'status' => Server::STATUS_IN_PROGRESS,
            'message' => 'Starting',
            'updatedAt' => time(),
        ]);

        $this->state->writeProgressFile($installId, [
            'step' => Server::STEP_ENV_VARS,
            'status' => Server::STATUS_COMPLETED,
            'message' => 'Env done',
            'updatedAt' => time(),
        ]);

        $data = $this->state->readProgressFile($installId);
        // Payload from first write should still be present
        $this->assertArrayHasKey('payload', $data);
        $this->assertEquals('80', $data['payload']['httpPort']);
        $this->assertEquals('mongodb', $data['payload']['database']);
        // Both steps should exist
        $this->assertArrayHasKey('start', $data['steps']);
        $this->assertArrayHasKey(Server::STEP_ENV_VARS, $data['steps']);
    }

    public function testWriteProgressFileUpdatesTimestamp(): void
    {
        $installId = 'test-time-' . uniqid();
        $this->trackProgressFile($installId);
        $now = time();

        $this->state->writeProgressFile($installId, [
            'step' => Server::STEP_ENV_VARS,
            'status' => Server::STATUS_IN_PROGRESS,
            'message' => 'test',
            'updatedAt' => $now,
        ]);

        $data = $this->state->readProgressFile($installId);
        $this->assertEquals($now, $data['updatedAt']);
    }

    public function testWriteProgressFileStartedAtOnlySetOnce(): void
    {
        $installId = 'test-startedat-' . uniqid();
        $this->trackProgressFile($installId);
        $firstTime = time() - 100;

        // First write with payload sets startedAt
        $this->state->writeProgressFile($installId, [
            'payload' => ['httpPort' => '80'],
            'step' => 'start',
            'status' => Server::STATUS_IN_PROGRESS,
            'message' => 'Starting',
            'updatedAt' => $firstTime,
        ]);

        $data = $this->state->readProgressFile($installId);
        $startedAt = $data['startedAt'];

        // Second write with payload should NOT overwrite startedAt
        $this->state->writeProgressFile($installId, [
            'payload' => ['httpPort' => '80'],
            'step' => Server::STEP_ENV_VARS,
            'status' => Server::STATUS_IN_PROGRESS,
            'message' => 'Env',
            'updatedAt' => time(),
        ]);

        $data = $this->state->readProgressFile($installId);
        $this->assertEquals($startedAt, $data['startedAt']);
    }

    public function testReserveGlobalLockFirstLockSucceeds(): void
    {
        @unlink(Server::INSTALLER_LOCK_FILE);
        $installId = 'lock-test-' . uniqid();
        $result = $this->state->reserveGlobalLock($installId);
        $this->assertEquals('ok', $result);
    }

    public function testReserveGlobalLockSameIdCanRelock(): void
    {
        @unlink(Server::INSTALLER_LOCK_FILE);
        $installId = 'lock-relock-' . uniqid();

        $result1 = $this->state->reserveGlobalLock($installId);
        $this->assertEquals('ok', $result1);

        // Same ID can re-reserve
        $result2 = $this->state->reserveGlobalLock($installId);
        $this->assertEquals('ok', $result2);
    }

    public function testReserveGlobalLockDifferentIdBlocked(): void
    {
        @unlink(Server::INSTALLER_LOCK_FILE);
        $installId1 = 'lock-id1-' . uniqid();
        $installId2 = 'lock-id2-' . uniqid();

        $result1 = $this->state->reserveGlobalLock($installId1);
        $this->assertEquals('ok', $result1);

        // Different ID should be blocked
        $result2 = $this->state->reserveGlobalLock($installId2);
        $this->assertEquals('locked', $result2);
    }

    public function testReserveGlobalLockAfterCompleted(): void
    {
        @unlink(Server::INSTALLER_LOCK_FILE);
        $installId1 = 'lock-done-' . uniqid();
        $installId2 = 'lock-new-' . uniqid();

        $this->state->reserveGlobalLock($installId1);
        $this->state->updateGlobalLock($installId1, Server::STATUS_COMPLETED);

        // After completion, a new install should be able to lock
        $result = $this->state->reserveGlobalLock($installId2);
        $this->assertEquals('ok', $result);
    }

    public function testReserveGlobalLockAfterError(): void
    {
        @unlink(Server::INSTALLER_LOCK_FILE);
        $installId1 = 'lock-err-' . uniqid();
        $installId2 = 'lock-retry-' . uniqid();

        $this->state->reserveGlobalLock($installId1);
        $this->state->updateGlobalLock($installId1, Server::STATUS_ERROR);

        // After error, a new install should be able to lock
        $result = $this->state->reserveGlobalLock($installId2);
        $this->assertEquals('ok', $result);
    }

    public function testReserveGlobalLockExpiredLockAllowsNew(): void
    {
        @unlink(Server::INSTALLER_LOCK_FILE);

        // Manually write an expired lock (updatedAt way in the past)
        $expiredLock = [
            'installId' => 'expired-lock',
            'status' => Server::STATUS_IN_PROGRESS,
            'updatedAt' => time() - 7200, // 2 hours ago, timeout is 1 hour
        ];
        file_put_contents(Server::INSTALLER_LOCK_FILE, json_encode($expiredLock));

        $newId = 'lock-after-expired-' . uniqid();
        $result = $this->state->reserveGlobalLock($newId);
        $this->assertEquals('ok', $result);
    }

    public function testUpdateGlobalLockUpdatesOwnLock(): void
    {
        @unlink(Server::INSTALLER_LOCK_FILE);
        $installId = 'lock-update-' . uniqid();

        $this->state->reserveGlobalLock($installId);
        $this->state->updateGlobalLock($installId, Server::STATUS_COMPLETED);

        // Read lock file directly to verify
        $contents = file_get_contents(Server::INSTALLER_LOCK_FILE);
        $this->assertNotFalse($contents);
        $lock = json_decode($contents, true);
        $this->assertIsArray($lock);
        $this->assertEquals($installId, $lock['installId']);
        $this->assertEquals(Server::STATUS_COMPLETED, $lock['status']);
    }

    public function testUpdateGlobalLockIgnoresDifferentId(): void
    {
        @unlink(Server::INSTALLER_LOCK_FILE);
        $installId1 = 'lock-owner-' . uniqid();
        $installId2 = 'lock-intruder-' . uniqid();

        $this->state->reserveGlobalLock($installId1);

        // Attempt to update with a different ID should be silently ignored
        $this->state->updateGlobalLock($installId2, Server::STATUS_COMPLETED);

        // Original lock should still be in progress
        $contents = file_get_contents(Server::INSTALLER_LOCK_FILE);
        $lock = json_decode($contents, true);
        $this->assertEquals($installId1, $lock['installId']);
        $this->assertEquals(Server::STATUS_IN_PROGRESS, $lock['status']);
    }

    public function testApplyEnvConfigWithConfigObject(): void
    {
        putenv('APPWRITE_INSTALLER_CONFIG');
        @unlink(Server::INSTALLER_CONFIG_FILE);

        $cfg = new Config(['defaultHttpPort' => '5555', 'isLocal' => true]);
        $this->state->applyEnvConfig($cfg);

        // Verify env var was set
        $envVal = getenv('APPWRITE_INSTALLER_CONFIG');
        $this->assertNotFalse($envVal);

        $decoded = json_decode($envVal, true);
        $this->assertIsArray($decoded);
        $this->assertEquals('5555', $decoded['defaultHttpPort']);
        $this->assertTrue($decoded['isLocal']);

        // Verify config file was written
        $this->assertFileExists(Server::INSTALLER_CONFIG_FILE);
        $fileContents = file_get_contents(Server::INSTALLER_CONFIG_FILE);
        $this->assertNotFalse($fileContents);
        $fileDecoded = json_decode($fileContents, true);
        $this->assertEquals('5555', $fileDecoded['defaultHttpPort']);
    }

    public function testApplyEnvConfigWithArray(): void
    {
        putenv('APPWRITE_INSTALLER_CONFIG');
        @unlink(Server::INSTALLER_CONFIG_FILE);

        $this->state->applyEnvConfig(['defaultHttpPort' => '6666']);

        $envVal = getenv('APPWRITE_INSTALLER_CONFIG');
        $this->assertNotFalse($envVal);
        $decoded = json_decode($envVal, true);
        $this->assertEquals('6666', $decoded['defaultHttpPort']);
    }

    public function testApplyEnvConfigThenBuildConfigReadsIt(): void
    {
        putenv('APPWRITE_INSTALLER_CONFIG');
        @unlink(Server::INSTALLER_CONFIG_FILE);

        $cfg = new Config(['defaultHttpPort' => '4444', 'isUpgrade' => true]);
        $this->state->applyEnvConfig($cfg);

        // buildConfig with useEnv=true should pick up the env var
        $rebuilt = $this->state->buildConfig([], true);
        $this->assertEquals('4444', $rebuilt->getDefaultHttpPort());
        $this->assertTrue($rebuilt->isUpgrade());
    }

    public function testBuildConfigWithInvalidEnvJson(): void
    {
        putenv('APPWRITE_INSTALLER_CONFIG=not-valid-json');

        // Should fall back to config file (or defaults if file doesn't exist)
        @unlink(Server::INSTALLER_CONFIG_FILE);
        $config = $this->state->buildConfig([], true);
        // Should get defaults since both env and file are invalid/missing
        $this->assertEquals('80', $config->getDefaultHttpPort());
    }

    public function testBuildConfigWithEmptyEnvVar(): void
    {
        putenv('APPWRITE_INSTALLER_CONFIG=');

        @unlink(Server::INSTALLER_CONFIG_FILE);
        $config = $this->state->buildConfig([], true);
        $this->assertEquals('80', $config->getDefaultHttpPort());
    }

    public function testBuildConfigFallsBackToConfigFile(): void
    {
        putenv('APPWRITE_INSTALLER_CONFIG');

        // Write a config file
        $data = json_encode(['defaultHttpPort' => '3333']);
        file_put_contents(Server::INSTALLER_CONFIG_FILE, $data);

        $config = $this->state->buildConfig([], true);
        $this->assertEquals('3333', $config->getDefaultHttpPort());
    }

    public function testBuildConfigWithCorruptedConfigFile(): void
    {
        putenv('APPWRITE_INSTALLER_CONFIG');

        file_put_contents(Server::INSTALLER_CONFIG_FILE, 'garbage data {{{');

        $config = $this->state->buildConfig([], true);
        // Should get defaults
        $this->assertEquals('80', $config->getDefaultHttpPort());
    }

    public function testBuildConfigWithEmptyConfigFile(): void
    {
        putenv('APPWRITE_INSTALLER_CONFIG');

        file_put_contents(Server::INSTALLER_CONFIG_FILE, '');

        $config = $this->state->buildConfig([], true);
        $this->assertEquals('80', $config->getDefaultHttpPort());
    }

    public function testBuildConfigUseEnvFalseIgnoresEnvAndFile(): void
    {
        putenv('APPWRITE_INSTALLER_CONFIG=' . json_encode(['defaultHttpPort' => '9999']));
        file_put_contents(Server::INSTALLER_CONFIG_FILE, json_encode(['defaultHttpPort' => '8888']));

        $config = $this->state->buildConfig([], false);
        // Neither env nor file should be used
        $this->assertEquals('80', $config->getDefaultHttpPort());
    }

    public function testBuildConfigWithJsonScalarEnvVar(): void
    {
        // A JSON scalar (string) is not an array, so decoding succeeds but is_array fails
        putenv('APPWRITE_INSTALLER_CONFIG="just a string"');
        @unlink(Server::INSTALLER_CONFIG_FILE);

        $config = $this->state->buildConfig([], true);
        $this->assertEquals('80', $config->getDefaultHttpPort());
    }

    public function testHashSensitiveValueWithNewlines(): void
    {
        // Newlines are not stripped by trim but surrounding whitespace is
        $hash1 = $this->state->hashSensitiveValue("line1\nline2");
        $hash2 = $this->state->hashSensitiveValue("line1\nline2");
        $this->assertEquals($hash1, $hash2);
        $this->assertNotEmpty($hash1);
    }

    public function testHashSensitiveValueWithOnlyNewline(): void
    {
        // A newline is not whitespace that trim() removes? Actually trim() removes \n
        // "\n" trimmed becomes "" => should return ''
        $this->assertEquals('', $this->state->hashSensitiveValue("\n"));
    }

    public function testIsValidEmailAddressWithUnicodeLocal(): void
    {
        // PHP's FILTER_VALIDATE_EMAIL does not support internationalized emails
        $this->assertFalse($this->state->isValidEmailAddress('ünïcödé@example.com'));
    }

    public function testIsValidEmailAddressWithDoubleAt(): void
    {
        $this->assertFalse($this->state->isValidEmailAddress('user@@example.com'));
    }

    public function testIsValidEmailAddressWithSpaces(): void
    {
        $this->assertFalse($this->state->isValidEmailAddress('user @example.com'));
        $this->assertFalse($this->state->isValidEmailAddress('user@ example.com'));
    }

    public function testIsValidAccountNameWithOnlyTabs(): void
    {
        $this->assertFalse($this->state->isValidAccountName("\t\t"));
    }

    public function testIsValidAccountNameWithMixedWhitespace(): void
    {
        $this->assertTrue($this->state->isValidAccountName(" a "));
    }

    public function testProgressFilePathWithSpecialCharsInId(): void
    {
        // The ID would normally be sanitized before this call, but the method itself
        // just concatenates
        $path = $this->state->progressFilePath('test-with-special');
        $this->assertStringContainsString('appwrite-install-test-with-special.json', $path);
    }

    public function testProgressFilePathWithEmptyId(): void
    {
        $path = $this->state->progressFilePath('');
        $this->assertStringContainsString('appwrite-install-.json', $path);
    }

    public function testWriteProgressFileCompletedDoesNotSetError(): void
    {
        $installId = 'test-noerror-' . uniqid();
        $this->trackProgressFile($installId);

        $this->state->writeProgressFile($installId, [
            'step' => Server::STEP_ENV_VARS,
            'status' => Server::STATUS_COMPLETED,
            'message' => 'All good',
            'updatedAt' => time(),
        ]);

        $data = $this->state->readProgressFile($installId);
        $this->assertArrayNotHasKey('error', $data);
    }

    public function testWriteProgressFileInProgressDoesNotSetError(): void
    {
        $installId = 'test-noerrip-' . uniqid();
        $this->trackProgressFile($installId);

        $this->state->writeProgressFile($installId, [
            'step' => Server::STEP_DOCKER_COMPOSE,
            'status' => Server::STATUS_IN_PROGRESS,
            'message' => 'Working',
            'updatedAt' => time(),
        ]);

        $data = $this->state->readProgressFile($installId);
        $this->assertArrayNotHasKey('error', $data);
    }

    public function testWriteProgressFileWithNoStep(): void
    {
        $installId = 'test-nostep-' . uniqid();
        $this->trackProgressFile($installId);

        $this->state->writeProgressFile($installId, [
            'status' => Server::STATUS_IN_PROGRESS,
            'message' => 'No step provided',
            'updatedAt' => time(),
        ]);

        $data = $this->state->readProgressFile($installId);
        // No step key means no step should be recorded
        $this->assertEmpty($data['steps']);
        // But updatedAt should still be set
        $this->assertArrayHasKey('updatedAt', $data);
    }

    public function testFullInstallationLifecycle(): void
    {
        @unlink(Server::INSTALLER_LOCK_FILE);
        $installId = 'lifecycle-' . uniqid();
        $this->trackProgressFile($installId);

        // 1. Reserve lock
        $lockResult = $this->state->reserveGlobalLock($installId);
        $this->assertEquals('ok', $lockResult);

        // 2. Write progress through multiple steps
        $this->state->writeProgressFile($installId, [
            'payload' => ['httpPort' => '80', 'database' => 'mongodb'],
            'step' => 'start',
            'status' => Server::STATUS_IN_PROGRESS,
            'message' => 'Started',
            'updatedAt' => time(),
        ]);

        $this->state->writeProgressFile($installId, [
            'step' => Server::STEP_ENV_VARS,
            'status' => Server::STATUS_COMPLETED,
            'message' => 'Env vars written',
            'updatedAt' => time(),
        ]);

        $this->state->writeProgressFile($installId, [
            'step' => Server::STEP_DOCKER_COMPOSE,
            'status' => Server::STATUS_COMPLETED,
            'message' => 'Compose generated',
            'updatedAt' => time(),
        ]);

        $this->state->writeProgressFile($installId, [
            'step' => Server::STEP_DOCKER_CONTAINERS,
            'status' => Server::STATUS_COMPLETED,
            'message' => 'Containers started',
            'updatedAt' => time(),
        ]);

        // 3. Verify progress
        $data = $this->state->readProgressFile($installId);
        $this->assertCount(4, $data['steps']); // start + 3 steps
        $this->assertArrayHasKey('payload', $data);
        $this->assertArrayHasKey('startedAt', $data);

        // 4. Complete the lock
        $this->state->updateGlobalLock($installId, Server::STATUS_COMPLETED);

        // 5. Verify a new install can now proceed
        $newId = 'lifecycle-new-' . uniqid();
        $this->trackProgressFile($newId);
        $newResult = $this->state->reserveGlobalLock($newId);
        $this->assertEquals('ok', $newResult);
    }
}
