<?php

use App\Mcp\Concerns\BuildsResponse;
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use App\Models\ApplicationPreview;
use App\Models\Environment;
use App\Models\EnvironmentVariable;
use App\Models\GithubApp;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\ScheduledDatabaseBackup;
use App\Models\ScheduledDatabaseBackupExecution;
use App\Models\ScheduledTask;
use App\Models\ScheduledTaskExecution;
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\ServiceDatabase;
use App\Models\SharedEnvironmentVariable;
use App\Models\StandaloneDocker;
use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql;
use App\Models\SwarmDocker;
use App\Models\Tag;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Str;

uses(RefreshDatabase::class);

beforeEach(function () {
    InstanceSettings::query()->where('id', 0)->delete();
    InstanceSettings::query()->delete();
    $settings = new InstanceSettings(['is_mcp_server_enabled' => true]);
    $settings->id = 0;
    $settings->save();

    $this->team = Team::factory()->create();
    $this->user = User::factory()->create();
    $this->team->members()->attach($this->user->id, ['role' => 'owner']);
    session(['currentTeam' => $this->team]);

    $this->server = Server::factory()->create(['team_id' => $this->team->id]);
    // Server::created auto-provisions a default StandaloneDocker (network=coolify).
    $this->destination = StandaloneDocker::query()->where('server_id', $this->server->id)->firstOrFail();
    $this->project = Project::factory()->create(['team_id' => $this->team->id]);
    // Project::created auto-creates a production environment.
    $this->environment = $this->project->environments()->first()
        ?? Environment::factory()->create(['project_id' => $this->project->id]);
    $this->application = Application::factory()->create([
        'environment_id' => $this->environment->id,
        'destination_id' => $this->destination->id,
        'destination_type' => $this->destination->getMorphClass(),
        'fqdn' => 'https://app.example.com',
    ]);
});

function mcpReadToken(): string
{
    return test()->user->createToken('mcp-read', ['read'])->plainTextToken;
}

function mcpReadCall(string $name, array $arguments = [])
{
    // Ensure each call resolves the Bearer token freshly (no guard bleed between tokens).
    auth()->forgetGuards();

    return test()->withHeaders([
        'Content-Type' => 'application/json',
        'Accept' => 'application/json, text/event-stream',
        'Authorization' => 'Bearer '.mcpReadToken(),
    ])->postJson('/mcp', [
        'jsonrpc' => '2.0',
        'id' => 1,
        'method' => 'tools/call',
        'params' => [
            'name' => $name,
            'arguments' => (object) $arguments,
        ],
    ]);
}

function mcpSensitiveReadCall(string $name, array $arguments = [])
{
    // Ensure each call resolves the Bearer token freshly (no guard bleed between tokens).
    auth()->forgetGuards();

    $token = test()->user->createToken('mcp-sensitive-read', ['read', 'read:sensitive'])->plainTextToken;

    return test()->withHeaders([
        'Content-Type' => 'application/json',
        'Accept' => 'application/json, text/event-stream',
        'Authorization' => 'Bearer '.$token,
    ])->postJson('/mcp', [
        'jsonrpc' => '2.0',
        'id' => 1,
        'method' => 'tools/call',
        'params' => [
            'name' => $name,
            'arguments' => (object) $arguments,
        ],
    ]);
}

function mcpReadJson($response): array
{
    return json_decode($response->json('result.content.0.text'), true);
}

test('tools/list includes new read tools and lifecycle tools', function () {
    $token = mcpReadToken();
    $response = test()->withHeaders([
        'Content-Type' => 'application/json',
        'Accept' => 'application/json, text/event-stream',
        'Authorization' => 'Bearer '.$token,
    ])->postJson('/mcp', [
        'jsonrpc' => '2.0',
        'id' => 1,
        'method' => 'tools/list',
        'params' => (object) [],
    ]);

    $response->assertOk();
    $names = collect($response->json('result.tools'))->pluck('name')->all();

    expect($names)->toContain(
        'get_project',
        'get_environment',
        'list_resources',
        'list_deployments',
        'get_deployment',
        'get_logs',
        'list_env_keys',
        'list_storages',
        'list_destinations',
        'get_destination',
        'get_server_domains',
        'get_server_resources',
        'list_tags',
        'list_github_apps',
        'get_current_team',
        'list_team_members',
        'list_database_backups',
        'list_service_applications',
        'list_service_databases',
        'search_resources',
        'list_unhealthy_resources',
        'list_application_previews',
        'list_shared_env_keys',
        'coolify_help',
        'control',
        'deploy',
        'cancel_deployment',
    );
});

test('database backup tools scope schedules by database type and id', function () {
    $postgres = StandalonePostgresql::create([
        'name' => 'postgres',
        'postgres_password' => 'password',
        'environment_id' => $this->environment->id,
        'destination_id' => $this->destination->id,
        'destination_type' => $this->destination->getMorphClass(),
    ]);
    $mysql = StandaloneMysql::create([
        'name' => 'mysql',
        'mysql_root_password' => 'password',
        'mysql_password' => 'password',
        'environment_id' => $this->environment->id,
        'destination_id' => $this->destination->id,
        'destination_type' => $this->destination->getMorphClass(),
    ]);

    expect($mysql->id)->toBe($postgres->id);

    $postgresBackup = ScheduledDatabaseBackup::create([
        'team_id' => $this->team->id,
        'frequency' => '0 0 * * *',
        'database_id' => $postgres->id,
        'database_type' => $postgres->getMorphClass(),
    ]);
    $mysqlBackup = ScheduledDatabaseBackup::create([
        'team_id' => $this->team->id,
        'frequency' => '0 0 * * *',
        'database_id' => $mysql->id,
        'database_type' => $mysql->getMorphClass(),
    ]);

    $response = mcpReadCall('list_database_backups', ['uuid' => $postgres->uuid]);
    $response->assertOk();

    $backupUuids = collect(mcpReadJson($response)['data']['backups'])->pluck('uuid');
    expect($backupUuids)
        ->toContain($postgresBackup->uuid)
        ->not->toContain($mysqlBackup->uuid);

    $response = mcpReadCall('list_backup_executions', [
        'database_uuid' => $postgres->uuid,
        'scheduled_backup_uuid' => $mysqlBackup->uuid,
    ]);
    $response->assertOk();
    expect($response->json('result.isError'))->toBeTrue();
});

test('list_backup_executions omits messages without sensitive read and redacts when included', function () {
    $postgres = StandalonePostgresql::create([
        'name' => 'backup-msg-db',
        'postgres_password' => 'password',
        'environment_id' => $this->environment->id,
        'destination_id' => $this->destination->id,
        'destination_type' => $this->destination->getMorphClass(),
    ]);
    $backup = ScheduledDatabaseBackup::create([
        'team_id' => $this->team->id,
        'frequency' => '0 0 * * *',
        'database_id' => $postgres->id,
        'database_type' => $postgres->getMorphClass(),
    ]);

    $older = ScheduledDatabaseBackupExecution::create([
        'uuid' => (string) Str::uuid(),
        'scheduled_database_backup_id' => $backup->id,
        'status' => 'success',
        'message' => 'older backup ok',
        'size' => 100,
        'filename' => 'old.sql.gz',
        'created_at' => now()->subHours(2),
        'updated_at' => now()->subHours(2),
    ]);
    $newer = ScheduledDatabaseBackupExecution::create([
        'uuid' => (string) Str::uuid(),
        'scheduled_database_backup_id' => $backup->id,
        'status' => 'failed',
        'message' => "backup failed password=redactme01\n",
        'size' => 0,
        'filename' => 'new.sql.gz',
        'created_at' => now()->subHour(),
        'updated_at' => now()->subHour(),
    ]);

    $readOnly = mcpReadCall('list_backup_executions', [
        'database_uuid' => $postgres->uuid,
        'scheduled_backup_uuid' => $backup->uuid,
        'page' => 1,
        'per_page' => 1,
    ]);
    $readOnly->assertOk();
    $readBody = mcpReadJson($readOnly);
    expect($readBody['data']['message_included'])->toBeFalse()
        ->and($readBody['data']['executions'])->toHaveCount(1)
        ->and($readBody['data']['executions'][0]['status'])->toBe('failed')
        ->and($readBody['data']['executions'][0]['filename'])->toBe('new.sql.gz')
        ->and($readBody['data']['executions'][0])->not->toHaveKey('message')
        ->and($readBody['_pagination']['total'])->toBe(2);

    $page2 = mcpReadCall('list_backup_executions', [
        'database_uuid' => $postgres->uuid,
        'scheduled_backup_uuid' => $backup->uuid,
        'page' => 2,
        'per_page' => 1,
    ]);
    $page2->assertOk();
    expect(mcpReadJson($page2)['data']['executions'][0]['status'])->toBe('success');

    $sensitive = mcpSensitiveReadCall('list_backup_executions', [
        'database_uuid' => $postgres->uuid,
        'scheduled_backup_uuid' => $backup->uuid,
        'page' => 1,
        'per_page' => 1,
    ]);
    $sensitive->assertOk();
    $sensitiveBody = mcpReadJson($sensitive);
    $message = $sensitiveBody['data']['executions'][0]['message'] ?? '';
    expect($sensitiveBody['data']['message_included'])->toBeTrue()
        ->and($message)->not->toContain('redactme01')
        ->and($message)->toContain('password=')
        ->and($message)->toContain(REDACTED);

    expect($older->id)->not->toBe($newer->id);
});

test('get_project returns environments and counts for team project only', function () {
    $otherTeam = Team::factory()->create();
    $otherProject = Project::factory()->create(['team_id' => $otherTeam->id]);

    $response = mcpReadCall('get_project', ['uuid' => $this->project->uuid]);
    $response->assertOk();
    $body = mcpReadJson($response);

    expect($body['data']['uuid'])->toBe($this->project->uuid);
    expect($body['data']['counts']['applications'])->toBeGreaterThanOrEqual(1);
    expect($body['data']['environments'])->not->toBeEmpty();

    $denied = mcpReadCall('get_project', ['uuid' => $otherProject->uuid]);
    expect($denied->json('result.isError'))->toBeTrue();
});

test('get_environment is team scoped via project', function () {
    $response = mcpReadCall('get_environment', [
        'project_uuid' => $this->project->uuid,
        'environment_name_or_uuid' => $this->environment->name,
    ]);
    $response->assertOk();
    $body = mcpReadJson($response);

    expect($body['data']['uuid'])->toBe($this->environment->uuid);
    expect(collect($body['data']['applications'])->pluck('uuid'))->toContain($this->application->uuid);
    expect($body['data']['counts']['applications'])->toBeGreaterThanOrEqual(1);
    expect($body['data']['truncated']['applications'])->toBeFalse();

    $otherTeam = Team::factory()->create();
    $otherProject = Project::factory()->create(['team_id' => $otherTeam->id]);
    $otherEnv = Environment::factory()->create(['project_id' => $otherProject->id]);

    $denied = mcpReadCall('get_environment', [
        'project_uuid' => $otherProject->uuid,
        'environment_name_or_uuid' => $otherEnv->uuid,
    ]);
    expect($denied->json('result.isError'))->toBeTrue();
});

test('get_environment caps resource samples and points to list tools when truncated', function () {
    foreach (['env-app-a', 'env-app-b', 'env-app-c'] as $name) {
        Application::factory()->create([
            'name' => $name,
            'environment_id' => $this->environment->id,
            'destination_id' => $this->destination->id,
            'destination_type' => $this->destination->getMorphClass(),
        ]);
    }

    $response = mcpReadCall('get_environment', [
        'project_uuid' => $this->project->uuid,
        'environment_name_or_uuid' => $this->environment->uuid,
        'sample_per_type' => 2,
    ]);
    $response->assertOk();
    $body = mcpReadJson($response);

    // beforeEach already has one application in this environment.
    expect($body['data']['counts']['applications'])->toBeGreaterThanOrEqual(4)
        ->and($body['data']['applications'])->toHaveCount(2)
        ->and($body['data']['truncated']['applications'])->toBeTrue()
        ->and(collect($body['data']['next_tools'])->pluck('tool')->all())->toContain('list_applications');
});

