<?php

app_require('classes/release_manager.php');
app_require('classes/release_manager_schema_bootstrap.php');
app_require('classes/coolify_api_client.php');

use classes\coolify_api_client;
use classes\release_manager;

class ReleaseManagerCoolifyEnvFake extends coolify_api_client
{
    public array $envRows;
    public array $deleted = [];

    public function __construct(array $envRows)
    {
        $this->envRows = $envRows;
    }

    public function listApplicationEnvs(string $uuid): array
    {
        return $this->envRows;
    }

    public function deleteApplicationEnv(string $uuid, string $envUuid): array
    {
        $this->deleted[] = [$uuid, $envUuid];
        return ['message' => 'deleted'];
    }
}

it('redacts sensitive release timeline payload fields recursively', function (): void {
    $payload = [
        'Authorization' => 'Bearer secret-token',
        'nested' => [
            'api_key' => 'key-value',
            'safe' => 'visible',
            'items' => [
                ['password' => 'hidden', 'status' => 500],
            ],
        ],
    ];

    expect(release_manager::redactPayload($payload))->toBe([
        'Authorization' => '[redacted]',
        'nested' => [
            'api_key' => '[redacted]',
            'safe' => 'visible',
            'items' => [
                ['password' => '[redacted]', 'status' => 500],
            ],
        ],
    ]);
});

it('verifies GitHub sha256 webhook signatures', function (): void {
    $secret = 'release-webhook-secret';
    $payload = '{"ref":"refs/heads/main"}';
    $signature = 'sha256=' . hash_hmac('sha256', $payload, $secret);

    expect(release_manager::verifyGithubSignature($secret, $payload, $signature))->toBeTrue();
    expect(release_manager::verifyGithubSignature($secret, $payload, 'sha256=bad'))->toBeFalse();
    expect(release_manager::verifyGithubSignature('', $payload, $signature))->toBeFalse();
});

it('verifies CI release gate bearer tokens from dedicated release credentials', function (): void {
    $previous = getenv('RELEASE_MANAGER_GATE_TOKEN');

    try {
        putenv('RELEASE_MANAGER_GATE_TOKEN=release-gate-secret');
        $_SERVER['RELEASE_MANAGER_GATE_TOKEN'] = 'release-gate-secret';

        $manager = new release_manager();

        expect($manager->verifyReleaseGateToken('release-gate-secret'))->toBeTrue();
        expect($manager->verifyReleaseGateToken('wrong-secret'))->toBeFalse();
        expect($manager->verifyReleaseGateToken(''))->toBeFalse();
    } finally {
        if ($previous === false) {
            putenv('RELEASE_MANAGER_GATE_TOKEN');
            unset($_SERVER['RELEASE_MANAGER_GATE_TOKEN']);
        } else {
            putenv('RELEASE_MANAGER_GATE_TOKEN=' . $previous);
            $_SERVER['RELEASE_MANAGER_GATE_TOKEN'] = $previous;
        }
    }
});

it('normalizes app-specific release gate auto-sync metadata', function (): void {
    $manager = new release_manager();
    $normalizeGate = new ReflectionMethod(release_manager::class, 'normalizeReleaseGateInput');
    $appMatches = new ReflectionMethod(release_manager::class, 'releaseGateAppMatches');

    $gate = $normalizeGate->invoke($manager, [
        'channel_slug' => 'stable',
        'app' => 'api',
        'repository' => 'https://github.com/copenhagentruckwash/api.git',
        'branch' => 'master',
        'expected_commit' => 'd52ceb85138740c45f20cda9b7ed9b7a21f0d4e8',
        'workflow_url' => 'https://github.com/copenhagentruckwash/api/actions/runs/123',
        'auto_sync' => true,
    ], ['slug' => 'stable']);

    expect($gate)->toMatchArray([
        'channel_slug' => 'stable',
        'route_slug' => 'master',
        'app' => 'api',
        'apps' => ['api'],
        'repository' => 'copenhagentruckwash/api',
        'branch' => 'master',
        'expected_commit' => 'd52ceb85138740c45f20cda9b7ed9b7a21f0d4e8',
        'workflow_url' => 'https://github.com/copenhagentruckwash/api/actions/runs/123',
        'auto_sync' => true,
    ]);

    $fullStackGate = $normalizeGate->invoke($manager, [
        'channel_slug' => 'stable',
    ], ['slug' => 'stable']);

    expect($fullStackGate['apps'])->toBe(['frontend', 'api']);
    expect($appMatches->invoke($manager, ['app' => 'api'], 'api'))->toBeTrue();
    expect($appMatches->invoke($manager, ['apps' => ['frontend', 'api']], 'api'))->toBeTrue();
    expect($appMatches->invoke($manager, [], 'frontend'))->toBeTrue();
    expect($appMatches->invoke($manager, [], 'api'))->toBeFalse();
});

it('requires non-empty release gate checks before auto-sync can proceed', function (): void {
    $manager = new release_manager();
    $method = new ReflectionMethod(release_manager::class, 'releaseGateAutoSyncValidationSteps');

    $failed = $method->invoke($manager, [
        'channel_slug' => 'stable',
        'app' => 'api',
        'repository' => 'copenhagentruckwash/api',
        'branch' => 'master',
        'expected_commit' => 'd52ceb85138740c45f20cda9b7ed9b7a21f0d4e8',
        'workflow_url' => 'https://github.com/copenhagentruckwash/api/actions/runs/123',
        'required_checks' => [],
    ], ['slug' => 'stable']);
    $failedByStep = array_column($failed, null, 'step_key');
    expect($failedByStep)->toHaveKey('auto_sync_required_checks')
        ->and($failedByStep['auto_sync_required_checks'])->toMatchArray([
            'step_key' => 'auto_sync_required_checks',
            'status' => 'failed',
        ]);

    $passed = $method->invoke($manager, [
        'channel_slug' => 'stable',
        'app' => 'api',
        'repository' => 'copenhagentruckwash/api',
        'branch' => 'master',
        'expected_commit' => 'd52ceb85138740c45f20cda9b7ed9b7a21f0d4e8',
        'workflow_url' => 'https://github.com/copenhagentruckwash/api/actions/runs/123',
        'required_checks' => ['api_gateway'],
    ], ['slug' => 'stable']);
    $passedByStep = array_column($passed, null, 'step_key');
    expect($passedByStep)->toHaveKey('auto_sync_inputs')
        ->and($passedByStep['auto_sync_inputs'])->toMatchArray([
            'step_key' => 'auto_sync_inputs',
            'status' => 'passed',
        ]);
});

it('extracts API ping commit metadata for release gate verification', function (): void {
    $manager = new release_manager();
    $method = new ReflectionMethod(release_manager::class, 'releaseGateApiPayloadCommitSha');

    expect($method->invoke($manager, [
        'success' => true,
        'data' => [
            'message' => 'pong',
            'api_commit_sha' => '327a77edf48069c14cb592f298924b0ea1aaf208',
        ],
    ]))->toBe('327a77edf48069c14cb592f298924b0ea1aaf208')
        ->and($method->invoke($manager, [
            'success' => true,
            'data' => [
                'backend_version' => '75c19bcce44fe3f0657d84b45dbc1c89f29332b4',
            ],
        ]))->toBe('75c19bcce44fe3f0657d84b45dbc1c89f29332b4');
});

it('normalizes GitHub repository identifiers for private repository access checks', function (): void {
    expect(release_manager::normalizeGithubRepositoryName('truckwash/backend-php'))->toBe('truckwash/backend-php');
    expect(release_manager::normalizeGithubRepositoryName('https://github.com/truckwash/front-end-vue.git'))->toBe('truckwash/front-end-vue');
    expect(release_manager::normalizeGithubRepositoryName('git@github.com:truckwash/backend-php.git'))->toBe('truckwash/backend-php');
    expect(release_manager::normalizeGithubRepositoryName('not a repository'))->toBe('');
});

it('keeps GitHub commit timestamps in public release manager commit payloads', function (): void {
    $manager = new release_manager();
    $method = new ReflectionMethod(release_manager::class, 'publicGithubCommit');

    $commit = $method->invoke($manager, [
        'sha' => 'feedface00000000000000000000000000000000',
        'html_url' => 'https://github.com/truckwash/backend-php/commit/feedface',
        'commit' => [
            'message' => "Deploy release bundle\n\nBody is intentionally omitted from option labels.",
            'author' => [
                'name' => 'Release Bot',
                'date' => '2026-05-19T08:10:00Z',
            ],
        ],
    ]);

    expect($commit)->toMatchArray([
        'sha' => 'feedface00000000000000000000000000000000',
        'short_sha' => 'feedface0000',
        'message' => 'Deploy release bundle',
        'author_name' => 'Release Bot',
        'authored_at' => '2026-05-19T08:10:00Z',
    ]);
});

it('summarizes failed deployments and blocks promotion until a deployment succeeds', function (): void {
    $summary = release_manager::deploymentFailureSummary(
        new RuntimeException('Coolify API request failed: HTTP 404'),
        [
            'app' => 'api',
            'repository' => 'copenhagentruckwash/api',
            'branch' => 'master',
            'commit_sha' => 'ab31cd6dbb288606a24fcdbcb7bb7d58d843e4f3',
            'coolify_service_uuid' => 'api-service',
        ]
    );

    expect($summary['category'])->toBe('coolify_target');
    expect($summary['stage'])->toBe('provider_target');
    expect($summary['promotion_blocked'])->toBeTrue();
    expect($summary['evidence']['commit_sha'])->toBe('ab31cd6dbb288606a24fcdbcb7bb7d58d843e4f3');
    expect(release_manager::deploymentCanBePromoted('deployed'))->toBeTrue();
    expect(release_manager::deploymentCanBePromoted('failed'))->toBeFalse();

    $reason = release_manager::deploymentPromotionBlockedReason([
        'status' => 'failed',
        'result_json' => json_encode(['failure_summary' => $summary]),
    ]);

    expect($reason)->toContain('Deployment failed');
    expect($reason)->toContain('HTTP 404');
});

it('uses the selected Coolify project and resolves server UUID from the instance default', function (): void {
    $manager = new release_manager();
    $method = new ReflectionMethod(release_manager::class, 'releaseCoolifyServicePayload');

    $payload = $method->invoke($manager, [
        'channel_slug' => 'internal',
        'app' => 'api',
        'repository' => 'copenhagentruckwash/api',
        'branch' => 'master',
    ], [
        'coolify_project_uuid' => 'project-selected',
        'coolify_deploy_now' => true,
        'image' => 'ghcr.io/copenhagentruckwash/api:master',
    ], [
        'default_project_uuid' => 'project-default',
        'default_environment_name' => 'production',
        'default_server_uuid' => 'server-default',
    ]);

    expect($payload['project_uuid'])->toBe('project-selected');
    expect($payload['server_uuid'])->toBe('server-default');
    expect($payload['environment_name'])->toBe('internal');
    expect($payload)->not->toHaveKey('environment_uuid');
    expect($payload)->not->toHaveKey('coolify_server_uuid');
});

