<?php

use App\Livewire\Admin\Integrations;
use App\Services\Deployment\UpdateChannel;
use App\Services\Env\EnvFileEditor;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
use Livewire\Livewire;

/**
 * Saving .env used to end with a card telling the operator to run
 * `docker compose restart queue scheduler reverb` by
 * hand — a shell step tacked onto the end of a console action, the exact
 * complaint the update button drew before it got its own request/agent
 * mechanism (App\Services\Deployment\UpdateChannel). This reuses that
 * mechanism rather than inventing a new one or mounting the Docker socket
 * into the web container: a third request KIND, "restart", picked up by
 * deploy/update-agent.sh exactly the way "check" and "run" already are.
 *
 * Covers both halves: UpdateChannel/update-agent.sh's new kind, and
 * App\Livewire\Admin\Integrations::saveEnv() wiring it in — config:clear
 * happens immediately (no host agent needed for that), the restart is
 * requested when an agent is there to collect it, and the operator is told
 * plainly — not left to assume success — when one is not.
 */
function envRestartFilePath(string $seed = "OLD=value\n"): string
{
    $path = storage_path('framework/testing/env-restart-'.Str::random(12).'.env');
    File::ensureDirectoryExists(dirname($path));
    File::put($path, $seed);
    app()->instance(EnvFileEditor::class, new EnvFileEditor($path));

    return $path;
}

/** Makes UpdateChannel::state()['agent_seen'] true — the agent checked in recently. */
function restartAgentAlive(): void
{
    File::ensureDirectoryExists(storage_path('app/deploy'));
    File::put(storage_path('app/deploy/update-status.json'), json_encode([
        'state' => 'idle',
        'checked_at' => now()->toIso8601String(),
    ]));
}

function pendingRequestKind(): ?string
{
    $path = storage_path('app/deploy/update-request.json');

    return File::exists($path) ? (json_decode(File::get($path), true)['kind'] ?? null) : null;
}

/**
 * The page, unlocked, with the .env tab open.
 *
 * The tab is where the editor lives since the page was split up; the unlock
 * itself is a session fact and survives switching between them.
 */
function unlockedIntegrations($owner)
{
    return Livewire::actingAs($owner, 'operator')
        ->test(Integrations::class)
        ->set('confirmablePassword', 'password')
        ->call('confirmPassword')
        ->set('tab', 'env');
}

beforeEach(function () {
    config()->set('admin_access.secrets_key', 'base64:'.base64_encode(random_bytes(32)));
    File::deleteDirectory(storage_path('app/deploy'));
});

afterEach(function () {
    foreach (glob(storage_path('framework/testing/env-restart-*')) as $leftover) {
        @unlink($leftover);
    }
});

// ---- UpdateChannel: the new "restart" request kind. ----

it('writes a distinct restart request kind, separate from check and run', function () {
    expect(app(UpdateChannel::class)->requestRestart('owner@example.com'))->toBeTrue();

    expect(pendingRequestKind())->toBe('restart');
});

it('mutation target: reports a pending restart as "restarting", never as "running" — no maintenance overlay for four workers', function () {
    // The exact bug this guards against: 'running' used to be everything
    // that was not a "check" — which would also have caught "restart" and
    // put an operator saving .env behind the full-screen deployment overlay
    // (layouts/admin.blade.php) for something that takes seconds, not the
    // minutes that overlay exists for.
    restartAgentAlive();

    app(UpdateChannel::class)->requestRestart('owner@example.com');

    $state = app(UpdateChannel::class)->state();

    expect($state['restarting'])->toBeTrue()
        ->and($state['running'])->toBeFalse()
        ->and($state['checking'])->toBeFalse();
});

it('does not queue a second restart, or anything else, while one is pending', function () {
    $channel = app(UpdateChannel::class);

    expect($channel->requestRestart('someone@example.com'))->toBeTrue()
        ->and($channel->requestRestart('someone@example.com'))->toBeFalse()
        ->and($channel->request('someone@example.com'))->toBeFalse()
        ->and($channel->requestCheck('someone@example.com'))->toBeFalse();
});

it('reports the outcome of the last restart, independent of the deployment fields', function () {
    File::ensureDirectoryExists(storage_path('app/deploy'));
    File::put(storage_path('app/deploy/restart-last-run.json'), json_encode([
        'state' => 'succeeded',
        'finished_at' => now()->toIso8601String(),
        'error' => '',
    ]));

    $state = app(UpdateChannel::class)->state();

    expect($state['last_restart_state'])->toBe('succeeded')
        ->and($state['last_restart_finished_at'])->not->toBeNull()
        ->and($state['last_restart_error'])->toBeNull();
});

it('translates a failed restart into the operator’s language', function () {
    File::ensureDirectoryExists(storage_path('app/deploy'));
    File::put(storage_path('app/deploy/restart-last-run.json'), json_encode([
        'state' => 'failed',
        'finished_at' => now()->toIso8601String(),
        'error' => 'restart_failed',
    ]));

    expect(app(UpdateChannel::class)->state()['last_restart_error'])
        ->toBe(__('admin_settings.update_error.restart_failed'));
});

// ---- update-agent.sh: the host side. ----