test('list_resources only returns team resources', function () {
    $otherTeam = Team::factory()->create();
    $otherProject = Project::factory()->create(['team_id' => $otherTeam->id]);
    $otherEnv = Environment::factory()->create(['project_id' => $otherProject->id]);
    $otherServer = Server::factory()->create(['team_id' => $otherTeam->id]);
    $otherDest = StandaloneDocker::query()->where('server_id', $otherServer->id)->firstOrFail();
    Application::factory()->create([
        'name' => 'OtherTeamApp',
        'environment_id' => $otherEnv->id,
        'destination_id' => $otherDest->id,
        'destination_type' => $otherDest->getMorphClass(),
    ]);

    $response = mcpReadCall('list_resources');
    $response->assertOk();
    $body = mcpReadJson($response);

    $uuids = collect($body['data'])->pluck('uuid');
    $names = collect($body['data'])->pluck('name');
    expect($uuids)->toContain($this->application->uuid);
    expect($names)->not->toContain('OtherTeamApp');
});

test('list_resources paginates sorts and filters at the query layer', function () {
    $this->application->update(['name' => 'Charlie App']);

    $alphaApp = Application::factory()->create([
        'name' => 'Alpha App',
        'environment_id' => $this->environment->id,
        'destination_id' => $this->destination->id,
        'destination_type' => $this->destination->getMorphClass(),
    ]);
    $bravoService = Service::factory()->create([
        'name' => 'Bravo Service',
        'environment_id' => $this->environment->id,
        'destination_id' => $this->destination->id,
        'destination_type' => $this->destination->getMorphClass(),
    ]);
    $deltaDb = StandalonePostgresql::create([
        'name' => 'Delta DB',
        'postgres_password' => 'password',
        'environment_id' => $this->environment->id,
        'destination_id' => $this->destination->id,
        'destination_type' => $this->destination->getMorphClass(),
    ]);

    $tag = Tag::create([
        'name' => 'mcp-listed',
        'team_id' => $this->team->id,
    ]);
    $alphaApp->tags()->attach($tag->id);
    $bravoService->tags()->attach($tag->id);

    $otherProject = Project::factory()->create(['team_id' => $this->team->id, 'name' => 'Other Project']);
    $otherEnv = $otherProject->environments()->first()
        ?? Environment::factory()->create(['project_id' => $otherProject->id]);
    Application::factory()->create([
        'name' => 'Zed Other Project App',
        'environment_id' => $otherEnv->id,
        'destination_id' => $this->destination->id,
        'destination_type' => $this->destination->getMorphClass(),
    ]);

    $page1 = mcpReadCall('list_resources', ['page' => 1, 'per_page' => 2]);
    $page1->assertOk();
    $body1 = mcpReadJson($page1);
    expect($body1['_pagination']['total'])->toBe(5)
        ->and($body1['_pagination']['per_page'])->toBe(2)
        ->and($body1['_pagination']['page'])->toBe(1)
        ->and(collect($body1['data'])->pluck('name')->all())->toBe(['Alpha App', 'Bravo Service']);

    $page2 = mcpReadCall('list_resources', ['page' => 2, 'per_page' => 2]);
    $page2->assertOk();
    $body2 = mcpReadJson($page2);
    expect(collect($body2['data'])->pluck('name')->all())->toBe(['Charlie App', 'Delta DB']);

    $appsOnly = mcpReadCall('list_resources', ['type' => 'application']);
    $appsOnly->assertOk();
    $appsBody = mcpReadJson($appsOnly);
    expect(collect($appsBody['data'])->pluck('type')->unique()->values()->all())->toBe(['application'])
        ->and($appsBody['_pagination']['total'])->toBe(3);

    $dbsOnly = mcpReadCall('list_resources', ['type' => 'database']);
    $dbsOnly->assertOk();
    $dbsBody = mcpReadJson($dbsOnly);
    expect($dbsBody['_pagination']['total'])->toBe(1)
        ->and($dbsBody['data'][0]['uuid'])->toBe($deltaDb->uuid)
        ->and($dbsBody['data'][0]['type'])->toBe('standalone-postgresql');

    $tagged = mcpReadCall('list_resources', ['tag' => 'mcp-listed']);
    $tagged->assertOk();
    $taggedBody = mcpReadJson($tagged);
    expect(collect($taggedBody['data'])->pluck('uuid')->sort()->values()->all())
        ->toBe(collect([$alphaApp->uuid, $bravoService->uuid])->sort()->values()->all());

    $byProject = mcpReadCall('list_resources', ['project_uuid' => $otherProject->uuid]);
    $byProject->assertOk();
    $projectBody = mcpReadJson($byProject);
    expect($projectBody['_pagination']['total'])->toBe(1)
        ->and($projectBody['data'][0]['name'])->toBe('Zed Other Project App')
        ->and($projectBody['data'][0]['project_uuid'])->toBe($otherProject->uuid);
});

test('list_deployments and get_deployment are team scoped and scrub logs', function () {
    $deployment = ApplicationDeploymentQueue::create([
        'application_id' => $this->application->id,
        'deployment_uuid' => 'dep-'.fake()->uuid(),
        'status' => 'in_progress',
        'server_id' => $this->server->id,
        'application_name' => $this->application->name,
        'server_name' => $this->server->name,
        'commit' => 'abc123',
        'logs' => json_encode([['name' => 'build', 'output' => 'SECRET_TOKEN=redactme01']]),
    ]);

    $list = mcpReadCall('list_deployments');
    $list->assertOk();
    $listBody = mcpReadJson($list);
    expect(collect($listBody['data'])->pluck('deployment_uuid'))->toContain($deployment->deployment_uuid);
    expect(json_encode($listBody))->not->toContain('redactme01');
    expect(json_encode($listBody))->not->toContain('"logs"');

    $get = mcpReadCall('get_deployment', ['uuid' => $deployment->deployment_uuid]);
    $get->assertOk();
    $getBody = mcpReadJson($get);
    expect($getBody['data']['deployment_uuid'])->toBe($deployment->deployment_uuid);
    expect($getBody['data']['application_uuid'])->toBe($this->application->uuid);
    expect(json_encode($getBody))->not->toContain('redactme01');

    $otherTeam = Team::factory()->create();
    $otherServer = Server::factory()->create(['team_id' => $otherTeam->id]);
    $otherProject = Project::factory()->create(['team_id' => $otherTeam->id]);
    $otherEnv = $otherProject->environments()->first()
        ?? Environment::factory()->create(['project_id' => $otherProject->id]);
    $otherDest = StandaloneDocker::query()->where('server_id', $otherServer->id)->firstOrFail();
    $otherApp = Application::factory()->create([
        'environment_id' => $otherEnv->id,
        'destination_id' => $otherDest->id,
        'destination_type' => $otherDest->getMorphClass(),
    ]);
    $otherDep = ApplicationDeploymentQueue::create([
        'application_id' => $otherApp->id,
        'deployment_uuid' => 'dep-other-'.fake()->uuid(),
        'status' => 'in_progress',
        'server_id' => $otherServer->id,
        'application_name' => $otherApp->name,
        'server_name' => $otherServer->name,
    ]);

    $denied = mcpReadCall('get_deployment', ['uuid' => $otherDep->deployment_uuid]);
    expect($denied->json('result.isError'))->toBeTrue();
});

test('deployment listings and overview scope shared server deployments by application team', function () {
    $teamDeployment = ApplicationDeploymentQueue::create([
        'application_id' => $this->application->id,
        'deployment_uuid' => 'dep-team-'.fake()->uuid(),
        'status' => 'in_progress',
        'server_id' => $this->server->id,
        'application_name' => $this->application->name,
        'server_name' => $this->server->name,
    ]);

    $otherTeam = Team::factory()->create();
    $otherProject = Project::factory()->create(['team_id' => $otherTeam->id]);
    $otherEnvironment = $otherProject->environments()->first()
        ?? Environment::factory()->create(['project_id' => $otherProject->id]);
    $otherApplication = Application::factory()->create([
        'environment_id' => $otherEnvironment->id,
        'destination_id' => $this->destination->id,
        'destination_type' => $this->destination->getMorphClass(),
    ]);
    $otherDeployment = ApplicationDeploymentQueue::create([
        'application_id' => $otherApplication->id,
        'deployment_uuid' => 'dep-other-shared-server-'.fake()->uuid(),
        'status' => 'in_progress',
        'server_id' => $this->server->id,
        'application_name' => $otherApplication->name,
        'server_name' => $this->server->name,
    ]);

    $listBody = mcpReadJson(mcpReadCall('list_deployments'));
    expect(collect($listBody['data'])->pluck('deployment_uuid'))
        ->toContain($teamDeployment->deployment_uuid)
        ->not->toContain($otherDeployment->deployment_uuid);

    $overviewBody = mcpReadJson(mcpReadCall('get_infrastructure_overview'));
    expect($overviewBody['data']['counts']['open_deployments'])->toBe(1);
});

test('list_env_keys never returns values and is team scoped', function () {
    EnvironmentVariable::create([
        'key' => 'DATABASE_URL',
        'value' => 'postgres://secret@localhost/db',
        'resourceable_type' => Application::class,
        'resourceable_id' => $this->application->id,
        'is_preview' => false,
    ]);

    $response = mcpReadCall('list_env_keys', [
        'resource' => 'application',
        'uuid' => $this->application->uuid,
    ]);
    $response->assertOk();
    $body = mcpReadJson($response);
    $raw = json_encode($body);

    expect(collect($body['data']['keys'])->pluck('key'))->toContain('DATABASE_URL');
    expect($raw)->not->toContain('postgres://secret');
    expect($raw)->not->toContain('"value"');
    expect($raw)->not->toContain('real_value');

    $otherTeam = Team::factory()->create();
    $otherProject = Project::factory()->create(['team_id' => $otherTeam->id]);
    $otherEnv = $otherProject->environments()->first()
        ?? Environment::factory()->create(['project_id' => $otherProject->id]);
    $otherServer = Server::factory()->create(['team_id' => $otherTeam->id]);
    $otherDest = StandaloneDocker::query()->where('server_id', $otherServer->id)->firstOrFail();
    $otherApp = Application::factory()->create([
        'environment_id' => $otherEnv->id,
        'destination_id' => $otherDest->id,
        'destination_type' => $otherDest->getMorphClass(),
    ]);

    $denied = mcpReadCall('list_env_keys', [
        'resource' => 'application',
        'uuid' => $otherApp->uuid,
    ]);
    expect($denied->json('result.isError'))->toBeTrue();
});

test('list_destinations and get_destination are team scoped', function () {
    $response = mcpReadCall('list_destinations');
    $response->assertOk();
    $body = mcpReadJson($response);
    expect(collect($body['data'])->pluck('uuid'))->toContain($this->destination->uuid);

    $get = mcpReadCall('get_destination', ['uuid' => $this->destination->uuid]);
    $get->assertOk();
    expect(mcpReadJson($get)['data']['uuid'])->toBe($this->destination->uuid);

    $otherTeam = Team::factory()->create();
    $otherServer = Server::factory()->create(['team_id' => $otherTeam->id]);
    $otherDest = StandaloneDocker::query()->where('server_id', $otherServer->id)->firstOrFail();
    $denied = mcpReadCall('get_destination', ['uuid' => $otherDest->uuid]);
    expect($denied->json('result.isError'))->toBeTrue();
});