it('supports isolated stack mode and names new Coolify services explicitly', function (): void {
    $manager = new release_manager();

    $normalizeMode = new ReflectionMethod(release_manager::class, 'normalizeServiceSetMode');
    expect($normalizeMode->invoke($manager, ' isolated_stack '))->toBe('isolated_stack');

    $payloadMethod = new ReflectionMethod(release_manager::class, 'releaseCoolifyServicePayload');
    $payload = $payloadMethod->invoke($manager, [
        'channel_slug' => 'internal',
        'app' => 'frontend',
        'repository' => 'copenhagentruckwash/front-end-vue',
        'branch' => 'main',
    ], [
        'coolify_service_name' => 'release-internal-safe-stack-frontend',
        'coolify_project_uuid' => 'project-internal',
        'coolify_deploy_now' => true,
        'image' => 'ghcr.io/copenhagentruckwash/front-end-vue:main',
    ], [
        'default_environment_name' => 'production',
        'default_server_uuid' => 'server-default',
    ]);

    expect($payload['name'])->toBe('release-internal-safe-stack-frontend');
    expect($payload['project_uuid'])->toBe('project-internal');
    expect($payload['environment_name'])->toBe('internal');
    expect($payload)->not->toHaveKey('type');
    expect(base64_decode($payload['docker_compose_raw'], true))->toContain('ghcr.io/copenhagentruckwash/front-end-vue:main');
});

it('creates frontend Coolify GitHub App application payloads with the release Dockerfile', function (): void {
    $manager = new release_manager();
    $payloadMethod = new ReflectionMethod(release_manager::class, 'releaseCoolifyApplicationPayload');

    $payload = $payloadMethod->invoke($manager, [
        'channel_slug' => 'canary',
        'app' => 'frontend',
        'repository' => 'copenhagentruckwash/pleno-vue',
        'branch' => 'release/canary',
        'auto_deploy' => 1,
    ], [
        'coolify_service_name' => 'release-canary-pleno-vue',
        'coolify_project_uuid' => 'project-canary',
        'coolify_github_app_uuid' => 'github-app-copenhagentruckwash-github',
        'coolify_build_pack' => 'nixpacks',
        'coolify_deploy_now' => true,
        'coolify_public_url' => 'https://canary.example.test',
        'coolify_enable_ssl' => true,
    ], [
        'default_environment_name' => 'production',
        'default_server_uuid' => 'server-default',
    ]);

    expect($payload['name'])->toBe('release-canary-pleno-vue');
    expect($payload['project_uuid'])->toBe('project-canary');
    expect($payload['environment_name'])->toBe('release-canary');
    expect($payload['server_uuid'])->toBe('server-default');
    expect($payload['github_app_uuid'])->toBe('github-app-copenhagentruckwash-github');
    expect($payload['git_repository'])->toBe('copenhagentruckwash/pleno-vue');
    expect($payload['git_branch'])->toBe('release/canary');
    expect($payload['build_pack'])->toBe('dockerfile');
    expect($payload['ports_exposes'])->toBe('80');
    expect($payload['dockerfile_location'])->toBe('/Dockerfile.coolify-frontend');
    expect($payload['domains'])->toBe('https://canary.example.test/canary/frontend');
    expect($payload)->not->toHaveKey('publish_directory');
    expect($payload)->not->toHaveKey('is_static');
    expect($payload)->not->toHaveKey('docker_compose_raw');
});

it('uses the self-contained Coolify API Dockerfile for API applications', function (): void {
    $manager = new release_manager();
    $payloadMethod = new ReflectionMethod(release_manager::class, 'releaseCoolifyApplicationPayload');

    $payload = $payloadMethod->invoke($manager, [
        'channel_slug' => 'internal',
        'app' => 'api',
        'repository' => 'copenhagentruckwash/api',
        'branch' => 'master',
        'auto_deploy' => 1,
    ], [
        'coolify_service_name' => 'release-internal-api-node3-truckwash-io',
        'coolify_project_uuid' => 'project-internal',
        'coolify_github_app_uuid' => 'github-app-copenhagentruckwash-github',
        'coolify_deploy_now' => true,
        'coolify_public_url' => 'https://api-v2.truckwash.io',
        'coolify_enable_ssl' => true,
        'gateway_route_autoprovision' => true,
    ], [
        'default_environment_name' => 'production',
        'default_server_uuid' => 'server-node3',
    ]);

    expect($payload['build_pack'])->toBe('dockerfile');
    expect($payload['ports_exposes'])->toBe('80');
    expect($payload['dockerfile_location'])->toBe('/Dockerfile.coolify-api');
    expect($payload['domains'])->toBe('https://api-v2.truckwash.io:80');
    expect($payload)->not->toHaveKey('publish_directory');
    expect($payload)->not->toHaveKey('is_static');
});

it('creates private Coolify application payloads for cron workers', function (): void {
    $manager = new release_manager();
    $payloadMethod = new ReflectionMethod(release_manager::class, 'releaseCoolifyApplicationPayload');

    $payload = $payloadMethod->invoke($manager, [
        'channel_slug' => 'internal',
        'app' => 'cron',
        'repository' => 'copenhagentruckwash/api',
        'branch' => 'master',
        'auto_deploy' => 0,
    ], [
        'coolify_service_name' => 'release-internal-cron-worker',
        'coolify_project_uuid' => 'project-internal',
        'coolify_github_app_uuid' => 'github-app-copenhagentruckwash-github',
        'coolify_build_pack' => 'dockerfile',
        'coolify_deploy_now' => true,
        'coolify_start_command' => 'php index.php run cron-worker',
    ], [
        'default_environment_name' => 'production',
        'default_server_uuid' => 'server-node3',
    ]);

    expect($payload['name'])->toBe('release-internal-cron-worker');
    expect($payload['build_pack'])->toBe('dockerfile');
    expect($payload['ports_exposes'])->toBe('80');
    expect($payload['dockerfile_location'])->toBe('/Dockerfile.coolify-api');
    expect($payload['start_command'])->toBe('php index.php run cron-worker');
    expect($payload['is_auto_deploy_enabled'])->toBeFalse();
    expect($payload)->not->toHaveKey('domains');
    expect($payload)->not->toHaveKey('is_force_https_enabled');
});

it('derives cron worker deployment context from the API target without public routing', function (): void {
    $manager = new release_manager();
    $contextMethod = new ReflectionMethod(release_manager::class, 'cronWorkerDeployContext');

    $context = $contextMethod->invoke($manager, [
        'id' => 17,
        'channel_id' => 3,
        'channel_slug' => 'internal',
        'deploy_context_json' => json_encode([
            'coolify_project_uuid' => 'project-internal',
            'coolify_environment_name' => 'production',
            'coolify_github_app_uuid' => 'github-app-copenhagentruckwash-github',
            'coolify_public_url' => 'https://api-v2.truckwash.io',
            'manual_endpoint_host' => 'manual.example.test',
        ]),
    ], null, '5555555555555555555555555555555555555555', 41);

    expect($context['coolify_auto_create'])->toBeTrue();
    expect($context['coolify_resource_type'])->toBe('application');
    expect($context['coolify_build_pack'])->toBe('dockerfile');
    expect($context['coolify_dockerfile_location'])->toBe('/Dockerfile.coolify-api');
    expect($context['coolify_start_command'])->toBe('php index.php run cron-worker');
    expect($context['coolify_is_auto_deploy_enabled'])->toBeFalse();
    expect($context['coolify_enable_ssl'])->toBeFalse();
    expect($context['coolify_service_name'])->toBe('release-internal-cron-worker');
    expect($context['coolify_git_commit_sha'])->toBe('5555555555555555555555555555555555555555');
    expect($context['coolify_env']['CRON_WORKER_RELEASE_CHANNEL_ID'])->toBe('3');
    expect($context['coolify_env']['CRON_WORKER_RELEASE_TARGET_ID'])->toBe('41');
    expect($context['coolify_env']['CRON_WORKER_SOURCE'])->toBe('coolify_worker');
    expect($context)->not->toHaveKey('coolify_public_url');
    expect($context)->not->toHaveKey('manual_endpoint_host');
});

it('requires Coolify cron worker autoprovisioning for API deployments by default', function (): void {
    $manager = new release_manager();
    $enabledMethod = new ReflectionMethod(release_manager::class, 'cronWorkerAutoprovisionEnabled');
    $requiredMethod = new ReflectionMethod(release_manager::class, 'cronWorkerAutoprovisionRequired');

    expect($enabledMethod->invoke($manager, ['deploy_context_json' => null]))->toBeTrue();
    expect($requiredMethod->invoke($manager, ['deploy_context_json' => null]))->toBeTrue();

    $optionalTarget = [
        'deploy_context_json' => json_encode([
            'cron_worker_autoprovision_required' => false,
        ]),
    ];
    expect($enabledMethod->invoke($manager, $optionalTarget))->toBeTrue();
    expect($requiredMethod->invoke($manager, $optionalTarget))->toBeFalse();

    $disabledTarget = [
        'deploy_context_json' => json_encode([
            'cron_worker_autoprovision' => false,
        ]),
    ];
    expect($enabledMethod->invoke($manager, $disabledTarget))->toBeFalse();
    expect($requiredMethod->invoke($manager, $disabledTarget))->toBeFalse();

	    $managerSource = file_get_contents(app_path('classes/release_manager.php'));
	    expect($managerSource)->toContain('Cron worker deployment is required for API deployments');
	});

it('classifies cron worker deployment and heartbeat lifecycle states', function (): void {
    $manager = new release_manager();
    $healthMethod = new ReflectionMethod(release_manager::class, 'cronWorkerHealth');

    expect($healthMethod->invoke($manager, null, null, [], ['running' => 0, 'stale' => 0, 'failed' => 0], null)['state'])
        ->toBe('needs_deploy');
    expect($healthMethod->invoke($manager, ['id' => 4], null, [], ['running' => 0, 'stale' => 0, 'failed' => 0], null)['state'])
        ->toBe('needs_deploy');
    expect($healthMethod->invoke($manager, ['id' => 4], ['id' => 9], [], ['running' => 0, 'stale' => 0, 'failed' => 0], [
        'status' => 'deploying',
        'created_at' => date('Y-m-d H:i:s'),
    ])['state'])->toBe('deploying');
    expect($healthMethod->invoke($manager, ['id' => 4], ['id' => 9], [], ['running' => 0, 'stale' => 0, 'failed' => 0], [
        'status' => 'deployed',
        'completed_at' => date('Y-m-d H:i:s'),
    ])['state'])->toBe('waiting_for_heartbeat');
    expect($healthMethod->invoke($manager, ['id' => 4], ['id' => 9], [
        ['status' => 'running', 'stale' => false],
    ], ['running' => 1, 'stale' => 0, 'failed' => 0], [
        'status' => 'deployed',
    ])['state'])->toBe('healthy');
    expect($healthMethod->invoke($manager, ['id' => 4], ['id' => 9], [], ['running' => 0, 'stale' => 0, 'failed' => 0], [
        'status' => 'deployed',
        'completed_at' => '2020-01-01 00:00:00',
    ])['state'])->toBe('failed');
});