it('mutation target: restarts only queue, scheduler and reverb — never app, never den Tunnel', function () {
    $agent = File::get(base_path('deploy/update-agent.sh'));

    expect($agent)->toContain('docker compose restart queue scheduler reverb');

    // The exact restart LIST, not merely "the word app does not appear
    // anywhere" — the script legitimately says "app" elsewhere (the -u
    // www-data exec calls, comments), so this has to isolate the argument
    // list of this specific command.
    preg_match('/docker compose restart ([a-z0-9_ -]+)/', $agent, $matches);
    $services = explode(' ', trim($matches[1] ?? ''));

    expect($services)->toContain('queue')
        ->and($services)->toContain('scheduler')
        ->and($services)->toContain('reverb')
        ->and($services)->not->toContain('app')
        // Und NIE der Provisionierungs-Arbeiter. Er teilt sich den
        // Netz-Namensraum mit dem Tunnel; ein `restart` baut den Namensraum neu
        // auf und reisst jede WireGuard-Sitzung ab — auch die des Betreibers,
        // der gerade „Dienste neu starten" gedrueckt hat. Er bekommt stattdessen
        // ein `queue:restart`-Signal, das seine Schleife auffaengt.
        ->and($services)->not->toContain('queue-provisioning');

    expect($agent)->toContain('queue-provisioning php artisan queue:restart');
});

it('branches on the restart kind and records its own outcome, apart from the deployment', function () {
    $agent = File::get(base_path('deploy/update-agent.sh'));

    expect($agent)->toContain('"$REQUEST_KIND" == "restart"')
        ->and($agent)->toContain('write_restart')
        ->and($agent)->toContain('RESTARTLAST');
});

// ---- App\Livewire\Admin\Integrations::saveEnv() wiring it in. ----

it('clears the config cache on every successful save, before anything about the agent is decided', function () {
    $owner = operator('Owner');
    envRestartFilePath();

    Artisan::shouldReceive('call')->once()->with('config:clear');

    unlockedIntegrations($owner)
        ->set('envContent', "NEW=value\n")
        ->call('saveEnv')
        ->assertHasNoErrors();
});

it('asks the agent to restart the workers once the agent is known to be alive, and says so', function () {
    $owner = operator('Owner');
    envRestartFilePath();
    restartAgentAlive();

    unlockedIntegrations($owner)
        ->set('envContent', "NEW=value\n")
        ->call('saveEnv')
        ->assertHasNoErrors()
        ->assertSet('envRestartWatching', true)
        ->assertDispatched('notify');

    expect(pendingRequestKind())->toBe('restart');
});

it('mutation target: tells the operator plainly when no agent is running, instead of claiming a restart happened', function () {
    $owner = operator('Owner');
    $path = envRestartFilePath();
    // No status file at all — the agent has never checked in.

    $page = unlockedIntegrations($owner)
        ->set('envContent', "NEW=value\n")
        ->call('saveEnv')
        ->assertHasNoErrors()
        ->assertSet('envRestartWatching', false);

    // Honest, not silent: no restart request was left behind either — there
    // is nothing to collect it, so nothing was queued to expire quietly.
    expect(pendingRequestKind())->toBeNull();

    $backups = glob($path.'.bak-*');
    expect($backups)->not->toBe([]);

    $page->assertDispatched('notify', message: __('integrations.env_saved_no_agent', [
        'backup' => basename($backups[0]),
    ]));
});

it('does not claim an automatic restart when another request is already in flight', function () {
    $owner = operator('Owner');
    $path = envRestartFilePath();
    restartAgentAlive();
    // Occupies the single request slot before .env is ever saved.
    app(UpdateChannel::class)->requestCheck('someone-else@example.com');

    $page = unlockedIntegrations($owner)
        ->set('envContent', "NEW=value\n")
        ->call('saveEnv')
        ->assertHasNoErrors()
        ->assertSet('envRestartWatching', false);

    // The ORIGINAL request is untouched — saveEnv() must not silently steal
    // the slot and overwrite what someone else asked for.
    expect(pendingRequestKind())->toBe('check');

    $backups = glob($path.'.bak-*');
    $page->assertDispatched('notify', message: __('integrations.env_saved_restart_busy', [
        'backup' => basename($backups[0]),
    ]));
});

it('shows the restart in progress, then reports it finished once the request is no longer pending', function () {
    $owner = operator('Owner');
    envRestartFilePath();
    restartAgentAlive();

    $page = unlockedIntegrations($owner)
        ->set('envContent', "NEW=value\n")
        ->call('saveEnv')
        ->assertSet('envRestartWatching', true);

    expect(pendingRequestKind())->toBe('restart');

    // Simulate the agent having picked the request up — exactly what
    // deploy/update-agent.sh's restart branch does: consume the request file
    // before acting on it.
    File::delete(storage_path('app/deploy/update-request.json'));

    // A trivial property round-trip, standing in for wire:poll's next tick —
    // it is render() that notices the request is gone and reports it.
    $page->set('dnsZone', $page->get('dnsZone'))
        ->assertSet('envRestartWatching', false)
        ->assertDispatched('notify', message: __('integrations.env_restart_done'));
});

it('explains the automatic restart, and shows the manual fallback only when no agent is reachable', function () {
    $owner = operator('Owner');
    envRestartFilePath();

    unlockedIntegrations($owner)
        ->assertSee(__('integrations.env_restart_title'))
        ->assertSee(__('admin_settings.update_no_agent'))
        ->assertSee('docker compose restart queue scheduler reverb');

    restartAgentAlive();

    unlockedIntegrations($owner)
        ->assertSee(__('integrations.env_restart_title'))
        ->assertDontSee(__('admin_settings.update_no_agent'));
});