test('get_server_domains and get_server_resources are team scoped', function () {
    $domains = mcpReadCall('get_server_domains', ['uuid' => $this->server->uuid]);
    $domains->assertOk();
    $domainBody = mcpReadJson($domains);
    expect($domainBody['data']['server_uuid'])->toBe($this->server->uuid);
    expect($domainBody['data']['domains'])->toHaveCount(1);
    expect($domainBody['data']['domains'][0]['resource_uuid'])->toBe($this->application->uuid);
    expect($domainBody['data']['domains'][0]['domains'])->toContain('app.example.com');

    $resources = mcpReadCall('get_server_resources', ['uuid' => $this->server->uuid]);
    $resources->assertOk();

    $otherServer = Server::factory()->create(['team_id' => Team::factory()->create()->id]);
    expect(mcpReadCall('get_server_domains', ['uuid' => $otherServer->uuid])->json('result.isError'))->toBeTrue();
    expect(mcpReadCall('get_server_resources', ['uuid' => $otherServer->uuid])->json('result.isError'))->toBeTrue();
});

test('get_server_domains filters polymorphic destinations by type and id', function () {
    // Other server gets the next standalone_dockers id (typically 2).
    $otherServer = Server::factory()->create(['team_id' => $this->team->id]);
    $otherStandalone = StandaloneDocker::query()->where('server_id', $otherServer->id)->firstOrFail();

    // Swarm on this server with the same numeric id as the other server's
    // standalone docker. Untyped whereIn(destination_id) would merge that id
    // and wrongly attribute the other server's app to this server.
    DB::table('swarm_dockers')->insert([
        'id' => $otherStandalone->id,
        'uuid' => (string) Str::uuid(),
        'server_id' => $this->server->id,
        'name' => 'swarm-network',
        'network' => 'swarm-network',
        'created_at' => now(),
        'updated_at' => now(),
    ]);
    $swarmOnThisServer = SwarmDocker::query()->findOrFail($otherStandalone->id);

    expect($swarmOnThisServer->id)->toBe($otherStandalone->id);
    expect($this->destination->id)->not->toBe($otherStandalone->id);

    $swarmApp = Application::factory()->create([
        'environment_id' => $this->environment->id,
        'destination_id' => $swarmOnThisServer->id,
        'destination_type' => SwarmDocker::class,
        'fqdn' => 'https://swarm.example.com',
        'name' => 'swarm-app',
    ]);

    $otherServerApp = Application::factory()->create([
        'environment_id' => $this->environment->id,
        'destination_id' => $otherStandalone->id,
        'destination_type' => StandaloneDocker::class,
        'fqdn' => 'https://other-server.example.com',
        'name' => 'other-server-app',
    ]);

    $domains = mcpReadCall('get_server_domains', ['uuid' => $this->server->uuid]);
    $domains->assertOk();
    $domainBody = mcpReadJson($domains);
    $resourceUuids = collect($domainBody['data']['domains'])->pluck('resource_uuid');

    expect($resourceUuids)->toContain($this->application->uuid);
    expect($resourceUuids)->toContain($swarmApp->uuid);
    expect($resourceUuids)->not->toContain($otherServerApp->uuid);
});

test('list_applications list_databases list_services server_uuid filters use destination type', function () {
    // Other server gets the next standalone_dockers id (typically 2).
    $otherServer = Server::factory()->create(['team_id' => $this->team->id]);
    $otherStandalone = StandaloneDocker::query()->where('server_id', $otherServer->id)->firstOrFail();

    // Swarm on this server with the same numeric id as the other server's standalone docker.
    DB::table('swarm_dockers')->insert([
        'id' => $otherStandalone->id,
        'uuid' => (string) Str::uuid(),
        'server_id' => $this->server->id,
        'name' => 'swarm-network-list-filter',
        'network' => 'swarm-network-list-filter',
        'created_at' => now(),
        'updated_at' => now(),
    ]);
    $swarmOnThisServer = SwarmDocker::query()->findOrFail($otherStandalone->id);
    expect($swarmOnThisServer->id)->toBe($otherStandalone->id);

    $swarmApp = Application::factory()->create([
        'environment_id' => $this->environment->id,
        'destination_id' => $swarmOnThisServer->id,
        'destination_type' => SwarmDocker::class,
        'name' => 'swarm-list-app',
    ]);
    $otherServerApp = Application::factory()->create([
        'environment_id' => $this->environment->id,
        'destination_id' => $otherStandalone->id,
        'destination_type' => StandaloneDocker::class,
        'name' => 'other-server-list-app',
    ]);

    $swarmDb = StandalonePostgresql::create([
        'name' => 'swarm-list-db',
        'status' => 'running:healthy',
        'postgres_password' => 'password',
        'environment_id' => $this->environment->id,
        'destination_id' => $swarmOnThisServer->id,
        'destination_type' => SwarmDocker::class,
    ]);
    $otherServerDb = StandalonePostgresql::create([
        'name' => 'other-server-list-db',
        'status' => 'running:healthy',
        'postgres_password' => 'password',
        'environment_id' => $this->environment->id,
        'destination_id' => $otherStandalone->id,
        'destination_type' => StandaloneDocker::class,
    ]);

    $swarmService = Service::factory()->create([
        'name' => 'swarm-list-svc',
        'environment_id' => $this->environment->id,
        'server_id' => null,
        'destination_id' => $swarmOnThisServer->id,
        'destination_type' => SwarmDocker::class,
    ]);
    $otherServerService = Service::factory()->create([
        'name' => 'other-server-list-svc',
        'environment_id' => $this->environment->id,
        'server_id' => null,
        'destination_id' => $otherStandalone->id,
        'destination_type' => StandaloneDocker::class,
    ]);

    $apps = mcpReadCall('list_applications', ['server_uuid' => $this->server->uuid]);
    $apps->assertOk();
    $appUuids = collect(mcpReadJson($apps)['data'])->pluck('uuid');
    expect($appUuids)->toContain($this->application->uuid, $swarmApp->uuid)
        ->not->toContain($otherServerApp->uuid);

    $dbs = mcpReadCall('list_databases', ['server_uuid' => $this->server->uuid]);
    $dbs->assertOk();
    $dbUuids = collect(mcpReadJson($dbs)['data'])->pluck('uuid');
    expect($dbUuids)->toContain($swarmDb->uuid)
        ->not->toContain($otherServerDb->uuid);

    $services = mcpReadCall('list_services', ['server_uuid' => $this->server->uuid]);
    $services->assertOk();
    $serviceUuids = collect(mcpReadJson($services)['data'])->pluck('uuid');
    expect($serviceUuids)->toContain($swarmService->uuid)
        ->not->toContain($otherServerService->uuid);
});

test('list_tags and get_current_team and list_team_members are team scoped', function () {
    Tag::create(['name' => 'prod', 'team_id' => $this->team->id]);
    Tag::create(['name' => 'theirs', 'team_id' => Team::factory()->create()->id]);

    $tags = mcpReadCall('list_tags');
    $tags->assertOk();
    $tagNames = collect(mcpReadJson($tags)['data'])->pluck('name');
    expect($tagNames)->toContain('prod');
    expect($tagNames)->not->toContain('theirs');

    $team = mcpReadCall('get_current_team');
    $team->assertOk();
    expect(mcpReadJson($team)['data']['name'])->toBe($this->team->name);

    $members = mcpReadCall('list_team_members');
    $members->assertOk();
    expect(collect(mcpReadJson($members)['data'])->pluck('email'))->toContain($this->user->email);
});

test('list_github_apps is team scoped and scrubs secrets', function () {
    $app = GithubApp::create([
        'name' => 'Mine',
        'team_id' => $this->team->id,
        'api_url' => 'https://api.github.com',
        'html_url' => 'https://github.com',
        'custom_user' => 'git',
        'custom_port' => 22,
        'app_id' => 1,
        'installation_id' => 1,
        'client_id' => 'client-id',
        'client_secret' => 'super-client-secret',
        'webhook_secret' => 'super-webhook-secret',
        'is_public' => false,
        'is_system_wide' => false,
    ]);

    GithubApp::create([
        'name' => 'Theirs',
        'team_id' => Team::factory()->create()->id,
        'api_url' => 'https://api.github.com',
        'html_url' => 'https://github.com',
        'custom_user' => 'git',
        'custom_port' => 22,
        'app_id' => 2,
        'installation_id' => 2,
        'client_id' => 'other-client',
        'client_secret' => 'other-secret',
        'webhook_secret' => 'other-webhook',
        'is_public' => false,
        'is_system_wide' => false,
    ]);

    $response = mcpReadCall('list_github_apps');
    $response->assertOk();
    $body = mcpReadJson($response);
    $names = collect($body['data'])->pluck('name');
    $raw = json_encode($body);

    expect($names)->toContain('Mine');
    expect($names)->not->toContain('Theirs');
    expect($raw)->not->toContain('super-client-secret');
    expect($raw)->not->toContain('super-webhook-secret');
    expect(collect($body['data'])->pluck('uuid'))->toContain($app->uuid);
});

test('list_github_repositories rejects public github sources cleanly', function () {
    $publicApp = GithubApp::create([
        'name' => 'Public Source',
        'uuid' => 'github-public-test',
        'team_id' => $this->team->id,
        'api_url' => 'https://api.github.com',
        'html_url' => 'https://github.com',
        'custom_user' => 'git',
        'custom_port' => 22,
        'is_public' => true,
        'is_system_wide' => false,
    ]);

    $response = mcpReadCall('list_github_repositories', [
        'github_app_uuid' => $publicApp->uuid,
    ]);
    $response->assertOk();
    expect($response->json('result.isError'))->toBeTrue();
    expect($response->json('result.content.0.text'))
        ->toContain('public or missing app installation credentials')
        ->not->toContain('private_key');
});

test('list_github_branches uses anonymous github api for public sources', function () {
    $publicApp = GithubApp::create([
        'name' => 'Public Source',
        'uuid' => 'github-public-branches',
        'team_id' => $this->team->id,
        'api_url' => 'https://api.github.com',
        'html_url' => 'https://github.com',
        'custom_user' => 'git',
        'custom_port' => 22,
        'is_public' => true,
        'is_system_wide' => false,
    ]);

    Http::fake([
        'https://api.github.com/repos/coollabsio/coolify/branches*' => Http::response([
            ['name' => 'v4.x', 'protected' => true, 'commit' => ['sha' => 'abc123']],
            ['name' => 'next', 'protected' => false, 'commit' => ['sha' => 'def456']],
        ], 200),
    ]);

    $response = mcpReadCall('list_github_branches', [
        'github_app_uuid' => $publicApp->uuid,
        'owner' => 'coollabsio',
        'repo' => 'coolify',
    ]);
    $response->assertOk();
    expect($response->json('result.isError'))->toBeFalse();
    $body = mcpReadJson($response);
    expect(collect($body['data']['branches'])->pluck('name')->all())->toContain('v4.x', 'next');
    expect($body['data']['branches'][0]['commit_sha'])->toBe('abc123');
});

test('list_github_branches rejects private apps missing installation credentials', function () {
    $privateApp = GithubApp::create([
        'name' => 'Incomplete Private App',
        'uuid' => 'github-private-incomplete',
        'team_id' => $this->team->id,
        'api_url' => 'https://api.github.com',
        'html_url' => 'https://github.com',
        'custom_user' => 'git',
        'custom_port' => 22,
        'app_id' => null,
        'installation_id' => null,
        'private_key_id' => null,
        'is_public' => false,
        'is_system_wide' => false,
    ]);

    Http::fake();

    $response = mcpReadCall('list_github_branches', [
        'github_app_uuid' => $privateApp->uuid,
        'owner' => 'coollabsio',
        'repo' => 'coolify',
    ]);
    $response->assertOk();
    expect($response->json('result.isError'))->toBeTrue();
    expect($response->json('result.content.0.text'))
        ->toContain('missing installation credentials')
        ->not->toContain('private_key');
    Http::assertNothingSent();
});