it('extracts Coolify cron deployment operation identifiers from provider payloads', function (): void {
    $manager = new release_manager();
    $operationMethod = new ReflectionMethod(release_manager::class, 'coolifyDeploymentOperationId');

    expect($operationMethod->invoke($manager, ['deployment' => ['deployment_uuid' => 'deployment-123']]))
        ->toBe('deployment-123');
    expect($operationMethod->invoke($manager, ['data' => ['uuid' => 'operation-456']]))
        ->toBe('operation-456');
    expect($operationMethod->invoke($manager, ['message' => 'queued']))
        ->toBeNull();
});

it('detects missing Coolify cron worker resources from provider errors', function (): void {
    $method = new ReflectionMethod(release_manager::class, 'coolifyResourceMissing');

    expect($method->invoke(null, new RuntimeException('Coolify API request failed: HTTP 404')))->toBeTrue();
    expect($method->invoke(null, new RuntimeException('Application not found')))->toBeTrue();
    expect($method->invoke(null, new RuntimeException('Coolify API request failed: HTTP 401')))->toBeFalse();
});

it('classifies missing Coolify cron worker resources as repairable', function (): void {
    $manager = new release_manager();
    $readiness = new ReflectionMethod(release_manager::class, 'cronWorkerDeploymentReadiness');

    $result = $readiness->invoke($manager, [
        'id' => 17,
        'app' => 'api',
        'coolify_instance_id' => 3,
        'repository' => 'copenhagentruckwash/api',
    ], [
        'id' => 71,
        'app' => 'cron',
        'coolify_instance_id' => 3,
        'coolify_service_uuid' => 'missing-cron-worker',
        'repository' => 'copenhagentruckwash/api',
        'branch' => 'master',
    ], [
        'configured' => true,
        'missing' => true,
    ]);

    expect($result['action'])->toBe('repair');
    expect($result['can_deploy'])->toBeTrue();
    expect(array_column($result['issues'], 'code'))->toContain('missing_coolify_worker_resource');
});

it('repairs from an existing cron target when the API target is absent', function (): void {
    $manager = new release_manager();
    $readiness = new ReflectionMethod(release_manager::class, 'cronWorkerDeploymentReadiness');

    $result = $readiness->invoke($manager, null, [
        'id' => 71,
        'app' => 'cron',
        'coolify_instance_id' => 3,
        'coolify_service_uuid' => 'missing-cron-worker',
        'repository' => 'copenhagentruckwash/api',
        'branch' => 'master',
    ], [
        'configured' => true,
        'missing' => true,
    ]);

    expect($result['action'])->toBe('repair');
    expect($result['can_deploy'])->toBeTrue();
    expect(array_column($result['issues'], 'code'))->toContain('repairable_cron_target');
});

it('blocks cron worker deployment without an API target or deployable cron context', function (): void {
    $manager = new release_manager();
    $readiness = new ReflectionMethod(release_manager::class, 'cronWorkerDeploymentReadiness');

    $result = $readiness->invoke($manager, null, [
        'id' => 71,
        'app' => 'cron',
        'coolify_instance_id' => null,
        'coolify_service_uuid' => 'missing-cron-worker',
        'repository' => '',
        'branch' => 'master',
    ], [
        'configured' => true,
        'missing' => true,
    ]);

    expect($result['action'])->toBe('blocked');
    expect($result['can_deploy'])->toBeFalse();
    expect(array_column($result['issues'], 'code'))->toContain('missing_api_target');
});

it('builds explicit Coolify application route labels for release API targets', function (): void {
    $manager = new release_manager();
    $payloadMethod = new ReflectionMethod(release_manager::class, 'releaseCoolifyApplicationRoutePayload');

    $payload = $payloadMethod->invoke($manager, [
        'channel_slug' => 'internal',
        'app' => 'api',
        'repository' => 'copenhagentruckwash/api',
        'branch' => 'master',
    ], [
        'coolify_ports_exposes' => '8080',
    ], 'https://api-v2.truckwash.io', 'api-app-uuid', base64_encode('custom.keep=true'));
    $labels = explode("\n", base64_decode($payload['custom_labels'], true));

    expect($payload['domains'])->toBe('https://api-v2.truckwash.io:8080')
        ->and($payload['is_force_https_enabled'])->toBeTrue()
        ->and($payload['force_domain_override'])->toBeTrue()
        ->and($labels)->toContain('custom.keep=true')
        ->and($labels)->toContain('traefik.http.routers.https-0-api-app-uuid.rule=Host(`api-v2.truckwash.io`) && PathPrefix(`/`)')
        ->and($labels)->toContain('traefik.http.routers.https-0-api-app-uuid.priority=1001')
        ->and($labels)->toContain('traefik.http.routers.https-0-api-app-uuid.tls.certresolver=letsencrypt')
        ->and($labels)->toContain('traefik.http.routers.https-0-api-app-uuid.tls.domains[0].main=api-v2.truckwash.io')
        ->and($labels)->toContain('traefik.http.services.https-0-api-app-uuid.loadbalancer.server.port=8080');
});

it('updates existing frontend Coolify applications away from legacy Nixpacks detection', function (): void {
    $manager = new release_manager();
    $payloadMethod = new ReflectionMethod(release_manager::class, 'releaseCoolifyApplicationUpdatePayload');

    $payload = $payloadMethod->invoke($manager, [
        'channel_slug' => 'internal',
        'app' => 'frontend',
        'repository' => 'copenhagentruckwash/pleno-vue',
        'branch' => 'master',
        'commit_sha' => '1132c8c2560e44478d1bb777c88c762a5e1d0b20',
    ], [
        'coolify_build_pack' => 'nixpacks',
    ]);

    expect($payload['git_repository'])->toBe('copenhagentruckwash/pleno-vue');
    expect($payload['git_branch'])->toBe('master');
    expect($payload['git_commit_sha'])->toBe('1132c8c2560e44478d1bb777c88c762a5e1d0b20');
    expect($payload['build_pack'])->toBe('dockerfile');
    expect($payload['ports_exposes'])->toBe('80');
    expect($payload['dockerfile_location'])->toBe('/Dockerfile.coolify-frontend');
    expect($payload['install_command'])->toBe('');
    expect($payload['build_command'])->toBe('');
    expect($payload['publish_directory'])->toBe('');
    expect($payload['is_static'])->toBeFalse();
    expect($payload['is_spa'])->toBeFalse();
});

it('can use the Coolify instance default GitHub App when source targets do not store it yet', function (): void {
    $manager = new release_manager();
    $payloadMethod = new ReflectionMethod(release_manager::class, 'releaseCoolifyApplicationPayload');

    $payload = $payloadMethod->invoke($manager, [
        'channel_slug' => 'internal',
        'app' => 'api',
        'repository' => 'copenhagentruckwash/api',
        'branch' => 'master',
    ], [
        'coolify_project_uuid' => 'project-internal',
        'coolify_deploy_now' => true,
    ], [
        'default_environment_name' => 'production',
        'default_server_uuid' => 'server-default',
        'default_github_app_uuid' => 'github-app-copenhagentruckwash-github',
    ]);

    expect($payload['github_app_uuid'])->toBe('github-app-copenhagentruckwash-github');
    expect($payload['git_repository'])->toBe('copenhagentruckwash/api');
    expect($payload['build_pack'])->toBe('dockerfile');
    expect($payload['ports_exposes'])->toBe('80');
    expect($payload)->not->toHaveKey('docker_compose_raw');
});

it('does not treat an existing Coolify service as an application just because a GitHub App UUID is stored', function (): void {
    $manager = new release_manager();
    $resourceTypeMethod = new ReflectionMethod(release_manager::class, 'releaseCoolifyResourceType');

    expect($resourceTypeMethod->invoke($manager, [
        'coolify_github_app_uuid' => 'github-app-copenhagentruckwash-github',
    ], 'existing-service-uuid'))->toBe('service');

    expect($resourceTypeMethod->invoke($manager, [
        'coolify_github_app_uuid' => 'github-app-copenhagentruckwash-github',
    ], ''))->toBe('application');

    expect($resourceTypeMethod->invoke($manager, [
        'coolify_resource_type' => 'application',
        'coolify_github_app_uuid' => 'github-app-copenhagentruckwash-github',
    ], 'existing-application-uuid'))->toBe('application');
});

it('auto-prepares path-routed release targets for Coolify application creation', function (): void {
    $manager = new release_manager();
    $needsApplication = new ReflectionMethod(release_manager::class, 'releaseTargetNeedsApplicationAutoCreate');

    $target = [
        'id' => 42,
        'channel_slug' => 'stable',
        'app' => 'api',
        'coolify_instance_id' => 7,
        'coolify_service_uuid' => '',
        'deploy_context_json' => null,
    ];

    expect($needsApplication->invoke($manager, $target, []))->toBeTrue();
    expect($needsApplication->invoke($manager, array_replace($target, [
        'coolify_service_uuid' => 'existing-service',
    ]), []))->toBeFalse();
    expect($needsApplication->invoke($manager, array_replace($target, [
        'coolify_instance_id' => null,
    ]), []))->toBeFalse();
    expect($needsApplication->invoke($manager, $target, [
        'coolify_auto_create' => true,
    ]))->toBeFalse();
});

it('offers application target preparation for missing Coolify service creation failures', function (): void {
    $needsApplicationAction = new ReflectionMethod(release_manager::class, 'releaseStatusIssueNeedsApplicationTarget');

    expect($needsApplicationAction->invoke(null, [
        'message' => 'API deployment failed before activation.',
        'next_action' => 'Select an existing Coolify service or enable Coolify service creation before deployment.',
    ]))->toBeTrue();
    expect($needsApplicationAction->invoke(null, [
        'message' => 'Path-routed release targets require a Coolify application resource with StripPrefix labels.',
        'next_action' => 'Migrate this target before deploying.',
    ]))->toBeTrue();
    expect($needsApplicationAction->invoke(null, [
        'message' => 'Release gate failed.',
        'next_action' => 'Run live smoke tests before promotion.',
    ]))->toBeFalse();
});