test('list_github_branches rejects owner or repo path segment injection', function () {
    $publicApp = GithubApp::create([
        'name' => 'Public Source Path Check',
        'uuid' => 'github-public-path-check',
        'team_id' => $this->team->id,
        'api_url' => 'https://api.github.com',
        'html_url' => 'https://github.com',
        'custom_user' => 'git',
        'custom_port' => 22,
        'is_public' => true,
        'is_system_wide' => false,
    ]);

    Http::fake();

    $badOwner = mcpReadCall('list_github_branches', [
        'github_app_uuid' => $publicApp->uuid,
        'owner' => 'cool/../labs',
        'repo' => 'coolify',
    ]);
    $badOwner->assertOk();
    expect($badOwner->json('result.isError'))->toBeTrue();
    expect($badOwner->json('result.content.0.text'))->toContain('valid GitHub login');

    $badRepo = mcpReadCall('list_github_branches', [
        'github_app_uuid' => $publicApp->uuid,
        'owner' => 'coollabsio',
        'repo' => 'coolify/extra',
    ]);
    $badRepo->assertOk();
    expect($badRepo->json('result.isError'))->toBeTrue();
    expect($badRepo->json('result.content.0.text'))->toContain('valid GitHub repository');

    Http::assertNothingSent();
});

test('list_applications project_uuid filter is team scoped', function () {
    $otherProject = Project::factory()->create(['team_id' => $this->team->id]);
    $otherEnv = $otherProject->environments()->first()
        ?? Environment::factory()->create(['project_id' => $otherProject->id]);
    Application::factory()->create([
        'name' => 'OtherProjectApp',
        'environment_id' => $otherEnv->id,
        'destination_id' => $this->destination->id,
        'destination_type' => $this->destination->getMorphClass(),
    ]);

    $response = mcpReadCall('list_applications', ['project_uuid' => $this->project->uuid]);
    $response->assertOk();
    $body = mcpReadJson($response);
    $names = collect($body['data'])->pluck('name');
    expect($names)->toContain($this->application->name);
    expect($names)->not->toContain('OtherProjectApp');
});

test('get_logs rejects other team application uuid', function () {
    $otherTeam = Team::factory()->create();
    $otherProject = Project::factory()->create(['team_id' => $otherTeam->id]);
    $otherEnv = $otherProject->environments()->first()
        ?? Environment::factory()->create(['project_id' => $otherProject->id]);
    $otherServer = Server::factory()->create(['team_id' => $otherTeam->id]);
    $otherDest = StandaloneDocker::query()->where('server_id', $otherServer->id)->firstOrFail();
    $otherApp = Application::factory()->create([
        'environment_id' => $otherEnv->id,
        'destination_id' => $otherDest->id,
        'destination_type' => $otherDest->getMorphClass(),
    ]);

    $response = mcpSensitiveReadCall('get_logs', [
        'resource' => 'application',
        'uuid' => $otherApp->uuid,
    ]);
    expect($response->json('result.isError'))->toBeTrue();
});

test('search_resources finds team app by name and domain and excludes other team', function () {
    $otherTeam = Team::factory()->create();
    $otherProject = Project::factory()->create(['team_id' => $otherTeam->id]);
    $otherEnv = $otherProject->environments()->first()
        ?? Environment::factory()->create(['project_id' => $otherProject->id]);
    $otherServer = Server::factory()->create(['team_id' => $otherTeam->id]);
    $otherDest = StandaloneDocker::query()->where('server_id', $otherServer->id)->firstOrFail();
    Application::factory()->create([
        'name' => 'SecretOtherApp',
        'fqdn' => 'https://app.example.com',
        'environment_id' => $otherEnv->id,
        'destination_id' => $otherDest->id,
        'destination_type' => $otherDest->getMorphClass(),
    ]);

    $byName = mcpReadCall('search_resources', ['query' => $this->application->name]);
    $byName->assertOk();
    $names = collect(mcpReadJson($byName)['data']['results'])->pluck('name');
    expect($names)->toContain($this->application->name);
    expect($names)->not->toContain('SecretOtherApp');

    $byDomain = mcpReadCall('search_resources', ['query' => 'app.example.com', 'types' => 'application']);
    $byDomain->assertOk();
    $uuids = collect(mcpReadJson($byDomain)['data']['results'])->pluck('uuid');
    expect($uuids)->toContain($this->application->uuid);
});

test('list_unhealthy_resources includes non-running apps and is team scoped', function () {
    $this->application->update(['status' => 'exited:unhealthy']);

    $otherTeam = Team::factory()->create();
    $otherProject = Project::factory()->create(['team_id' => $otherTeam->id]);
    $otherEnv = $otherProject->environments()->first()
        ?? Environment::factory()->create(['project_id' => $otherProject->id]);
    $otherServer = Server::factory()->create(['team_id' => $otherTeam->id]);
    $otherDest = StandaloneDocker::query()->where('server_id', $otherServer->id)->firstOrFail();
    Application::factory()->create([
        'name' => 'OtherDown',
        'status' => 'exited:unhealthy',
        'environment_id' => $otherEnv->id,
        'destination_id' => $otherDest->id,
        'destination_type' => $otherDest->getMorphClass(),
    ]);

    $response = mcpReadCall('list_unhealthy_resources');
    $response->assertOk();
    $body = mcpReadJson($response);
    $names = collect($body['data']['unhealthy'])->pluck('name');
    expect($names)->toContain($this->application->name);
    expect($names)->not->toContain('OtherDown');
});

test('get_infrastructure_overview health_hints and project counts stay accurate', function () {
    $this->application->update(['status' => 'exited:unhealthy']);
    Application::factory()->create([
        'name' => 'HealthyApp',
        'status' => 'running:healthy',
        'environment_id' => $this->environment->id,
        'destination_id' => $this->destination->id,
        'destination_type' => $this->destination->getMorphClass(),
    ]);

    StandalonePostgresql::create([
        'name' => 'DownDb',
        'postgres_password' => 'password',
        'status' => 'exited:unhealthy',
        'environment_id' => $this->environment->id,
        'destination_id' => $this->destination->id,
        'destination_type' => $this->destination->getMorphClass(),
    ]);
    StandalonePostgresql::create([
        'name' => 'UpDb',
        'postgres_password' => 'password',
        'status' => 'running:healthy',
        'environment_id' => $this->environment->id,
        'destination_id' => $this->destination->id,
        'destination_type' => $this->destination->getMorphClass(),
    ]);

    Service::factory()->create([
        'name' => 'EmptyService',
        'environment_id' => $this->environment->id,
        'destination_id' => $this->destination->id,
        'destination_type' => $this->destination->getMorphClass(),
    ]);

    $otherTeam = Team::factory()->create();
    $otherProject = Project::factory()->create(['team_id' => $otherTeam->id]);
    $otherEnv = $otherProject->environments()->first()
        ?? Environment::factory()->create(['project_id' => $otherProject->id]);
    $otherServer = Server::factory()->create(['team_id' => $otherTeam->id]);
    $otherDest = StandaloneDocker::query()->where('server_id', $otherServer->id)->firstOrFail();
    Application::factory()->create([
        'name' => 'OtherTeamDown',
        'status' => 'exited:unhealthy',
        'environment_id' => $otherEnv->id,
        'destination_id' => $otherDest->id,
        'destination_type' => $otherDest->getMorphClass(),
    ]);
    StandalonePostgresql::create([
        'name' => 'OtherTeamDb',
        'postgres_password' => 'password',
        'status' => 'exited:unhealthy',
        'environment_id' => $otherEnv->id,
        'destination_id' => $otherDest->id,
        'destination_type' => $otherDest->getMorphClass(),
    ]);

    $response = mcpReadCall('get_infrastructure_overview');
    $response->assertOk();
    $body = mcpReadJson($response);
    $data = $body['data'];

    expect($data['counts']['applications'])->toBe(2)
        ->and($data['counts']['services'])->toBe(1)
        ->and($data['counts']['databases'])->toBe(2)
        ->and($data['projects'][0]['counts']['applications'])->toBe(2)
        ->and($data['projects'][0]['counts']['services'])->toBe(1)
        ->and($data['projects'][0]['counts']['databases'])->toBe(2)
        ->and($data['health_hints']['applications_not_running'])->toBe(1)
        ->and($data['health_hints']['databases_not_running'])->toBe(1)
        // Empty service has no containers → aggregated status is not healthy.
        ->and($data['health_hints']['services_not_running'])->toBe(1);
});

test('list_application_previews is team scoped', function () {
    $preview = ApplicationPreview::create([
        'application_id' => $this->application->id,
        'pull_request_id' => 42,
        'pull_request_html_url' => 'https://github.com/org/repo/pull/42',
        'fqdn' => 'https://pr-42.example.com',
        'status' => 'running:healthy',
    ]);

    $response = mcpReadCall('list_application_previews', ['uuid' => $this->application->uuid]);
    $response->assertOk();
    $body = mcpReadJson($response);
    expect(collect($body['data']['previews'])->pluck('uuid'))->toContain($preview->uuid);
    expect(collect($body['data']['previews'])->pluck('pull_request_id'))->toContain(42);

    $otherTeam = Team::factory()->create();
    $otherProject = Project::factory()->create(['team_id' => $otherTeam->id]);
    $otherEnv = $otherProject->environments()->first()
        ?? Environment::factory()->create(['project_id' => $otherProject->id]);
    $otherServer = Server::factory()->create(['team_id' => $otherTeam->id]);
    $otherDest = StandaloneDocker::query()->where('server_id', $otherServer->id)->firstOrFail();
    $otherApp = Application::factory()->create([
        'environment_id' => $otherEnv->id,
        'destination_id' => $otherDest->id,
        'destination_type' => $otherDest->getMorphClass(),
    ]);

    expect(mcpReadCall('list_application_previews', ['uuid' => $otherApp->uuid])->json('result.isError'))->toBeTrue();
});

test('list_shared_env_keys returns names without values and is team scoped', function () {
    SharedEnvironmentVariable::create([
        'key' => 'SHARED_API_URL',
        'value' => 'https://secret.example.com',
        'type' => 'project',
        'team_id' => $this->team->id,
        'project_id' => $this->project->id,
    ]);

    $response = mcpReadCall('list_shared_env_keys', [
        'scope' => 'project',
        'uuid' => $this->project->uuid,
    ]);
    $response->assertOk();
    $body = mcpReadJson($response);
    $raw = json_encode($body);
    expect(collect($body['data']['keys'])->pluck('key'))->toContain('SHARED_API_URL');
    expect($raw)->not->toContain('secret.example.com');
    expect($raw)->not->toContain('"value"');

    $otherProject = Project::factory()->create(['team_id' => Team::factory()->create()->id]);
    expect(mcpReadCall('list_shared_env_keys', [
        'scope' => 'project',
        'uuid' => $otherProject->uuid,
    ])->json('result.isError'))->toBeTrue();
});

test('get_deployment include_log_summary requires sensitive read ability', function () {
    $deployment = ApplicationDeploymentQueue::create([
        'application_id' => $this->application->id,
        'deployment_uuid' => 'dep-log-'.fake()->uuid(),
        'status' => 'failed',
        'server_id' => $this->server->id,
        'application_name' => $this->application->name,
        'server_name' => $this->server->name,
        'commit' => 'deadbeef',
        'logs' => json_encode([
            ['output' => 'unstructured-sensitive-build-output', 'type' => 'stdout', 'hidden' => false],
        ]),
    ]);

    $response = mcpReadCall('get_deployment', [
        'uuid' => $deployment->deployment_uuid,
        'include_log_summary' => true,
    ]);

    $response->assertOk();
    expect($response->json('result.isError'))->toBeTrue()
        ->and($response->json('result.content.0.text'))->toContain('read:sensitive')
        ->and($response->json('result.content.0.text'))->not->toContain('unstructured-sensitive-build-output');
});

test('get_deployment include_log_summary returns capped redacted text with sensitive read ability', function () {
    $logs = json_encode([
        ['output' => 'step 1 ok', 'type' => 'stdout', 'hidden' => false],
        ['output' => 'password=redactme01', 'type' => 'stderr', 'hidden' => false],
        ['output' => 'done', 'type' => 'stdout', 'hidden' => false],
    ]);

    $deployment = ApplicationDeploymentQueue::create([
        'application_id' => $this->application->id,
        'deployment_uuid' => 'dep-log-'.fake()->uuid(),
        'status' => 'failed',
        'server_id' => $this->server->id,
        'application_name' => $this->application->name,
        'server_name' => $this->server->name,
        'commit' => 'deadbeef',
        'logs' => $logs,
    ]);

    $response = mcpSensitiveReadCall('get_deployment', [
        'uuid' => $deployment->deployment_uuid,
        'include_log_summary' => true,
        'log_lines' => 10,
    ]);
    $response->assertOk();
    $body = mcpReadJson($response);

    expect($body['data']['log_summary']['available'])->toBeTrue();
    expect($body['data']['log_summary']['text'])->toContain('step 1 ok');
    expect($body['data']['log_summary']['text'])->not->toContain('redactme01');
    expect($body['data']['log_summary']['text'])->toContain('password=');
    // Full logs field still scrubbed from root payload
    expect(json_encode($body))->not->toContain('"logs":');
});

test('get_deployment plain-text log summary respects the requested line limit', function () {
    $deployment = ApplicationDeploymentQueue::create([
        'application_id' => $this->application->id,
        'deployment_uuid' => 'dep-log-'.fake()->uuid(),
        'status' => 'failed',
        'server_id' => $this->server->id,
        'application_name' => $this->application->name,
        'server_name' => $this->server->name,
        'commit' => 'deadbeef',
        'logs' => "first line\nsecond line token=redactme01\nlast line",
    ]);

    $response = mcpSensitiveReadCall('get_deployment', [
        'uuid' => $deployment->deployment_uuid,
        'include_log_summary' => true,
        'log_lines' => 1,
    ]);
    $response->assertOk();
    $summary = mcpReadJson($response)['data']['log_summary'];

    expect($summary['lines'])->toBe(1)
        ->and($summary['truncated'])->toBeTrue()
        ->and($summary['text'])->toBe('last line')
        ->and($summary['text'])->not->toContain('redactme01');
});

test('list_servers reachable filter works', function () {
    $this->server->settings->forceFill(['is_reachable' => true])->saveQuietly();

    $response = mcpReadCall('list_servers', ['reachable' => true]);
    $response->assertOk();
    $uuids = collect(mcpReadJson($response)['data'])->pluck('uuid');
    expect($uuids)->toContain($this->server->uuid);

    $none = mcpReadCall('list_servers', ['reachable' => false]);
    $none->assertOk();
    expect(collect(mcpReadJson($none)['data'])->pluck('uuid'))->not->toContain($this->server->uuid);
});

test('list_applications status and server_uuid filters work', function () {
    $this->application->update(['status' => 'running:healthy']);

    $byStatus = mcpReadCall('list_applications', ['status' => 'running']);
    $byStatus->assertOk();
    expect(collect(mcpReadJson($byStatus)['data'])->pluck('uuid'))->toContain($this->application->uuid);

    $byServer = mcpReadCall('list_applications', ['server_uuid' => $this->server->uuid]);
    $byServer->assertOk();
    expect(collect(mcpReadJson($byServer)['data'])->pluck('uuid'))->toContain($this->application->uuid);

    $missingServer = mcpReadCall('list_applications', ['server_uuid' => 'no-such-server']);
    $missingServer->assertOk();
    expect(mcpReadJson($missingServer)['data'])->toBe([]);
});

test('list_applications paginates with stable name order and disjoint pages', function () {
    $this->application->update(['name' => 'app-z-original']);

    foreach (['app-a', 'app-b', 'app-c'] as $name) {
        Application::factory()->create([
            'name' => $name,
            'environment_id' => $this->environment->id,
            'destination_id' => $this->destination->id,
            'destination_type' => $this->destination->getMorphClass(),
        ]);
    }

    $page1 = mcpReadCall('list_applications', ['page' => 1, 'per_page' => 2]);
    $page1->assertOk();
    $page1Body = mcpReadJson($page1);
    $page1Names = collect($page1Body['data'])->pluck('name')->all();
    $page1Uuids = collect($page1Body['data'])->pluck('uuid')->all();

    expect($page1Names)->toBe(collect($page1Names)->sort()->values()->all())
        ->and($page1Body['_pagination']['total'])->toBeGreaterThanOrEqual(4)
        ->and($page1Body['_pagination']['next']['args']['page'] ?? null)->toBe(2);

    $page2 = mcpReadCall('list_applications', ['page' => 2, 'per_page' => 2]);
    $page2->assertOk();
    $page2Uuids = collect(mcpReadJson($page2)['data'])->pluck('uuid')->all();

    expect(array_intersect($page1Uuids, $page2Uuids))->toBe([]);
});

test('list_databases filters by project, name, status, and server and is team scoped', function () {
    $matching = StandalonePostgresql::create([
        'name' => 'prod-postgres',
        'status' => 'running:healthy',
        'postgres_password' => 'password',
        'environment_id' => $this->environment->id,
        'destination_id' => $this->destination->id,
        'destination_type' => $this->destination->getMorphClass(),
    ]);
    $otherName = StandalonePostgresql::create([
        'name' => 'dev-redis-like',
        'status' => 'exited:unhealthy',
        'postgres_password' => 'password',
        'environment_id' => $this->environment->id,
        'destination_id' => $this->destination->id,
        'destination_type' => $this->destination->getMorphClass(),
    ]);

    $otherProject = Project::factory()->create(['team_id' => $this->team->id]);
    $otherEnv = $otherProject->environments()->first()
        ?? Environment::factory()->create(['project_id' => $otherProject->id]);
    StandalonePostgresql::create([
        'name' => 'other-project-db',
        'status' => 'running:healthy',
        'postgres_password' => 'password',
        'environment_id' => $otherEnv->id,
        'destination_id' => $this->destination->id,
        'destination_type' => $this->destination->getMorphClass(),
    ]);

    $otherTeam = Team::factory()->create();
    $otherTeamProject = Project::factory()->create(['team_id' => $otherTeam->id]);
    $otherTeamEnv = $otherTeamProject->environments()->first()
        ?? Environment::factory()->create(['project_id' => $otherTeamProject->id]);
    $otherTeamServer = Server::factory()->create(['team_id' => $otherTeam->id]);
    $otherTeamDest = StandaloneDocker::query()->where('server_id', $otherTeamServer->id)->firstOrFail();
    StandalonePostgresql::create([
        'name' => 'foreign-db',
        'status' => 'running:healthy',
        'postgres_password' => 'password',
        'environment_id' => $otherTeamEnv->id,
        'destination_id' => $otherTeamDest->id,
        'destination_type' => $otherTeamDest->getMorphClass(),
    ]);

    $all = mcpReadCall('list_databases');
    $all->assertOk();
    $allUuids = collect(mcpReadJson($all)['data'])->pluck('uuid');
    expect($allUuids)
        ->toContain($matching->uuid, $otherName->uuid)
        ->not->toContain(StandalonePostgresql::where('name', 'foreign-db')->value('uuid'));

    $byProject = mcpReadCall('list_databases', ['project_uuid' => $this->project->uuid]);
    $byProject->assertOk();
    $projectUuids = collect(mcpReadJson($byProject)['data'])->pluck('uuid');
    expect($projectUuids)
        ->toContain($matching->uuid)
        ->not->toContain(StandalonePostgresql::where('name', 'other-project-db')->value('uuid'));

    $byName = mcpReadCall('list_databases', ['name' => 'prod-']);
    $byName->assertOk();
    expect(collect(mcpReadJson($byName)['data'])->pluck('uuid'))
        ->toContain($matching->uuid)
        ->not->toContain($otherName->uuid);

    $byStatus = mcpReadCall('list_databases', ['status' => 'exited']);
    $byStatus->assertOk();
    expect(collect(mcpReadJson($byStatus)['data'])->pluck('uuid'))
        ->toContain($otherName->uuid)
        ->not->toContain($matching->uuid);

    $byServer = mcpReadCall('list_databases', ['server_uuid' => $this->server->uuid]);
    $byServer->assertOk();
    expect(collect(mcpReadJson($byServer)['data'])->pluck('uuid'))->toContain($matching->uuid);

    $missingServer = mcpReadCall('list_databases', ['server_uuid' => 'no-such-server']);
    $missingServer->assertOk();
    expect(mcpReadJson($missingServer)['data'])->toBe([]);

    $row = collect(mcpReadJson($byProject)['data'])->firstWhere('uuid', $matching->uuid);
    expect($row)
        ->toHaveKeys(['uuid', 'name', 'status', 'type', 'project_uuid', 'project_name'])
        ->and($row['project_uuid'])->toBe($this->project->uuid)
        ->and($row['type'])->toBe('standalone-postgresql');
});

test('list_services filters by its computed status before pagination', function () {
    Service::factory()->create([
        'name' => 'Matching service',
        'environment_id' => $this->environment->id,
        'destination_id' => $this->destination->id,
        'destination_type' => $this->destination->getMorphClass(),
    ]);
    Service::factory()->create([
        'name' => 'Another service',
        'environment_id' => $this->environment->id,
        'destination_id' => $this->destination->id,
        'destination_type' => $this->destination->getMorphClass(),
    ]);

    $response = mcpReadCall('list_services', [
        'status' => 'unknown',
        'per_page' => 1,
    ]);

    $response->assertOk();
    $body = mcpReadJson($response);

    expect($body['_pagination']['total'])->toBe(2)
        ->and($body['data'])->toHaveCount(1)
        ->and($body['data'][0]['status'])->toContain('unknown');
});

test('get_service_application returns a field whitelist and is team scoped', function () {
    $service = Service::factory()->create([
        'environment_id' => $this->environment->id,
        'server_id' => $this->server->id,
        'destination_id' => $this->destination->id,
        'destination_type' => $this->destination->getMorphClass(),
        'docker_compose_raw' => "services:\n  web:\n    image: nginx:alpine\n",
    ]);
    $app = ServiceApplication::create([
        'uuid' => (string) Str::uuid(),
        'name' => 'web',
        'human_name' => 'Web',
        'description' => 'Frontend container',
        'service_id' => $service->id,
        'image' => 'nginx:alpine',
        'fqdn' => 'https://web.example.com',
        'status' => 'running:healthy',
    ]);

    $response = mcpReadCall('get_service_application', [
        'service_uuid' => $service->uuid,
        'uuid' => $app->uuid,
    ]);
    $response->assertOk();
    $body = mcpReadJson($response);
    $data = $body['data'];

    expect($data['uuid'])->toBe($app->uuid)
        ->and($data['service_uuid'])->toBe($service->uuid)
        ->and($data['name'])->toBe('web')
        ->and($data['human_name'])->toBe('Web')
        ->and($data['status'])->toBe('running:healthy')
        ->and($data['fqdn'])->toBe('https://web.example.com')
        ->and($data['image'])->toBe('nginx:alpine')
        ->and($data)->toHaveKeys([
            'uuid',
            'service_uuid',
            'name',
            'human_name',
            'description',
            'status',
            'fqdn',
            'ports',
            'exposes',
            'image',
            'exclude_from_status',
            'required_fqdn',
            'is_log_drain_enabled',
            'is_include_timestamps',
            'is_gzip_enabled',
            'is_stripprefix_enabled',
            'last_online_at',
            'created_at',
            'updated_at',
        ])
        ->and($data)->not->toHaveKey('id')
        ->and($data)->not->toHaveKey('service_id')
        ->and($data)->not->toHaveKey('is_migrated');

    $otherTeam = Team::factory()->create();
    $otherServer = Server::factory()->create(['team_id' => $otherTeam->id]);
    $otherProject = Project::factory()->create(['team_id' => $otherTeam->id]);
    $otherEnv = $otherProject->environments()->first()
        ?? Environment::factory()->create(['project_id' => $otherProject->id]);
    $otherDest = StandaloneDocker::query()->where('server_id', $otherServer->id)->firstOrFail();
    $otherService = Service::factory()->create([
        'environment_id' => $otherEnv->id,
        'server_id' => $otherServer->id,
        'destination_id' => $otherDest->id,
        'destination_type' => $otherDest->getMorphClass(),
    ]);
    $otherApp = ServiceApplication::create([
        'uuid' => (string) Str::uuid(),
        'name' => 'theirs',
        'service_id' => $otherService->id,
        'image' => 'nginx:alpine',
    ]);

    $denied = mcpReadCall('get_service_application', [
        'service_uuid' => $otherService->uuid,
        'uuid' => $otherApp->uuid,
    ]);
    expect($denied->json('result.isError'))->toBeTrue();
});