it('builds API Coolify runtime environment from allowed process variables', function (): void {
    $keys = ['CONFIG_DB_HOST', 'CONFIG_DB_PASSWORD', 'EDGE_BROKER_URL', 'CORS', 'PATH'];
    $previous = [];
    foreach ($keys as $key) {
        $previous[$key] = getenv($key);
    }

    try {
        putenv('CONFIG_DB_HOST=db.example.test');
        $_ENV['CONFIG_DB_HOST'] = 'db.example.test';
        $_SERVER['CONFIG_DB_HOST'] = 'db.example.test';
        putenv('CONFIG_DB_PASSWORD=runtime-secret');
        $_ENV['CONFIG_DB_PASSWORD'] = 'runtime-secret';
        $_SERVER['CONFIG_DB_PASSWORD'] = 'runtime-secret';
        putenv('EDGE_BROKER_URL=https://edge.example.test');
        $_ENV['EDGE_BROKER_URL'] = 'https://edge.example.test';
        $_SERVER['EDGE_BROKER_URL'] = 'https://edge.example.test';
        putenv('CORS=https://truckwash.io,https://api-v2.truckwash.io/master/api');
        $_ENV['CORS'] = 'https://truckwash.io,https://api-v2.truckwash.io/master/api';
        $_SERVER['CORS'] = 'https://truckwash.io,https://api-v2.truckwash.io/master/api';
        putenv('PATH=/should/not/copy');
        $_ENV['PATH'] = '/should/not/copy';
        $_SERVER['PATH'] = '/should/not/copy';

        $manager = new release_manager();
        $runtimeEnv = new ReflectionMethod(release_manager::class, 'releaseCoolifyRuntimeEnv');

        $env = $runtimeEnv->invoke($manager, [
            'app' => 'api',
        ], [
            'coolify_env' => [
                'CONFIG_DB_HOST' => 'context-db.example.test',
                'CUSTOM_ALLOWED' => 'from-context',
            ],
        ]);

        expect($env['USE_ENV'])->toBe('true');
        expect($env['CONFIG_DB_HOST'])->toBe('context-db.example.test');
        expect($env['CONFIG_DB_PASSWORD'])->toBe('runtime-secret');
        expect($env['EDGE_BROKER_URL'])->toBe('https://edge.example.test');
        expect($env['CUSTOM_ALLOWED'])->toBe('from-context');
        expect(explode(',', $env['CORS']))->toContain('https://api-v2.truckwash.io');
        expect(explode(',', $env['CORS']))->toContain('http://localhost:5173');
        expect(explode(',', $env['CORS']))->not->toContain('https://api-v2.truckwash.io/master/api');
        expect($env)->not->toHaveKey('PATH');
    } finally {
        foreach ($previous as $key => $value) {
            if ($value === false) {
                putenv($key);
                unset($_ENV[$key], $_SERVER[$key]);
            } else {
                putenv($key . '=' . $value);
                $_ENV[$key] = $value;
                $_SERVER[$key] = $value;
            }
        }
    }
});