test('MCP lists prompts for troubleshooting workflows', function () {
    $token = mcpReadToken();
    $response = test()->withHeaders([
        'Content-Type' => 'application/json',
        'Accept' => 'application/json, text/event-stream',
        'Authorization' => 'Bearer '.$token,
    ])->postJson('/mcp', [
        'jsonrpc' => '2.0',
        'id' => 1,
        'method' => 'prompts/list',
        'params' => (object) [],
    ]);

    $response->assertOk();
    $names = collect($response->json('result.prompts'))->pluck('name')->all();
    expect($names)->toContain('troubleshoot_application', 'explain_failed_deploy');
});

test('get_logs requires sensitive read ability', function () {
    $this->application->update(['status' => 'running:healthy']);

    $response = mcpReadCall('get_logs', [
        'resource' => 'application',
        'uuid' => $this->application->uuid,
    ]);

    $response->assertOk();
    expect($response->json('result.isError'))->toBeTrue()
        ->and($response->json('result.content.0.text'))->toContain('read:sensitive');
});

test('team members cannot retrieve logs with sensitive read ability', function () {
    $this->team->members()->updateExistingPivot($this->user->id, ['role' => 'member']);

    $response = mcpSensitiveReadCall('get_logs', [
        'resource' => 'application',
        'uuid' => $this->application->uuid,
    ]);

    // Elevated member tokens are rejected as JSON-RPC errors (HTTP 200) so MCP clients can parse them.
    $response->assertOk();
    expect($response->json('error.message') ?? $response->json('result.content.0.text') ?? '')
        ->toMatch('/team role|Missing required/i');
});

test('get_logs returns structured next_tools when application is not running', function () {
    $this->application->update(['status' => 'exited:unhealthy']);

    $response = mcpSensitiveReadCall('get_logs', [
        'resource' => 'application',
        'uuid' => $this->application->uuid,
    ]);
    $response->assertOk();
    $body = mcpReadJson($response);

    expect($body['data']['ok'])->toBeFalse();
    expect($body['data']['reason'])->toBe('not_running');
    expect($body['data']['next_tools'])->not->toBeEmpty();
    expect(collect($body['data']['next_tools'])->pluck('tool'))->toContain('list_deployments', 'list_unhealthy_resources');
});

test('get_logs returns structured choices when service has multiple containers', function () {
    $this->server->settings()->update(['is_reachable' => true, 'is_usable' => true]);

    $service = Service::factory()->create([
        'name' => 'multi-container-svc',
        'environment_id' => $this->environment->id,
        'server_id' => $this->server->id,
        'destination_id' => $this->destination->id,
        'destination_type' => $this->destination->getMorphClass(),
    ]);
    $childApp = ServiceApplication::create([
        'uuid' => (string) Str::uuid(),
        'name' => 'web',
        'service_id' => $service->id,
        'status' => 'running:healthy',
        'image' => 'nginx:latest',
    ]);
    $childDb = ServiceDatabase::create([
        'uuid' => (string) Str::uuid(),
        'name' => 'db',
        'service_id' => $service->id,
        'status' => 'running:healthy',
        'image' => 'postgres:16',
    ]);

    $response = mcpSensitiveReadCall('get_logs', [
        'resource' => 'service',
        'uuid' => $service->uuid,
    ]);
    $response->assertOk();
    $body = mcpReadJson($response);

    expect($body['data']['ok'])->toBeFalse()
        ->and($body['data']['reason'])->toBe('multiple_containers')
        ->and($body['data']['choices'])->toBeArray()
        ->and(collect($body['data']['choices'])->pluck('uuid')->all())
        ->toContain($childApp->uuid, $childDb->uuid)
        ->and(json_encode($body['data']['message'] ?? ''))->not->toContain('"uuid"');
});

test('coolify_help returns catalog intents', function () {
    $response = mcpReadCall('coolify_help', ['intent' => 'essentials']);
    $response->assertOk();
    $body = mcpReadJson($response);
    expect($body['data']['catalog']['essentials']['tools'])->toContain('search_resources', 'control');
});

test('list_unhealthy_resources sample_only returns summary and samples', function () {
    $this->application->update(['status' => 'exited:unhealthy']);

    $response = mcpReadCall('list_unhealthy_resources', ['sample_only' => true, 'sample_per_type' => 3]);
    $response->assertOk();
    $body = mcpReadJson($response);
    expect($body['data']['sample_only'])->toBeTrue();
    expect($body['data']['summary'])->toHaveKeys(['total', 'applications', 'servers']);
    expect($body['data']['samples'])->toHaveKeys(['applications', 'servers', 'services', 'databases']);
});

test('control and deploy require deploy ability', function () {
    $denied = mcpReadCall('control', [
        'resource' => 'application',
        'action' => 'start',
        'uuid' => $this->application->uuid,
    ]);
    $denied->assertOk();
    expect($denied->json('result.isError'))->toBeTrue();
    expect($denied->json('result.content.0.text'))->toContain('Missing required permissions');

    $deployDenied = mcpReadCall('deploy', ['uuid' => $this->application->uuid]);
    expect($deployDenied->json('result.isError'))->toBeTrue();
});

test('control stop requires confirm', function () {
    $token = test()->user->createToken('mcp-deploy', ['read', 'deploy'])->plainTextToken;
    $response = test()->withHeaders([
        'Content-Type' => 'application/json',
        'Accept' => 'application/json, text/event-stream',
        'Authorization' => 'Bearer '.$token,
    ])->postJson('/mcp', [
        'jsonrpc' => '2.0',
        'id' => 1,
        'method' => 'tools/call',
        'params' => [
            'name' => 'control',
            'arguments' => (object) [
                'resource' => 'application',
                'action' => 'stop',
                'uuid' => $this->application->uuid,
            ],
        ],
    ]);
    expect($response->json('result.isError'))->toBeTrue();
    expect($response->json('result.content.0.text'))->toContain('confirm=true');
});

test('team member with deploy ability cannot call lifecycle tools', function () {
    $this->team->members()->updateExistingPivot($this->user->id, ['role' => 'member']);

    $token = $this->user->createToken('mcp-member-deploy', ['read', 'deploy'])->plainTextToken;

    $response = test()->withHeaders([
        'Content-Type' => 'application/json',
        'Accept' => 'application/json, text/event-stream',
        'Authorization' => 'Bearer '.$token,
    ])->postJson('/mcp', [
        'jsonrpc' => '2.0',
        'id' => 1,
        'method' => 'tools/call',
        'params' => [
            'name' => 'control',
            'arguments' => (object) [
                'resource' => 'application',
                'action' => 'start',
                'uuid' => $this->application->uuid,
            ],
        ],
    ]);

    // Middleware returns a JSON-RPC error envelope (HTTP 200) for MCP clients.
    $response->assertOk();
    expect($response->json('jsonrpc'))->toBe('2.0');
    expect($response->json('error.message') ?? $response->json('result.content.0.text') ?? '')
        ->toMatch('/team role|Missing required/i');
});

test('control start with deploy ability queues application deployment', function () {
    Bus::fake();

    $token = $this->user->createToken('mcp-deploy-start', ['read', 'deploy'])->plainTextToken;
    $response = test()->withHeaders([
        'Content-Type' => 'application/json',
        'Accept' => 'application/json, text/event-stream',
        'Authorization' => 'Bearer '.$token,
    ])->postJson('/mcp', [
        'jsonrpc' => '2.0',
        'id' => 1,
        'method' => 'tools/call',
        'params' => [
            'name' => 'control',
            'arguments' => (object) [
                'resource' => 'application',
                'action' => 'start',
                'uuid' => $this->application->uuid,
            ],
        ],
    ]);

    $response->assertOk();
    expect($response->json('result.isError'))->toBeFalse();
    $body = mcpReadJson($response);
    expect($body['data']['ok'])->toBeTrue()
        ->and($body['data']['action'])->toBe('start')
        ->and($body['data']['deployment_uuid'])->not->toBeEmpty();
});

test('deploy tool queues application deployment', function () {
    Bus::fake();

    $token = $this->user->createToken('mcp-deploy-tool', ['read', 'deploy'])->plainTextToken;
    $response = test()->withHeaders([
        'Content-Type' => 'application/json',
        'Accept' => 'application/json, text/event-stream',
        'Authorization' => 'Bearer '.$token,
    ])->postJson('/mcp', [
        'jsonrpc' => '2.0',
        'id' => 1,
        'method' => 'tools/call',
        'params' => [
            'name' => 'deploy',
            'arguments' => (object) [
                'uuid' => $this->application->uuid,
                'force' => false,
            ],
        ],
    ]);

    $response->assertOk();
    expect($response->json('result.isError'))->toBeFalse();
    $body = mcpReadJson($response);
    expect($body['data']['ok'])->toBeTrue()
        ->and($body['data']['deployment_uuid'])->not->toBeEmpty();
    expect(ApplicationDeploymentQueue::where('deployment_uuid', $body['data']['deployment_uuid'])->exists())->toBeTrue();
});

test('cancel_deployment cancels team deployment and rejects other team', function () {
    // Avoid real SSH via instant_remote_process during cancellation cleanup.
    Process::fake([
        '*' => Process::result(output: ''),
    ]);

    $deployment = ApplicationDeploymentQueue::create([
        'application_id' => $this->application->id,
        'deployment_uuid' => 'dep-cancel-'.fake()->uuid(),
        'status' => 'in_progress',
        'server_id' => $this->server->id,
        'application_name' => $this->application->name,
        'server_name' => $this->server->name,
        'commit' => 'abc',
        'current_process_id' => '12345',
    ]);

    $token = $this->user->createToken('mcp-cancel', ['read', 'deploy'])->plainTextToken;
    $ok = test()->withHeaders([
        'Content-Type' => 'application/json',
        'Accept' => 'application/json, text/event-stream',
        'Authorization' => 'Bearer '.$token,
    ])->postJson('/mcp', [
        'jsonrpc' => '2.0',
        'id' => 1,
        'method' => 'tools/call',
        'params' => [
            'name' => 'cancel_deployment',
            'arguments' => (object) ['uuid' => $deployment->deployment_uuid],
        ],
    ]);

    $ok->assertOk();
    expect($ok->json('result.isError'))->toBeFalse();
    $body = mcpReadJson($ok);
    expect($body['data']['ok'])->toBeTrue()
        ->and($body['data']['status'])->toBe('cancelled-by-user');
    expect($deployment->fresh()->status)->toBe('cancelled-by-user');

    $otherTeam = Team::factory()->create();
    $otherServer = Server::factory()->create(['team_id' => $otherTeam->id]);
    $otherProject = Project::factory()->create(['team_id' => $otherTeam->id]);
    $otherEnv = $otherProject->environments()->first()
        ?? Environment::factory()->create(['project_id' => $otherProject->id]);
    $otherDest = StandaloneDocker::query()->where('server_id', $otherServer->id)->firstOrFail();
    $otherApp = Application::factory()->create([
        'environment_id' => $otherEnv->id,
        'destination_id' => $otherDest->id,
        'destination_type' => $otherDest->getMorphClass(),
    ]);
    $otherDep = ApplicationDeploymentQueue::create([
        'application_id' => $otherApp->id,
        'deployment_uuid' => 'dep-other-cancel-'.fake()->uuid(),
        'status' => 'in_progress',
        'server_id' => $otherServer->id,
        'application_name' => $otherApp->name,
        'server_name' => $otherServer->name,
    ]);

    $denied = test()->withHeaders([
        'Content-Type' => 'application/json',
        'Accept' => 'application/json, text/event-stream',
        'Authorization' => 'Bearer '.$token,
    ])->postJson('/mcp', [
        'jsonrpc' => '2.0',
        'id' => 1,
        'method' => 'tools/call',
        'params' => [
            'name' => 'cancel_deployment',
            'arguments' => (object) ['uuid' => $otherDep->deployment_uuid],
        ],
    ]);
    expect($denied->json('result.isError'))->toBeTrue();
    expect($otherDep->fresh()->status)->toBe('in_progress');
});