it('resolves backend commit sha from API runtime environment in priority order', function (): void {
    $keys = ['API_COMMIT_SHA', 'COMMIT_SHA', 'GITHUB_SHA', 'RELEASE_COMMIT_SHA'];
    $previous = [];
    foreach ($keys as $key) {
        $previous[$key] = [
            'process' => getenv($key),
            'env_set' => array_key_exists($key, $_ENV),
            'env' => $_ENV[$key] ?? null,
            'server_set' => array_key_exists($key, $_SERVER),
            'server' => $_SERVER[$key] ?? null,
        ];
        putenv($key);
        unset($_ENV[$key], $_SERVER[$key]);
    }

    $set = static function (string $key, string $value): void {
        putenv($key . '=' . $value);
        $_ENV[$key] = $value;
        $_SERVER[$key] = $value;
    };

    try {
        $set('API_COMMIT_SHA', 'not-a-sha');
        $set('COMMIT_SHA', 'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB');
        $set('GITHUB_SHA', 'cccccccccccccccccccccccccccccccccccccccc');

        expect(release_manager::backendCommitSha())->toBe('bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb');

        $set('API_COMMIT_SHA', 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA');

        expect(release_manager::backendCommitSha())->toBe('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa');
    } finally {
        foreach ($previous as $key => $state) {
            if ($state['process'] === false) {
                putenv($key);
            } else {
                putenv($key . '=' . $state['process']);
            }

            if ($state['env_set']) {
                $_ENV[$key] = $state['env'];
            } else {
                unset($_ENV[$key]);
            }

            if ($state['server_set']) {
                $_SERVER[$key] = $state['server'];
            } else {
                unset($_SERVER[$key]);
            }
        }
    }
});

it('forces selected API commit into generated Coolify runtime env keys', function (): void {
    $manager = new release_manager();
    $runtimeEnv = new ReflectionMethod(release_manager::class, 'releaseCoolifyRuntimeEnv');

    $selectedCommit = '1111111111111111111111111111111111111111';
    $explicitCommit = '2222222222222222222222222222222222222222';

    $env = $runtimeEnv->invoke($manager, [
        'app' => 'api',
        'commit_sha' => $selectedCommit,
    ], [
        'coolify_env' => [
            'API_COMMIT_SHA' => $explicitCommit,
            'COMMIT_SHA' => $explicitCommit,
            'GITHUB_SHA' => $explicitCommit,
            'RELEASE_COMMIT_SHA' => $explicitCommit,
        ],
    ]);

    expect($env['API_COMMIT_SHA'])->toBe($selectedCommit);
    expect($env['COMMIT_SHA'])->toBe($selectedCommit);
    expect($env['GITHUB_SHA'])->toBe($selectedCommit);
    expect($env['RELEASE_COMMIT_SHA'])->toBe($selectedCommit);
});

it('builds cron worker runtime environment from API runtime keys and cron context', function (): void {
    $keys = ['CONFIG_DB_HOST', 'CRON_WORKER_NAME'];
    $previous = [];
    foreach ($keys as $key) {
        $previous[$key] = getenv($key);
    }

    try {
        putenv('CONFIG_DB_HOST=db.example.test');
        $_ENV['CONFIG_DB_HOST'] = 'db.example.test';
        $_SERVER['CONFIG_DB_HOST'] = 'db.example.test';
        putenv('CRON_WORKER_NAME=ignored-runtime-name');
        $_ENV['CRON_WORKER_NAME'] = 'ignored-runtime-name';
        $_SERVER['CRON_WORKER_NAME'] = 'ignored-runtime-name';

        $manager = new release_manager();
        $runtimeEnv = new ReflectionMethod(release_manager::class, 'releaseCoolifyRuntimeEnv');
        $selectedCommit = '4444444444444444444444444444444444444444';

        $env = $runtimeEnv->invoke($manager, [
            'app' => 'cron',
            'commit_sha' => $selectedCommit,
            'coolify_service_uuid' => 'cron-worker-resource-uuid',
        ], [
            'coolify_env' => [
                'CRON_WORKER_NAME' => 'release-internal-cron-worker',
                'CRON_WORKER_SOURCE' => 'coolify_worker',
            ],
        ]);

        expect($env['USE_ENV'])->toBe('true');
        expect($env['CONFIG_DB_HOST'])->toBe('db.example.test');
        expect($env['CRON_WORKER_NAME'])->toBe('release-internal-cron-worker');
        expect($env['CRON_WORKER_SOURCE'])->toBe('coolify_worker');
        expect($env['CRON_WORKER_COOLIFY_RESOURCE_UUID'])->toBe('cron-worker-resource-uuid');
        expect($env['CRON_WORKER_COMMIT_SHA'])->toBe($selectedCommit);
        expect($env['API_COMMIT_SHA'])->toBe($selectedCommit);
        expect($env['COMMIT_SHA'])->toBe($selectedCommit);
    } finally {
        foreach ($previous as $key => $value) {
            if ($value === false) {
                putenv($key);
                unset($_ENV[$key], $_SERVER[$key]);
            } else {
                putenv($key . '=' . $value);
                $_ENV[$key] = $value;
                $_SERVER[$key] = $value;
            }
        }
    }
});

it('uses the selected deployment commit before stale Coolify context commits', function (): void {
    $manager = new release_manager();
    $gitCommitSha = new ReflectionMethod(release_manager::class, 'releaseCoolifyGitCommitSha');

    $selectedCommit = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
    $staleCommit = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb';

    expect($gitCommitSha->invoke($manager, [
        'commit_sha' => $selectedCommit,
    ], [
        'coolify_git_commit_sha' => $staleCommit,
        'git_commit_sha' => $staleCommit,
    ]))->toBe($selectedCommit);
});

it('injects selected frontend commit into Coolify runtime env for manifest builds', function (): void {
    $manager = new release_manager();
    $runtimeEnv = new ReflectionMethod(release_manager::class, 'releaseCoolifyRuntimeEnv');

    $selectedCommit = '3333333333333333333333333333333333333333';

    $env = $runtimeEnv->invoke($manager, [
        'app' => 'frontend',
        'commit_sha' => $selectedCommit,
    ], []);

    expect($env['SOURCE_COMMIT'])->toBe($selectedCommit);
    expect($env['RELEASE_COMMIT_SHA'])->toBe($selectedCommit);
    expect($env['COMMIT_SHA'])->toBe($selectedCommit);
    expect($env['GITHUB_SHA'])->toBe($selectedCommit);
    expect($env['VITE_COMMIT_HASH'])->toBe($selectedCommit);
});

it('keeps beta API runtime environment on production database target', function (): void {
    $keys = ['CONFIG_DB_TARGET', 'CONFIG_DB_HOST', 'CONFIG_DB_DEBUG_HOST', 'DEBUG'];
    $previous = [];
    foreach ($keys as $key) {
        $previous[$key] = getenv($key);
    }

    try {
        putenv('CONFIG_DB_TARGET=production');
        $_ENV['CONFIG_DB_TARGET'] = 'production';
        $_SERVER['CONFIG_DB_TARGET'] = 'production';
        putenv('CONFIG_DB_HOST=prod-db.example.test');
        $_ENV['CONFIG_DB_HOST'] = 'prod-db.example.test';
        $_SERVER['CONFIG_DB_HOST'] = 'prod-db.example.test';
        putenv('CONFIG_DB_DEBUG_HOST=debug-db.example.test');
        $_ENV['CONFIG_DB_DEBUG_HOST'] = 'debug-db.example.test';
        $_SERVER['CONFIG_DB_DEBUG_HOST'] = 'debug-db.example.test';
        putenv('DEBUG=false');
        $_ENV['DEBUG'] = 'false';
        $_SERVER['DEBUG'] = 'false';

        $manager = new release_manager();
        $runtimeEnv = new ReflectionMethod(release_manager::class, 'releaseCoolifyRuntimeEnv');

        $env = $runtimeEnv->invoke($manager, [
            'app' => 'api',
            'channel_slug' => 'beta',
        ], []);

        expect($env['CONFIG_DB_TARGET'])->toBe('production');
        expect($env['CONFIG_DB_HOST'])->toBe('prod-db.example.test');
        expect($env['DEBUG'])->toBe('false');
        expect($env['CONFIG_DB_TARGET'])->not->toBe('debug');
    } finally {
        foreach ($previous as $key => $value) {
            if ($value === false) {
                putenv($key);
                unset($_ENV[$key], $_SERVER[$key]);
            } else {
                putenv($key . '=' . $value);
                $_ENV[$key] = $value;
                $_SERVER[$key] = $value;
            }
        }
    }
});

it('treats attach-existing service sets without data target ids as production-shared ready', function (): void {
    $manager = new release_manager();
    $status = new ReflectionMethod(release_manager::class, 'serviceSetStatus');
    $dataTargets = ['database' => null, 'redis' => null, 'minio' => null];

    expect($status->invoke($manager, 'attach_existing', 10, 11, $dataTargets))->toBe('ready');
    expect($status->invoke($manager, 'clone_existing', 10, 11, $dataTargets))->toBe('needs_clone_targets');
    expect($status->invoke($manager, 'fresh_empty', 10, 11, $dataTargets))->toBe('isolated_empty');
    expect($status->invoke($manager, 'isolated_stack', 10, 11, $dataTargets))->toBe('needs_isolated_targets');
});

it('detects explicit data target ids so beta service sets can stay data-only', function (): void {
    $manager = new release_manager();
    $hasExplicitDataTargets = new ReflectionMethod(release_manager::class, 'serviceSetInputHasExplicitDataTargets');

    expect($hasExplicitDataTargets->invoke($manager, [
        'mode' => 'attach_existing',
        'data_source_service_set_id' => 10,
    ]))->toBeFalse();
    expect($hasExplicitDataTargets->invoke($manager, [
        'data_targets' => [
            'database' => 42,
        ],
    ]))->toBeTrue();
    expect($hasExplicitDataTargets->invoke($manager, [
        'redis_coolify_target_id' => 43,
    ]))->toBeTrue();
});

it('allows beta production-service bundles only when data services stay production-shared', function (): void {
    $manager = new release_manager();
    $assert = new ReflectionMethod(release_manager::class, 'assertBetaProductionDataPolicy');
    $betaChannel = ['id' => 2, 'slug' => 'beta'];

    expect($assert->invoke($manager, $betaChannel, ['mode' => 'attach_existing']))->toBeNull();

    foreach (['clone_existing', 'fresh_empty', 'isolated_stack'] as $mode) {
        expect(fn() => $assert->invoke($manager, $betaChannel, ['mode' => $mode]))
            ->toThrow(RuntimeException::class, 'production-shared');
    }
});

it('keeps release branch services out of the production Coolify environment', function (): void {
    $manager = new release_manager();
    $payloadMethod = new ReflectionMethod(release_manager::class, 'releaseCoolifyServicePayload');

    $canaryPayload = $payloadMethod->invoke($manager, [
        'channel_slug' => 'canary',
        'app' => 'api',
        'repository' => 'copenhagentruckwash/api',
        'branch' => 'release/canary',
    ], [
        'coolify_project_uuid' => 'project-release',
        'coolify_deploy_now' => true,
        'environment_name' => 'production',
        'image' => 'ghcr.io/copenhagentruckwash/api:canary',
    ], [
        'default_environment_uuid' => 'env-production',
        'default_environment_name' => 'production',
        'default_server_uuid' => 'server-default',
    ]);

    expect($canaryPayload['environment_name'])->toBe('release-canary');
    expect($canaryPayload)->not->toHaveKey('environment_uuid');

    $betaPayload = $payloadMethod->invoke($manager, [
        'channel_slug' => 'beta',
        'app' => 'api',
        'repository' => 'copenhagentruckwash/api',
        'branch' => 'release/beta',
    ], [
        'coolify_project_uuid' => 'project-release',
        'coolify_deploy_now' => true,
        'image' => 'ghcr.io/copenhagentruckwash/api:beta',
    ], [
        'default_environment_uuid' => 'env-production',
        'default_environment_name' => 'production',
        'default_server_uuid' => 'server-default',
    ]);

    expect($betaPayload['environment_name'])->toBe('release-beta');
    expect($betaPayload)->not->toHaveKey('environment_uuid');
});

it('does not invent GHCR images for Coolify service payloads', function (): void {
    $manager = new release_manager();
    $payloadMethod = new ReflectionMethod(release_manager::class, 'releaseCoolifyServicePayload');

    $payloadMethod->invoke($manager, [
        'channel_slug' => 'internal',
        'app' => 'frontend',
        'repository' => 'copenhagentruckwash/pleno-vue',
        'branch' => 'master',
    ], [
        'coolify_service_name' => 'release-internal-safe-stack-frontend',
        'coolify_project_uuid' => 'project-internal',
        'coolify_deploy_now' => true,
    ], [
        'default_environment_name' => 'production',
        'default_server_uuid' => 'server-default',
    ]);
})->throws(RuntimeException::class, 'explicit image');

it('creates Coolify service payloads from raw compose without a service type', function (): void {
    $manager = new release_manager();
    $payloadMethod = new ReflectionMethod(release_manager::class, 'releaseCoolifyServicePayload');

    $compose = "services:\n  app:\n    image: ghcr.io/copenhagentruckwash/api:test";
    $payload = $payloadMethod->invoke($manager, [
        'channel_slug' => 'internal',
        'app' => 'api',
        'repository' => 'copenhagentruckwash/api',
        'branch' => 'master',
    ], [
        'coolify_project_uuid' => 'project-internal',
        'coolify_deploy_now' => true,
        'docker_compose_raw' => $compose,
    ], [
        'default_environment_name' => 'production',
        'default_server_uuid' => 'server-default',
    ]);

    expect($payload)->not->toHaveKey('type');
    expect($payload['docker_compose_raw'])->toBe(base64_encode($compose));
    expect(base64_decode($payload['docker_compose_raw'], true))->toBe($compose);
});

it('defines release manager schema, routes, permissions, and system-status integration hooks', function (): void {
    $schema = file_get_contents(app_path('classes/release_manager_schema_bootstrap.php'));
    $manager = file_get_contents(app_path('classes/release_manager.php'));
    $route = file_get_contents(app_path('routes/releaseManagerRoute.php'));
    $auth = file_get_contents(app_path('routes/authRoute.php'));
    $response = file_get_contents(app_path('classes/response.php'));
    $status = file_get_contents(app_path('classes/superuser_system_status_service.php'));
    $index = file_get_contents(app_path('index.php'));
    $corsPolicy = file_get_contents(app_path('classes/cors_policy.php'));

    expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_channels');
    expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_versions');
    expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_channel_versions');
    expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_assignments');
    expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_deployment_targets');
    expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_auto_sync_events');
    expect($schema)->toContain('UNIQUE KEY uq_release_auto_sync_event');
    expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_service_sets');
    expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_deployments');
    expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_bundles');
    expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_operation_runs');
    expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_operation_steps');
    expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_replay_targets');
    expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_timeline_sessions');
    expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_timeline_events');
    expect($schema)->toContain('device_type VARCHAR(16) NULL');
    expect($schema)->toContain('frontend_version_label VARCHAR(128) NULL');
    expect($schema)->toContain('api_version_label VARCHAR(128) NULL');
    expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_module_health_snapshots');
    expect($schema)->toContain("ensureColumn('release_channel_versions', 'service_set_id'");
    expect($schema)->toContain("ensureColumn('release_channel_versions', 'bundle_id'");
    expect($schema)->toContain("ensureColumn('release_deployments', 'deployment_kind'");
    expect($schema)->toContain("ensureColumn('release_deployments', 'active_channel_app_key'");
    expect($schema)->toContain("ensureUniqueIndex('release_deployments', 'uniq_release_deployments_active_channel_app'");
    expect($schema)->toContain("ensureColumn('release_timeline_sessions', 'device_type'");
    expect($schema)->toContain("ensureIndex('release_timeline_sessions', 'idx_release_timeline_device'");
    expect($schema)->toContain("ensureModuleConfigDefault('ReleaseManager', 'enabled'");
    expect($schema)->toContain("ensureModuleConfigDefault('ReleaseManager', 'release_gate_token'");
    expect($schema)->toContain("ensureModuleConfigDefault('ReleaseManager', 'release_gate_required_for_promotion'");
    expect($schema)->toContain("ensureModuleConfigDefault('ReleaseManager', 'github_token'");
    expect($schema)->toContain("ensureModuleConfigDefault('ReleaseManager', 'github_api_url'");
    expect($schema)->toContain("['stable', 'Stable'");
    expect($schema)->toContain("['canary', 'Canary'");
    expect($schema)->toContain("['beta', 'Beta'");
    expect($schema)->toContain("['internal', 'Internal'");

    expect($route)->toContain('/release/bootstrap');
    expect($route)->toContain('/release/runtime');
    expect($route)->toContain('/release/timeline/events');
    expect($route)->toContain('/release/github/webhook');
    expect($route)->toContain('/release/gate/test-runs');
    expect($route)->toContain('releaseGateToken');
    expect($route)->toContain('/superuser/releases/config');
    expect($route)->toContain('/superuser/releases/github/repositories');
    expect($route)->toContain('/superuser/releases/github/branches');
    expect($route)->toContain('/superuser/releases/github/commits');
    expect($route)->toContain('/superuser/releases/github/test');
    expect($route)->toContain('/superuser/releases/channels');
    expect($route)->toContain('/superuser/releases/channels/{id}/sync');
    expect($route)->toContain('/superuser/releases/test-runs');
    expect($route)->toContain('/superuser/releases/operations');
    expect($route)->toContain('/superuser/releases/operations/{id}');
    expect($route)->toContain('/superuser/releases/channels/{id}/bundle');
    expect($route)->toContain('/superuser/releases/assignment-subjects');
    expect($route)->toContain('/superuser/releases/assignments');
    expect($route)->toContain('/superuser/releases/service-sets');
    expect($route)->toContain("\$this->delete('/superuser/releases/service-sets/{id}'");
    expect($route)->toContain('/superuser/releases/service-sets/{id}/isolated-data-services');
    expect($route)->toContain('/superuser/releases/bundles');
    expect($route)->toContain('/superuser/releases/bundles/{id}/deploy');
    expect($route)->toContain('/superuser/releases/bundles/{id}/promote');
    expect($route)->toContain('/superuser/releases/deployments');
    expect($route)->toContain('/superuser/releases/issues/actions');
    expect($route)->toContain('/superuser/releases/replay-targets');
    expect($route)->toContain('/superuser/releases/timeline/sessions');
    expect($route)->toContain('/superuser/releases/timeline/sessions/{traceId}');
    expect($route)->toContain('/superuser/releases/timeline');
    expect($route)->toContain("requirePermission('superuser_release_manager_view')");
    expect($route)->toContain("requirePermission('superuser_release_manager_manage')");
    expect($route)->toContain("requirePermission('superuser_release_manager_deploy')");
    expect($route)->toContain("requirePermission('superuser_release_manager_rollback')");
    expect($route)->toContain("requirePermission('superuser_release_manager_replay')");

    expect($manager)->toContain('verifyGithubSignature');
    expect($manager)->toContain('verifyReleaseGateToken');
    expect($manager)->toContain('normalizeReleaseGateInput');
    expect($manager)->toContain('processReleaseGateAutoSync');
    expect($manager)->toContain('release_auto_sync_events');
    expect($manager)->toContain('require_readiness');
    expect($manager)->toContain('release-manifest.json');
    expect($manager)->toContain('static_artifact');
    expect($manager)->toContain('api_gateway');
    expect($manager)->toContain('assertReleaseGatePassedForPromotion');
    expect($manager)->toContain('release_gate_required_for_promotion');
    expect($manager)->toContain("'public_smoke_required' => true");
    expect($manager)->toContain('normalizeGithubRepositoryName');
    expect($manager)->toContain("private const DEFAULT_BRANCH = 'master'");
    expect($manager)->toContain('releaseConfig');
    expect($manager)->toContain('updateReleaseConfig');
    expect($manager)->toContain('github_token_variable');
    expect($manager)->toContain('github_token_env_variable');
    expect($manager)->toContain('listGithubRepositories');
    expect($manager)->toContain('listGithubBranches');
    expect($manager)->toContain('listGithubCommits');
    expect($manager)->toContain('testGithubRepositoryAccess');
    expect($manager)->toContain('githubRepositoryAccess');
    expect($manager)->toContain('github_token');
    expect($manager)->toContain('commit_mode');
    expect($manager)->toContain('restartCoolifyService');
    expect($manager)->toContain('deployCoolifyReleaseTarget');
    expect($manager)->toContain('deployResource');
    expect($manager)->toContain('releaseCoolifyGitCommitSha');
    expect($manager)->toContain('releaseCoolifyForceRebuild');
    expect($manager)->toContain('listServiceSets');
    expect($manager)->toContain('createServiceSet');
    expect($manager)->toContain('deleteServiceSet');
    expect($manager)->toContain('createBundle');
    expect($manager)->toContain('deployBundle');
    expect($manager)->toContain('promoteBundle');
    expect($manager)->toContain('if ($apiVersionId !== null)');
    expect($manager)->toContain('setChannelBundle');
    expect($manager)->toContain('searchAssignmentSubjects');
    expect($manager)->toContain('publicAssignmentSubjectSuggestion');
    expect($manager)->toContain('available_channels');
    expect($manager)->toContain("'source' => 'deployment'");
    expect($manager)->toContain('chooseRuntimeChannel');
    expect($manager)->toContain('requestedRuntimeChannelSlug');
    expect($manager)->toContain("status = 'superseded'");
    expect($manager)->toContain('serviceSetIsActive');
    expect($manager)->toContain('bundleIsActive');
    expect($manager)->toContain('service_set_removed');
    expect($manager)->toContain('clone_replica_from_source');
    expect($manager)->toContain('register_isolated_empty_service');
    expect($manager)->toContain('isolated_stack');
    expect($manager)->toContain('create_isolated_empty_stack_service');
    expect($manager)->toContain('assertIsolatedStackTarget');
    expect($manager)->toContain('completeIsolatedStackDataServices');
    expect($manager)->toContain('createIsolatedStackDataTarget');
    expect($manager)->toContain('skip_replication_provisioning');
    expect($manager)->toContain('must not point at an existing Coolify service');
    expect($manager)->toContain("'data_promotion' => false");
    expect($manager)->toContain("'replica_failover' => false");
    expect($manager)->toContain('deploymentCanBePromoted');
    expect($manager)->toContain('deploymentFailureSummary');
    expect($manager)->toContain('runIssueAction');
    expect($manager)->toContain('release_issue_action_attempted');
    expect($manager)->toContain('prepareReleaseIssueApplicationTarget');
    expect($manager)->toContain('coolifyProjectSuggestions');
    expect($manager)->toContain('releaseCoolifyServerUuid');
    expect($manager)->toContain('coolify_project_uuid');
    expect($manager)->toContain('releaseCoolifyServicePayload');
    expect($manager)->toContain('releaseCoolifyApplicationPayload');
    expect($manager)->toContain('releaseCoolifyApplicationUpdatePayload');
    expect($manager)->toContain('createPrivateGithubAppApplication');
    expect($manager)->toContain('coolifyGithubAppSuggestions');
    expect($manager)->toContain('coolify_github_apps');
    expect($manager)->toContain('release_deployment_targets');
    expect($manager)->toContain('resolveChannel');
    expect($manager)->toContain('capturePolicyFor');
    expect($manager)->toContain('listTimelineSessions');
    expect($manager)->toContain('timelineSessionDetail');
    expect($manager)->toContain('timelineSessionContext');
    expect($manager)->toContain('timelineErrorReports');
    expect($manager)->toContain('timelineReleaseContext');
    expect($manager)->toContain('publicTimelineDeploymentReference');
    expect($manager)->toContain('publicTimelineBundleReference');
    expect($manager)->toContain('cleanupExpiredReplayData');
    expect($manager)->toContain('channelAvailability');
    expect($manager)->toContain("'availability' =>");
    expect($manager)->toContain('redactPayload');
    expect($manager)->toContain('moduleKeys');
    expect($manager)->toContain('releaseSuggestions');
    expect($manager)->toContain('load_balancer_domains');
    expect($manager)->toContain('appendDomainSuggestion');
    expect($manager)->toContain('Coolify SSL requires a DNS domain routed to the load balancer.');
    expect($manager)->toContain('releaseRuntimeUrls');
    expect($manager)->toContain('coolify_services');
    expect($manager)->toContain('coolify_enable_ssl');
    expect($manager)->toContain('createService');
    expect($manager)->toContain('updateService');
    expect(file_get_contents(app_path('classes/coolify_api_client.php')))->toContain('updateApplicationEnvsBulk');
    expect(file_get_contents(app_path('classes/coolify_api_client.php')))->toContain('stopApplication');
    expect($manager)->toContain('channel_presets');
    expect($manager)->toContain('target_presets');

    expect($auth)->toContain("'release' => (new release_manager())->runtimeForPayload");
    expect($response)->toContain('recordBackendFailure');
    expect($status)->toContain("'key' => 'releasemanager'");
    expect($status)->toContain('probeReleaseManagerModule');
    expect($index)->toContain('release_manager::initializeRequestContext');
    expect($corsPolicy)->toContain('X-Release-Trace');
    expect($manager)->toContain('X-Release-Channel');
    expect($manager)->toContain('normalizeReleaseApiIngressPath');
    expect($manager)->toContain('routeSlugForChannel');
    expect($manager)->toContain('channelSlugForRoute');
    expect($manager)->toContain('syncChannel');
    expect($manager)->toContain('runReleaseTest');
    expect($manager)->toContain("'apps' => \$this->releaseTestAppsFromInput(\$input)");
    expect($manager)->toContain('releaseTestAppsFromInput');
    expect($manager)->toContain('release_operation_runs');
    expect($manager)->toContain('active_channel_app_key');
    expect($manager)->toContain('production_shared');
    expect($manager)->toContain('Path-routed release targets require a Coolify application resource with StripPrefix labels');
    expect($manager)->toContain('$applicationPayload[\'instant_deploy\'] = false');
    expect($manager)->toContain('$servicePayload[\'instant_deploy\'] = false');
});

it('captures release request context from headers and runtime query parameters', function (): void {
    $server = $_SERVER;
    $get = $_GET;
    $context = $GLOBALS['RELEASE_REQUEST_CONTEXT'] ?? null;

    try {
        $_SERVER['HTTP_X_RELEASE_TRACE'] = 'trace-abc123';
        $_SERVER['HTTP_X_RELEASE_CHANNEL'] = 'Internal';
        $_SERVER['HTTP_X_FRONTEND_VERSION'] = 'frontend-130cc2fc106a';
        $_SERVER['REQUEST_URI'] = '/release/runtime?release_channel=canary';
        $_GET = ['release_channel' => 'canary'];

        $initialized = release_manager::initializeRequestContext();

        expect($initialized)->toMatchArray([
            'trace_id' => 'trace-abc123',
            'requested_channel' => 'internal',
            'frontend_version' => 'frontend-130cc2fc106a',
            'original_request_uri' => '/release/runtime?release_channel=canary',
            'ingress_prefix_stripped' => false,
        ]);
        expect($GLOBALS['RELEASE_REQUEST_CONTEXT']['requested_channel'])->toBe('internal');
    } finally {
        $_SERVER = $server;
        $_GET = $get;
        if ($context === null) {
            unset($GLOBALS['RELEASE_REQUEST_CONTEXT']);
        } else {
            $GLOBALS['RELEASE_REQUEST_CONTEXT'] = $context;
        }
    }
});

it('normalizes channel-prefixed API ingress paths before route dispatch', function (): void {
    $server = $_SERVER;
    $get = $_GET;
    $context = $GLOBALS['RELEASE_REQUEST_CONTEXT'] ?? null;

    try {
        $_SERVER['REQUEST_URI'] = '/internal/api/auth/session?foo=bar';
        $_GET = ['foo' => 'bar'];
        $GLOBALS['RELEASE_REQUEST_CONTEXT'] = [
            'trace_id' => 'trace-normalize',
            'requested_channel' => '',
            'frontend_version' => '',
            'backend_version' => 'test',
            'request_started_at' => date('c'),
            'original_request_uri' => '/internal/api/auth/session?foo=bar',
            'normalized_request_uri' => '',
            'ingress_prefix_stripped' => false,
        ];

        $normalized = release_manager::normalizeReleaseApiIngressPath(['stable', 'internal']);

        expect($normalized)->toMatchArray([
            'channel_slug' => 'internal',
            'original_request_uri' => '/internal/api/auth/session?foo=bar',
            'normalized_request_uri' => '/auth/session?foo=bar',
            'normalized_path' => '/auth/session',
        ]);
        expect($_SERVER['REQUEST_URI'])->toBe('/auth/session?foo=bar');
        expect($_SERVER['PATH_INFO'])->toBe('/auth/session');
        expect($_GET['release_channel'])->toBe('internal');
        expect($GLOBALS['RELEASE_REQUEST_CONTEXT'])->toMatchArray([
            'requested_channel' => 'internal',
            'normalized_request_uri' => '/auth/session?foo=bar',
            'ingress_prefix_stripped' => true,
        ]);
    } finally {
        $_SERVER = $server;
        $_GET = $get;
        if ($context === null) {
            unset($GLOBALS['RELEASE_REQUEST_CONTEXT']);
        } else {
            $GLOBALS['RELEASE_REQUEST_CONTEXT'] = $context;
        }
    }
});

it('maps the public master API prefix to the stable release channel', function (): void {
    $server = $_SERVER;
    $get = $_GET;
    $context = $GLOBALS['RELEASE_REQUEST_CONTEXT'] ?? null;

    try {
        $_SERVER['REQUEST_URI'] = '/master/api/release/runtime';
        $_GET = [];
        $GLOBALS['RELEASE_REQUEST_CONTEXT'] = [
            'trace_id' => 'trace-master',
            'requested_channel' => '',
            'frontend_version' => '',
            'backend_version' => 'test',
            'request_started_at' => date('c'),
            'original_request_uri' => '/master/api/release/runtime',
            'normalized_request_uri' => '',
            'ingress_prefix_stripped' => false,
        ];

        $normalized = release_manager::normalizeReleaseApiIngressPath(['stable', 'beta', 'canary', 'internal']);

        expect($normalized)->toMatchArray([
            'route_slug' => 'master',
            'channel_slug' => 'stable',
            'normalized_request_uri' => '/release/runtime',
            'normalized_path' => '/release/runtime',
        ]);
        expect($_GET['release_channel'])->toBe('stable');
        expect($GLOBALS['RELEASE_REQUEST_CONTEXT'])->toMatchArray([
            'requested_channel' => 'stable',
            'release_route_slug' => 'master',
            'ingress_prefix_stripped' => true,
        ]);
    } finally {
        $_SERVER = $server;
        $_GET = $get;
        if ($context === null) {
            unset($GLOBALS['RELEASE_REQUEST_CONTEXT']);
        } else {
            $GLOBALS['RELEASE_REQUEST_CONTEXT'] = $context;
        }
    }
});

it('uses master as the public route slug for the stable release channel', function (): void {
    expect(release_manager::routeSlugForChannel('stable'))->toBe('master');
    expect(release_manager::routeSlugForChannel('beta'))->toBe('beta');
    expect(release_manager::channelSlugForRoute('master'))->toBe('stable');
    expect(release_manager::channelSlugForRoute('internal'))->toBe('internal');
});

it('ignores explicit runtime selection for channels outside the principal channel set', function (): void {
    $manager = new release_manager();
    $chooseRuntimeChannel = new ReflectionMethod(release_manager::class, 'chooseRuntimeChannel');

    $stable = [
        'id' => 1,
        'slug' => 'stable',
        'enabled' => 1,
        'default_channel' => 1,
    ];
    $internal = [
        'id' => 4,
        'slug' => 'internal',
        'name' => 'Internal',
        'enabled' => 1,
        'default_channel' => 0,
    ];

    expect($chooseRuntimeChannel->invoke($manager, $stable, [$stable], 'internal'))->toBe($stable);
    expect($chooseRuntimeChannel->invoke($manager, $stable, [$stable, $internal], 'internal'))->toBe($internal);
});

it('requires non-default release channel runtime URLs and preserves load balancer paths', function (): void {
    $manager = new release_manager();
    $availability = new ReflectionMethod(release_manager::class, 'channelAvailability');
    $runtimeUrls = new ReflectionMethod(release_manager::class, 'releaseRuntimeUrls');
    $publicUrl = new ReflectionMethod(release_manager::class, 'releaseCoolifyPublicUrl');
    $targetPublicBaseUrl = new ReflectionMethod(release_manager::class, 'releaseTargetPublicBaseUrl');
    $applicationLabels = new ReflectionMethod(release_manager::class, 'releaseCoolifyApplicationLabels');

    expect($availability->invoke($manager, [
        'id' => 1,
        'slug' => 'stable',
        'default_channel' => 1,
        'frontend_base_url' => null,
        'api_base_url' => null,
    ]))->toMatchArray([
        'configured' => true,
        'missing' => [],
        'status' => 'ready',
    ]);

    expect($runtimeUrls->invoke($manager, [
        'id' => 2,
        'slug' => 'canary',
        'default_channel' => 0,
        'frontend_base_url' => null,
        'api_base_url' => null,
    ], [
        'frontend' => ['deployed_url' => 'https://api-v2.truckwash.io/canary/frontend/health'],
        'api' => ['deployed_url' => 'https://api-v2.truckwash.io/canary/api/ping'],
    ]))->toBe([
        'frontend_base_url' => 'https://api-v2.truckwash.io/canary/frontend',
        'api_base_url' => 'https://api-v2.truckwash.io/canary/api',
    ]);

    expect($runtimeUrls->invoke($manager, [
        'id' => 3,
        'slug' => 'internal',
        'default_channel' => 0,
        'frontend_base_url' => null,
        'api_base_url' => null,
    ], [
        'frontend' => ['deployed_url' => null],
        'api' => ['deployed_url' => null],
        'service_set' => [
            'targets' => [
                'frontend' => [
                    'app' => 'frontend',
                    'channel_slug' => 'internal',
                    'deploy_context' => [
                        'coolify_public_url' => 'https://api-v2.truckwash.io',
                    ],
                ],
                'api' => [
                    'app' => 'api',
                    'channel_slug' => 'internal',
                    'deploy_context' => [
                        'coolify_domain' => 'api-v2.truckwash.io',
                    ],
                ],
            ],
        ],
    ]))->toBe([
        'frontend_base_url' => 'https://api-v2.truckwash.io/internal/frontend',
        'api_base_url' => 'https://api-v2.truckwash.io/internal/api',
    ]);

    expect($publicUrl->invoke($manager, ['app' => 'frontend', 'channel_slug' => 'canary'], [
        'coolify_public_url' => 'https://api-v2.truckwash.io/canary/frontend',
        'coolify_enable_ssl' => true,
    ]))->toBe('https://api-v2.truckwash.io/canary/frontend');

    expect($publicUrl->invoke($manager, ['app' => 'frontend', 'channel_slug' => 'internal'], [
        'coolify_public_url' => 'https://api-v2.truckwash.io',
        'coolify_enable_ssl' => true,
    ]))->toBe('https://api-v2.truckwash.io/internal/frontend');

    expect($publicUrl->invoke($manager, ['app' => 'api', 'channel_slug' => 'internal'], [
        'coolify_domain' => 'api-v2.truckwash.io',
        'coolify_enable_ssl' => true,
    ]))->toBe('https://api-v2.truckwash.io/internal/api');

    expect($targetPublicBaseUrl->invoke($manager, [
        'app' => 'frontend',
        'channel_slug' => 'internal',
        'deploy_context_json' => json_encode([
            'coolify_public_url' => 'https://api-v2.truckwash.io',
        ]),
    ]))->toBe('https://api-v2.truckwash.io/internal/frontend');

    $labels = $applicationLabels->invoke(
        null,
        'https://api-v2.truckwash.io/internal/api',
        'release-api-internal',
        80,
        'letsencrypt'
    );
    expect($labels)->toContain('traefik.http.routers.https-0-release-api-internal.rule=Host(`api-v2.truckwash.io`) && PathPrefix(`/internal/api`)');
    expect($labels)->toContain('traefik.http.routers.https-0-release-api-internal.priority=1013');
    expect($labels)->toContain('traefik.http.middlewares.https-0-release-api-internal-stripprefix.stripprefix.prefixes=/internal/api');
    expect($labels)->toContain('traefik.http.routers.https-0-release-api-internal.middlewares=https-0-release-api-internal-stripprefix,gzip');

    $source = file(app_path('classes/release_manager.php'));
    $methodSource = implode('', array_slice(
        $source,
        $availability->getStartLine() - 1,
        $availability->getEndLine() - $availability->getStartLine() + 1
    ));

    expect($methodSource)->toContain('frontend_base_url');
    expect($methodSource)->toContain('api_base_url');
    expect($methodSource)->not->toContain("missing[] = 'release_bundle'");
    expect($methodSource)->toContain('frontend_version');
    expect($methodSource)->toContain('api_version');
});

it('resolves release deployment endpoints from manual overrides, URLs, health checks, and gateway defaults', function (): void {
    $manager = new release_manager();
    $endpoint = new ReflectionMethod(release_manager::class, 'releaseDeploymentEndpoint');
    $publicUrl = new ReflectionMethod(release_manager::class, 'releaseCoolifyPublicUrl');
    $targetPublicBaseUrl = new ReflectionMethod(release_manager::class, 'releaseTargetPublicBaseUrl');

    $manual = $endpoint->invoke($manager, [
        'app' => 'api',
        'channel_slug' => 'beta',
        'deploy_context' => [
            'endpoint_mode' => 'manual',
            'manual_endpoint_host' => 'manual-api.example.test',
            'manual_endpoint_port' => '8443',
        ],
    ]);

    expect($manual)->toMatchArray([
        'mode' => 'manual',
        'status' => 'resolved',
        'host' => 'manual-api.example.test',
        'port' => 8443,
        'url' => 'https://manual-api.example.test:8443',
        'source' => 'manual',
    ]);
    expect($publicUrl->invoke($manager, ['app' => 'api', 'channel_slug' => 'beta'], [
        'endpoint_mode' => 'manual',
        'manual_endpoint_host' => 'manual-api.example.test',
        'manual_endpoint_port' => '8443',
    ]))->toBe('https://manual-api.example.test:8443');

    $fromPublicUrl = $endpoint->invoke($manager, [
        'app' => 'frontend',
        'channel_slug' => 'internal',
        'deploy_context' => [
            'endpoint_mode' => 'auto',
            'coolify_public_url' => 'https://api-v2.truckwash.io',
        ],
    ]);
    expect($fromPublicUrl)->toMatchArray([
        'mode' => 'auto',
        'status' => 'resolved',
        'host' => 'api-v2.truckwash.io',
        'port' => 443,
        'url' => 'https://api-v2.truckwash.io/internal/frontend',
        'source' => 'coolify_public_url',
    ]);

    $fromHealth = $endpoint->invoke($manager, [
        'app' => 'api',
        'channel_slug' => 'canary',
        'health_url' => 'https://health.example.test/canary/api/ping',
        'deploy_context' => [
            'endpoint_mode' => 'auto',
            'coolify_domain' => 'domain.example.test',
        ],
    ]);
    expect($fromHealth)->toMatchArray([
        'status' => 'resolved',
        'host' => 'health.example.test',
        'url' => 'https://health.example.test/canary/api',
        'source' => 'health_url',
    ]);

    $gateway = $endpoint->invoke($manager, [
        'app' => 'frontend',
        'channel_slug' => 'beta',
        'deploy_context_json' => json_encode([
            'endpoint_mode' => 'auto',
            'public_gateway_host' => 'gateway.example.test',
        ]),
    ]);
    expect($gateway)->toMatchArray([
        'mode' => 'auto',
        'status' => 'pending',
        'host' => 'gateway.example.test',
        'port' => 443,
        'url' => 'https://gateway.example.test/beta/frontend',
        'source' => 'auto_gateway',
    ]);
    expect($targetPublicBaseUrl->invoke($manager, [
        'app' => 'frontend',
        'channel_slug' => 'beta',
        'deploy_context_json' => json_encode([
            'endpoint_mode' => 'auto',
            'public_gateway_host' => 'gateway.example.test',
        ]),
    ]))->toBe('https://gateway.example.test/beta/frontend');

    $cron = $endpoint->invoke($manager, [
        'app' => 'cron',
        'channel_slug' => 'beta',
        'deploy_context_json' => json_encode([
            'endpoint_mode' => 'auto',
            'public_gateway_host' => 'gateway.example.test',
            'coolify_public_url' => 'https://api-v2.truckwash.io',
        ]),
    ]);
    expect($cron)->toMatchArray([
        'status' => 'pending',
        'host' => null,
        'url' => null,
        'source' => 'private_worker',
    ]);
    expect($publicUrl->invoke($manager, ['app' => 'cron', 'channel_slug' => 'beta'], [
        'public_gateway_host' => 'gateway.example.test',
    ]))->toBeNull();
    expect($targetPublicBaseUrl->invoke($manager, [
        'app' => 'cron',
        'channel_slug' => 'beta',
        'deploy_context_json' => json_encode([
            'public_gateway_host' => 'gateway.example.test',
        ]),
    ]))->toBeNull();
});

it('collects previous Coolify application UUIDs for stale route cleanup', function (): void {
    $manager = new release_manager();
    $previousApplications = new ReflectionMethod(release_manager::class, 'releaseCoolifyPreviousApplicationUuids');

    expect($previousApplications->invoke($manager, [
        'coolify_previous_artifact_app_uuid' => 'old-artifact-app',
        'coolify_previous_application_uuid' => 'old-application',
        'coolify_previous_application_uuids' => [
            'old-application',
            'active-application',
            'other-old-application',
            '',
        ],
    ], 'active-application'))->toBe([
        'old-application',
        'old-artifact-app',
        'other-old-application',
    ]);
});

it('deletes only generated API commit env rows before Coolify application env updates', function (): void {
    $manager = new release_manager();
    $deleteCommitEnvs = new ReflectionMethod(release_manager::class, 'deleteCoolifyGeneratedCommitEnvs');
    $client = new ReleaseManagerCoolifyEnvFake([
        ['uuid' => 'api-commit', 'key' => 'API_COMMIT_SHA'],
        ['uuid' => 'commit', 'key' => 'COMMIT_SHA'],
        ['uuid' => 'github', 'key' => 'GITHUB_SHA'],
        ['uuid' => 'release', 'key' => 'RELEASE_COMMIT_SHA'],
        ['uuid' => 'frontend-source', 'key' => 'SOURCE_COMMIT'],
        ['uuid' => 'secret', 'key' => 'CONFIG_DB_PASSWORD'],
    ]);

    $deleteCommitEnvs->invoke($manager, $client, 'application-uuid', ['app' => 'api'], []);

    expect($client->deleted)->toBe([
        ['application-uuid', 'api-commit'],
        ['application-uuid', 'commit'],
        ['application-uuid', 'github'],
        ['application-uuid', 'release'],
    ]);
});

it('waits for an in-flight automatic sync instead of passing the retry immediately', function (): void {
    $manager = file_get_contents(app_path('classes/release_manager.php'));

    expect($manager)->toContain('return $this->waitForReleaseAutoSyncEventResult($eventId, $channelId, $app, $commitSha, $gateInput);')
        ->and($manager)->toContain('Automatic container update is already being processed for event %d but did not finish');
});

it('redacts GitHub access metadata from public release versions', function (): void {
    $manager = new release_manager();
    $publicVersion = new ReflectionMethod(release_manager::class, 'publicVersion');

    $version = $publicVersion->invoke($manager, [
        'id' => 12,
        'app' => 'frontend',
        'repository' => 'truckwash/front-end-vue',
        'branch' => 'release/canary',
        'commit_sha' => 'c0ffee0000001111222233334444555566667777',
        'tag' => null,
        'version_label' => 'frontend-canary',
        'build_url' => null,
        'artifact_url' => null,
        'deployed_url' => null,
        'status' => 'active',
        'metadata_json' => json_encode([
            'commit_mode' => 'specific',
            'github_access' => [
                'commit' => [
                    'sha' => 'c0ffee0000001111222233334444555566667777',
                    'message' => 'Private implementation detail',
                    'author_name' => 'Release Bot',
                    'authored_at' => '2026-05-19T10:15:00Z',
                ],
                'commit_authored_at' => '2026-05-19T10:15:00Z',
            ],
            'bundle_member' => true,
        ]),
        'created_at' => '2026-05-19 10:10:00',
        'deployed_at' => '2026-05-19 10:20:00',
    ]);

    expect($version['commit_sha'])->toBe('c0ffee0000001111222233334444555566667777');
    expect($version['commit'])->toBeNull();
    expect($version['commit_authored_at'])->toBeNull();
    expect($version['metadata'])->toBe([
        'commit_mode' => 'specific',
        'bundle_member' => true,
    ]);
    expect($version['deployed_at'])->toBe('2026-05-19 10:20:00');
});

it('only chooses requested runtime channels from channels available to the principal', function (): void {
    $manager = new release_manager();
    $choose = new ReflectionMethod(release_manager::class, 'chooseRuntimeChannel');

    $stable = [
        'id' => 1,
        'slug' => 'stable',
        'name' => 'Stable',
        'default_channel' => 1,
    ];
    $canary = [
        'id' => 2,
        'slug' => 'canary',
        'name' => 'Canary',
        'default_channel' => 0,
    ];
    $internal = [
        'id' => 3,
        'slug' => 'internal',
        'name' => 'Internal',
        'default_channel' => 0,
        'enabled' => 1,
    ];

    expect($choose->invoke($manager, $stable, [$stable, $canary], 'canary'))->toBe($canary);
    expect($choose->invoke($manager, $stable, [$stable, $canary], 'stable'))->toBe($stable);
    expect($choose->invoke($manager, $stable, [$stable, $canary], 'internal'))->toBe($stable);
    expect($choose->invoke($manager, $stable, [$stable, $canary], 'unknown'))->toBe($stable);
    expect($choose->invoke($manager, $canary, [$stable, $canary], ''))->toBe($canary);
    expect($internal['enabled'])->toBe(1);
});

it('normalizes release assignment subject suggestions without leaking private fields', function (): void {
    $suggestion = release_manager::publicAssignmentSubjectSuggestion([
        'subject_type' => 'USER',
        'subject_id' => 42,
        'title' => ' Dispatcher ',
        'description' => 'Customer #424242 / dispatcher@example.test',
        'icon' => 'fas fa-user',
        'source' => 'users',
        'password' => 'secret',
        'two_factor_secret' => 'private',
    ]);

    expect($suggestion)->toBe([
        'subject_type' => 'user',
        'subject_id' => '42',
        'label' => 'Dispatcher - Customer #424242 / dispatcher@example.test',
        'title' => 'Dispatcher',
        'description' => 'Customer #424242 / dispatcher@example.test',
        'icon' => 'fas fa-user',
        'source' => 'users',
    ]);
    expect(array_keys($suggestion))->not->toContain('password');
    expect(array_keys($suggestion))->not->toContain('two_factor_secret');
    expect(release_manager::publicAssignmentSubjectSuggestion([
        'subject_type' => 'invalid',
        'subject_id' => 42,
        'title' => 'Invalid',
    ]))->toBeNull();
});

it('restricts release gate fetches to Truckwash release hosts and relative paths', function (): void {
    $manager = new release_manager();

    $joinUrl = new ReflectionMethod(release_manager::class, 'releaseGateJoinUrl');
    $hostAllowed = new ReflectionMethod(release_manager::class, 'releaseGateFetchHostAllowed');
    $publicIpAllowed = new ReflectionMethod(release_manager::class, 'releaseGatePublicIpAllowed');
    $stringArray = new ReflectionMethod(release_manager::class, 'releaseGateStringArray');

    expect($joinUrl->invoke($manager, 'https://api-v2.truckwash.io', '/master/api/ping'))
        ->toBe('https://api-v2.truckwash.io/master/api/ping')
        ->and($hostAllowed->invoke($manager, 'api-v2.truckwash.io'))->toBeTrue()
        ->and($hostAllowed->invoke($manager, 'assets.canary.truckwash.io'))->toBeTrue()
        ->and($hostAllowed->invoke($manager, 'truckwash.io.evil.test'))->toBeFalse()
        ->and($hostAllowed->invoke($manager, '127.0.0.1'))->toBeFalse()
        ->and($publicIpAllowed->invoke($manager, '8.8.8.8'))->toBeTrue()
        ->and($publicIpAllowed->invoke($manager, '127.0.0.1'))->toBeFalse()
        ->and($publicIpAllowed->invoke($manager, '10.0.0.5'))->toBeFalse()
        ->and($publicIpAllowed->invoke($manager, '169.254.169.254'))->toBeFalse()
        ->and($publicIpAllowed->invoke($manager, '::1'))->toBeFalse()
        ->and($stringArray->invoke($manager, ['/a', '/b', '/c'], 2))->toBe(['/a', '/b']);

    expect(fn() => $joinUrl->invoke($manager, 'https://api-v2.truckwash.io', 'http://127.0.0.1/ping'))
        ->toThrow(RuntimeException::class, 'relative');
    expect(fn() => $joinUrl->invoke($manager, 'https://api-v2.truckwash.io', '//127.0.0.1/ping'))
        ->toThrow(RuntimeException::class, 'relative');
});

it('normalizes Coolify application list payloads for release cleanup previews', function (): void {
    $manager = new release_manager();
    $payloadRows = new ReflectionMethod(release_manager::class, 'payloadRows');

    expect($payloadRows->invoke($manager, [
        'applications' => [
            ['uuid' => 'app-1', 'name' => 'release-canary-api'],
            ['uuid' => 'app-2', 'name' => 'release-old-frontend'],
        ],
    ]))->toBe([
        ['uuid' => 'app-1', 'name' => 'release-canary-api'],
        ['uuid' => 'app-2', 'name' => 'release-old-frontend'],
    ]);
});

it('keeps Coolify cleanup selection hashes tied to action and resource identity', function (): void {
    $manager = new release_manager();
    $hash = new ReflectionMethod(release_manager::class, 'coolifyCleanupSelectionHash');
    $phrase = new ReflectionMethod(release_manager::class, 'coolifyCleanupConfirmationPhrase');
    $matches = new ReflectionMethod(release_manager::class, 'coolifyCleanupReferenceMatchesPolicy');

    $policy = [
        'instance_id' => 3,
        'channel_id' => 2,
        'channel_slug' => 'canary',
        'app' => 'api',
        'resource_type' => 'application',
        'action' => 'delete',
    ];
    $candidate = [
        'instance_id' => 3,
        'type' => 'application',
        'uuid' => 'old-api-canary-app',
        'action' => 'delete',
    ];
    $reference = [
        'apps' => ['api' => 'api'],
        'channels' => [2 => 2],
        'resource_types' => ['application' => 'application'],
    ];

    $deleteHash = $hash->invoke($manager, $policy, [$candidate]);
    $stopHash = $hash->invoke($manager, array_replace($policy, ['action' => 'stop']), [
        array_replace($candidate, ['action' => 'stop']),
    ]);

    expect($matches->invoke($manager, $reference, $policy))->toBeTrue()
        ->and($matches->invoke($manager, $reference, array_replace($policy, ['app' => 'frontend'])))->toBeFalse()
        ->and($matches->invoke($manager, $reference, array_replace($policy, ['channel_id' => 99])))->toBeFalse()
        ->and($deleteHash)->not->toBe($stopHash)
        ->and($phrase->invoke($manager, $deleteHash))->toBe('cleanup-coolify-' . substr($deleteHash, 0, 12));
});