test('cancel_deployment rejects other team deployment even on owned server', function () {
    // Shared-server case: caller's team owns the host server, but the application belongs to another team.
    $otherTeam = Team::factory()->create();
    $otherProject = Project::factory()->create(['team_id' => $otherTeam->id]);
    $otherEnv = $otherProject->environments()->first()
        ?? Environment::factory()->create(['project_id' => $otherProject->id]);
    $otherApp = Application::factory()->create([
        'environment_id' => $otherEnv->id,
        'destination_id' => $this->destination->id,
        'destination_type' => $this->destination->getMorphClass(),
    ]);
    $sharedServerDep = ApplicationDeploymentQueue::create([
        'application_id' => $otherApp->id,
        'deployment_uuid' => 'dep-shared-server-cancel-'.fake()->uuid(),
        'status' => 'in_progress',
        'server_id' => $this->server->id,
        'application_name' => $otherApp->name,
        'server_name' => $this->server->name,
    ]);

    $token = $this->user->createToken('mcp-shared-server-cancel', ['read', 'deploy'])->plainTextToken;
    $denied = test()->withHeaders([
        'Content-Type' => 'application/json',
        'Accept' => 'application/json, text/event-stream',
        'Authorization' => 'Bearer '.$token,
    ])->postJson('/mcp', [
        'jsonrpc' => '2.0',
        'id' => 1,
        'method' => 'tools/call',
        'params' => [
            'name' => 'cancel_deployment',
            'arguments' => (object) ['uuid' => $sharedServerDep->deployment_uuid],
        ],
    ]);

    expect($denied->json('result.isError'))->toBeTrue();
    expect($sharedServerDep->fresh()->status)->toBe('in_progress');
});

test('cancel_deployment updates only a still cancellable deployment', function () {
    // Avoid real SSH via instant_remote_process during cancellation cleanup.
    Process::fake([
        '*' => Process::result(output: ''),
    ]);

    $deployment = ApplicationDeploymentQueue::create([
        'application_id' => $this->application->id,
        'deployment_uuid' => 'dep-atomic-cancel-'.fake()->uuid(),
        'status' => 'in_progress',
        'server_id' => $this->server->id,
        'application_name' => $this->application->name,
        'server_name' => $this->server->name,
    ]);

    $updates = [];
    DB::listen(function ($query) use (&$updates) {
        if (str_starts_with(strtolower(ltrim($query->sql)), 'update')) {
            $updates[] = strtolower($query->sql);
        }
    });

    $token = $this->user->createToken('mcp-atomic-cancel', ['read', 'deploy'])->plainTextToken;
    $response = test()->withHeaders([
        'Content-Type' => 'application/json',
        'Accept' => 'application/json, text/event-stream',
        'Authorization' => 'Bearer '.$token,
    ])->postJson('/mcp', [
        'jsonrpc' => '2.0',
        'id' => 1,
        'method' => 'tools/call',
        'params' => [
            'name' => 'cancel_deployment',
            'arguments' => (object) ['uuid' => $deployment->deployment_uuid],
        ],
    ]);

    $response->assertOk();
    expect(collect($updates)->contains(
        fn (string $sql) => str_contains($sql, 'application_deployment_queues')
            && str_contains($sql, 'status')
            && str_contains($sql, ' in '),
    ))->toBeTrue();
});

test('MCP resources list includes overview and application template', function () {
    $token = mcpReadToken();
    $response = test()->withHeaders([
        'Content-Type' => 'application/json',
        'Accept' => 'application/json, text/event-stream',
        'Authorization' => 'Bearer '.$token,
    ])->postJson('/mcp', [
        'jsonrpc' => '2.0',
        'id' => 1,
        'method' => 'resources/list',
        'params' => (object) [],
    ]);

    $response->assertOk();
    $uris = collect($response->json('result.resources'))->pluck('uri')->filter()->all();
    $templates = collect($response->json('result.resources'))->pluck('uriTemplate')->filter()->all();

    // Static resource may appear under resources; templates under list or templates/list depending on server.
    $all = collect($uris)->merge($templates)->implode(' ');
    expect($all)->toContain('coolify://');
});

test('MCP overview resource returns batched project resource counts', function () {
    StandalonePostgresql::create([
        'name' => 'overview-postgres',
        'postgres_password' => 'password',
        'environment_id' => $this->environment->id,
        'destination_id' => $this->destination->id,
        'destination_type' => $this->destination->getMorphClass(),
    ]);
    Service::create([
        'name' => 'overview-service',
        'environment_id' => $this->environment->id,
        'destination_id' => $this->destination->id,
        'destination_type' => $this->destination->getMorphClass(),
        'docker_compose_raw' => 'services: {}',
    ]);

    $token = mcpReadToken();
    $response = test()->withHeaders([
        'Content-Type' => 'application/json',
        'Accept' => 'application/json, text/event-stream',
        'Authorization' => 'Bearer '.$token,
    ])->postJson('/mcp', [
        'jsonrpc' => '2.0',
        'id' => 1,
        'method' => 'resources/read',
        'params' => [
            'uri' => 'coolify://overview',
        ],
    ]);

    $response->assertOk();

    $text = collect($response->json('result.contents'))->pluck('text')->first();
    expect($text)->not->toBeNull();

    $body = json_decode($text, true);
    expect($body)->toHaveKeys(['coolify_version', 'servers', 'projects', 'counts']);
    expect($body['counts']['projects'])->toBe(1);

    $project = collect($body['projects'])->firstWhere('uuid', $this->project->uuid);
    expect($project)->not->toBeNull();
    expect($project['counts'])->toMatchArray([
        'applications' => 1,
        'services' => 1,
        'databases' => 1,
    ]);
});

test('get_deployment and cancel_deployment work for soft-deleted applications', function () {
    Process::fake([
        '*' => Process::result(output: ''),
    ]);

    $deployment = ApplicationDeploymentQueue::create([
        'application_id' => $this->application->id,
        'deployment_uuid' => 'dep-soft-delete-'.fake()->uuid(),
        'status' => 'in_progress',
        'server_id' => $this->server->id,
        'application_name' => $this->application->name,
        'server_name' => $this->server->name,
        'commit' => 'abc123',
    ]);

    $deployToken = $this->user->createToken('mcp-soft-cancel', ['read', 'deploy'])->plainTextToken;

    $this->application->delete();
    expect(Application::withTrashed()->find($this->application->id))->not->toBeNull();
    expect(Application::find($this->application->id))->toBeNull();

    // get_deployment still resolves soft-deleted applications for the team.
    $get = test()->withHeaders([
        'Content-Type' => 'application/json',
        'Accept' => 'application/json, text/event-stream',
        'Authorization' => 'Bearer '.$deployToken,
    ])->postJson('/mcp', [
        'jsonrpc' => '2.0',
        'id' => 1,
        'method' => 'tools/call',
        'params' => [
            'name' => 'get_deployment',
            'arguments' => (object) ['uuid' => $deployment->deployment_uuid],
        ],
    ]);
    $get->assertOk();
    expect($get->json('result.isError'))->toBeFalse();
    $getBody = mcpReadJson($get);
    expect($getBody['data']['deployment_uuid'])->toBe($deployment->deployment_uuid)
        ->and($getBody['data']['application_uuid'])->toBe($this->application->uuid);

    $cancel = test()->withHeaders([
        'Content-Type' => 'application/json',
        'Accept' => 'application/json, text/event-stream',
        'Authorization' => 'Bearer '.$deployToken,
    ])->postJson('/mcp', [
        'jsonrpc' => '2.0',
        'id' => 1,
        'method' => 'tools/call',
        'params' => [
            'name' => 'cancel_deployment',
            'arguments' => (object) ['uuid' => $deployment->deployment_uuid],
        ],
    ]);
    $cancel->assertOk();
    expect($cancel->json('result.isError'))->toBeFalse();
    expect($deployment->fresh()->status)->toBe('cancelled-by-user');
});

test('list_databases paginates at the query layer', function () {
    foreach (['alpha-db', 'beta-db', 'gamma-db'] as $name) {
        StandalonePostgresql::create([
            'name' => $name,
            'status' => 'running:healthy',
            'postgres_password' => 'password',
            'environment_id' => $this->environment->id,
            'destination_id' => $this->destination->id,
            'destination_type' => $this->destination->getMorphClass(),
        ]);
    }

    $page1 = mcpReadCall('list_databases', ['per_page' => 2, 'page' => 1]);
    $page1->assertOk();
    $body1 = mcpReadJson($page1);
    expect($body1['_pagination']['total'])->toBe(3)
        ->and($body1['data'])->toHaveCount(2)
        ->and($body1['data'][0]['name'])->toBe('alpha-db')
        ->and($body1['data'][1]['name'])->toBe('beta-db');

    $page2 = mcpReadCall('list_databases', ['per_page' => 2, 'page' => 2]);
    $page2->assertOk();
    $body2 = mcpReadJson($page2);
    expect($body2['data'])->toHaveCount(1)
        ->and($body2['data'][0]['name'])->toBe('gamma-db');

    $page1Uuids = collect($body1['data'])->pluck('uuid')->all();
    $page2Uuids = collect($body2['data'])->pluck('uuid')->all();
    expect(array_intersect($page1Uuids, $page2Uuids))->toBe([]);
});

test('list_unhealthy_resources full mode paginates without dropping summary totals', function () {
    $this->application->update(['name' => 'AppA', 'status' => 'exited:unhealthy']);
    Application::factory()->create([
        'name' => 'AppB',
        'status' => 'exited:unhealthy',
        'environment_id' => $this->environment->id,
        'destination_id' => $this->destination->id,
        'destination_type' => $this->destination->getMorphClass(),
    ]);
    Application::factory()->create([
        'name' => 'AppC',
        'status' => 'exited:unhealthy',
        'environment_id' => $this->environment->id,
        'destination_id' => $this->destination->id,
        'destination_type' => $this->destination->getMorphClass(),
    ]);

    // Ensure the default server is not counted as unhealthy (factory settings vary).
    $this->server->settings()->update(['is_reachable' => true, 'is_usable' => true]);

    $page1 = mcpReadCall('list_unhealthy_resources', [
        'sample_only' => false,
        'per_page' => 2,
        'page' => 1,
    ]);
    $page1->assertOk();
    $body1 = mcpReadJson($page1);
    expect($body1['data']['summary']['applications'])->toBe(3)
        ->and($body1['data']['summary']['servers'])->toBe(0)
        ->and($body1['_pagination']['total'])->toBe(3)
        ->and($body1['data']['unhealthy'])->toHaveCount(2);

    $page1Names = collect($body1['data']['unhealthy'])->pluck('name')->all();
    expect($page1Names)->toBe(['AppA', 'AppB']);

    $page2 = mcpReadCall('list_unhealthy_resources', [
        'sample_only' => false,
        'per_page' => 2,
        'page' => 2,
    ]);
    $page2->assertOk();
    $body2 = mcpReadJson($page2);
    expect($body2['data']['unhealthy'])->toHaveCount(1)
        ->and($body2['data']['unhealthy'][0]['name'])->toBe('AppC');
});

test('list_unhealthy_resources full mode paginates services without dropping summary totals', function () {
    // Keep apps/servers healthy so the page window is pure services.
    $this->application->update(['status' => 'running:healthy']);
    $this->server->settings()->update(['is_reachable' => true, 'is_usable' => true]);

    // Empty services have no running status → treated as unhealthy by the status scan.
    foreach (['SvcA', 'SvcB', 'SvcC', 'SvcD', 'SvcE'] as $name) {
        Service::factory()->create([
            'name' => $name,
            'environment_id' => $this->environment->id,
            'server_id' => $this->server->id,
            'destination_id' => $this->destination->id,
            'destination_type' => $this->destination->getMorphClass(),
        ]);
    }

    $page1 = mcpReadCall('list_unhealthy_resources', [
        'sample_only' => false,
        'per_page' => 2,
        'page' => 1,
    ]);
    $page1->assertOk();
    $body1 = mcpReadJson($page1);
    expect($body1['data']['summary']['services'])->toBe(5)
        ->and($body1['data']['summary']['total'])->toBe(5)
        ->and($body1['_pagination']['total'])->toBe(5)
        ->and($body1['data']['unhealthy'])->toHaveCount(2)
        ->and(collect($body1['data']['unhealthy'])->pluck('name')->all())->toBe(['SvcA', 'SvcB'])
        ->and(collect($body1['data']['unhealthy'])->pluck('type')->unique()->all())->toBe(['service']);

    $page3 = mcpReadCall('list_unhealthy_resources', [
        'sample_only' => false,
        'per_page' => 2,
        'page' => 3,
    ]);
    $page3->assertOk();
    $body3 = mcpReadJson($page3);
    expect($body3['data']['summary']['services'])->toBe(5)
        ->and($body3['data']['unhealthy'])->toHaveCount(1)
        ->and($body3['data']['unhealthy'][0]['name'])->toBe('SvcE');
});

test('get_service_database returns a field whitelist and is team scoped', function () {
    $service = Service::factory()->create([
        'environment_id' => $this->environment->id,
        'server_id' => $this->server->id,
        'destination_id' => $this->destination->id,
        'destination_type' => $this->destination->getMorphClass(),
        'docker_compose_raw' => "services:\n  db:\n    image: postgres:16\n",
    ]);
    $db = ServiceDatabase::create([
        'uuid' => (string) Str::uuid(),
        'name' => 'db',
        'human_name' => 'Database',
        'description' => 'Primary DB',
        'service_id' => $service->id,
        'image' => 'postgres:16',
        'status' => 'running:healthy',
    ]);

    $response = mcpReadCall('get_service_database', [
        'service_uuid' => $service->uuid,
        'uuid' => $db->uuid,
    ]);
    $response->assertOk();
    $data = mcpReadJson($response)['data'];

    expect($data['uuid'])->toBe($db->uuid)
        ->and($data['service_uuid'])->toBe($service->uuid)
        ->and($data['name'])->toBe('db')
        ->and($data)->toHaveKeys([
            'uuid',
            'service_uuid',
            'name',
            'human_name',
            'description',
            'status',
            'image',
            'created_at',
            'updated_at',
        ])
        ->and($data)->not->toHaveKey('id')
        ->and($data)->not->toHaveKey('service_id')
        ->and($data)->not->toHaveKey('is_migrated');

    $otherTeam = Team::factory()->create();
    $otherServer = Server::factory()->create(['team_id' => $otherTeam->id]);
    $otherProject = Project::factory()->create(['team_id' => $otherTeam->id]);
    $otherEnv = $otherProject->environments()->first()
        ?? Environment::factory()->create(['project_id' => $otherProject->id]);
    $otherDest = StandaloneDocker::query()->where('server_id', $otherServer->id)->firstOrFail();
    $otherService = Service::factory()->create([
        'environment_id' => $otherEnv->id,
        'server_id' => $otherServer->id,
        'destination_id' => $otherDest->id,
        'destination_type' => $otherDest->getMorphClass(),
    ]);
    $otherDb = ServiceDatabase::create([
        'uuid' => (string) Str::uuid(),
        'name' => 'theirs',
        'service_id' => $otherService->id,
        'image' => 'postgres:16',
    ]);

    $denied = mcpReadCall('get_service_database', [
        'service_uuid' => $otherService->uuid,
        'uuid' => $otherDb->uuid,
    ]);
    expect($denied->json('result.isError'))->toBeTrue();
});

test('list_scheduled_tasks omits command without sensitive read ability', function () {
    ScheduledTask::create([
        'uuid' => (string) Str::uuid(),
        'name' => 'nightly-backup',
        'command' => 'pg_dump --flag=redactme01',
        'frequency' => '0 2 * * *',
        'enabled' => true,
        'timeout' => 3600,
        'team_id' => $this->team->id,
        'application_id' => $this->application->id,
    ]);

    $readOnly = mcpReadCall('list_scheduled_tasks', [
        'resource' => 'application',
        'uuid' => $this->application->uuid,
    ]);
    $readOnly->assertOk();
    $tasks = mcpReadJson($readOnly)['data']['tasks'];
    expect($tasks)->toHaveCount(1)
        ->and($tasks[0]['name'])->toBe('nightly-backup')
        ->and($tasks[0]['command_included'])->toBeFalse()
        ->and($tasks[0])->not->toHaveKey('command');
    expect(json_encode($tasks))->not->toContain('redactme01');

    $sensitive = mcpSensitiveReadCall('list_scheduled_tasks', [
        'resource' => 'application',
        'uuid' => $this->application->uuid,
    ]);
    $sensitive->assertOk();
    expect($sensitive->json('result.isError'))->toBeFalse();
    $sensitiveBody = mcpReadJson($sensitive);
    expect($sensitiveBody['data']['command_included'])->toBeTrue();
    $sensitiveTasks = $sensitiveBody['data']['tasks'];
    expect($sensitiveTasks[0]['command_included'])->toBeTrue()
        ->and($sensitiveTasks[0]['command'])->toContain('pg_dump');
});

test('list_scheduled_task_executions returns newest first across pages', function () {
    $task = ScheduledTask::create([
        'uuid' => (string) Str::uuid(),
        'name' => 'exec-history',
        'command' => 'echo ok',
        'frequency' => '0 1 * * *',
        'enabled' => true,
        'timeout' => 60,
        'team_id' => $this->team->id,
        'application_id' => $this->application->id,
    ]);

    $older = ScheduledTaskExecution::create([
        'scheduled_task_id' => $task->id,
        'status' => 'success',
        'message' => 'older-run',
        'started_at' => now()->subHours(2),
        'finished_at' => now()->subHours(2)->addMinute(),
        'created_at' => now()->subHours(2),
        'updated_at' => now()->subHours(2),
    ]);
    $newer = ScheduledTaskExecution::create([
        'scheduled_task_id' => $task->id,
        'status' => 'failed',
        'message' => 'newer-run',
        'started_at' => now()->subHour(),
        'finished_at' => now()->subHour()->addMinute(),
        'created_at' => now()->subHour(),
        'updated_at' => now()->subHour(),
    ]);

    $page1 = mcpReadCall('list_scheduled_task_executions', [
        'resource' => 'application',
        'uuid' => $this->application->uuid,
        'task_uuid' => $task->uuid,
        'page' => 1,
        'per_page' => 1,
    ]);
    $page1->assertOk();
    $page1Body = mcpReadJson($page1);
    expect($page1Body['data']['executions'])->toHaveCount(1)
        ->and($page1Body['data']['message_included'])->toBeFalse()
        ->and($page1Body['data']['executions'][0])->not->toHaveKey('message')
        ->and($page1Body['data']['executions'][0]['status'])->toBe('failed')
        ->and($page1Body['_pagination']['total'])->toBe(2);

    $page2 = mcpReadCall('list_scheduled_task_executions', [
        'resource' => 'application',
        'uuid' => $this->application->uuid,
        'task_uuid' => $task->uuid,
        'page' => 2,
        'per_page' => 1,
    ]);
    $page2->assertOk();
    $page2Body = mcpReadJson($page2);
    expect($page2Body['data']['executions'][0]['status'])->toBe('success')
        ->and($page2Body['data']['executions'][0])->not->toHaveKey('message');

    $sensitive = mcpSensitiveReadCall('list_scheduled_task_executions', [
        'resource' => 'application',
        'uuid' => $this->application->uuid,
        'task_uuid' => $task->uuid,
        'page' => 1,
        'per_page' => 1,
    ]);
    $sensitive->assertOk();
    $sensitiveBody = mcpReadJson($sensitive);
    expect($sensitiveBody['data']['message_included'])->toBeTrue()
        ->and($sensitiveBody['data']['executions'][0]['message'])->toBe('newer-run');

    // Silence unused variable analysis when timestamps are forced via create attributes.
    expect($older->id)->not->toBe($newer->id);
});

test('list_scheduled_task_executions redacts secret-like values in messages with sensitive read', function () {
    $task = ScheduledTask::create([
        'uuid' => (string) Str::uuid(),
        'name' => 'exec-redact',
        'command' => 'echo ok',
        'frequency' => '0 1 * * *',
        'enabled' => true,
        'timeout' => 60,
        'team_id' => $this->team->id,
        'application_id' => $this->application->id,
    ]);
    ScheduledTaskExecution::create([
        'scheduled_task_id' => $task->id,
        'status' => 'failed',
        'message' => "backup failed password=redactme01\n",
        'started_at' => now()->subMinute(),
        'finished_at' => now(),
    ]);

    $response = mcpSensitiveReadCall('list_scheduled_task_executions', [
        'resource' => 'application',
        'uuid' => $this->application->uuid,
        'task_uuid' => $task->uuid,
    ]);
    $response->assertOk();
    $body = mcpReadJson($response);
    $message = $body['data']['executions'][0]['message'] ?? '';

    expect($body['data']['message_included'])->toBeTrue()
        ->and($message)->not->toContain('redactme01')
        ->and($message)->toContain('password=')
        ->and($message)->toContain(REDACTED);
});

test('get_logs redacts secret-like values in container output', function () {
    // Preflight fails for non-running apps, so exercise redaction via the shared helper path
    // through get_deployment (already covered) and unit-level BuildsResponse redaction.
    // Use low-entropy test markers so secret scanners do not flag fixtures.
    $trait = new class
    {
        use BuildsResponse;

        public function redact(string $text): string
        {
            return $this->redactLogText($text);
        }
    };

    $redacted = $trait->redact("boot ok\npassword=redactme01\nAPI_TOKEN=redactme02\n");
    expect($redacted)->toContain('boot ok')
        ->and($redacted)->not->toContain('redactme01')
        ->and($redacted)->not->toContain('redactme02')
        ->and($redacted)->toContain('password=')
        ->and($redacted)->toContain(REDACTED);
});

test('redactLogText redacts JSON secret fields in log lines', function () {
    $trait = new class
    {
        use BuildsResponse;

        public function redact(string $text): string
        {
            return $this->redactLogText($text);
        }
    };

    $jsonLine = '{"token":"redactme01","API_KEY":"redactme02","status":"ok"}';
    $redacted = $trait->redact("request failed: {$jsonLine}");

    expect($redacted)->toContain('status')
        ->and($redacted)->toContain('ok')
        ->and($redacted)->not->toContain('redactme01')
        ->and($redacted)->not->toContain('redactme02')
        ->and($redacted)->toContain('token=')
        ->and($redacted)->toContain('API_KEY=')
        ->and($redacted)->toContain(REDACTED);

    // Shell-style still works alongside JSON
    $mixed = $trait->redact('password=redactme01 {"client_secret":"redactme02"} export DB_PASSWORD=redactme03');
    expect($mixed)->not->toContain('redactme01')
        ->and($mixed)->not->toContain('redactme02')
        ->and($mixed)->not->toContain('redactme03')
        ->and($mixed)->toContain(REDACTED);
});
