<?php

use App\Livewire\Admin\Settings as AdminSettings;
use App\Models\User;
use App\Services\Deployment\UpdateChannel;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
use Livewire\Livewire;

/**
 * Updating the installation from the console.
 *
 * The panel cannot run the update — it is www-data inside the container the
 * update restarts. It leaves a request for the host-side agent instead. These
 * tests cover the panel's half: what it reports, what it refuses, and what it
 * must never claim.
 */
beforeEach(function () {
    File::deleteDirectory(storage_path('app/deploy'));
});

it('does not offer to update when the agent has never reported in', function () {
    // Without an agent the button would do nothing at all, and a button that
    // does nothing is worse than a missing one.
    $state = app(UpdateChannel::class)->state();

    expect($state['agent_seen'])->toBeFalse()
        ->and($state['behind'])->toBeNull()
        ->and($state['available'])->toBeFalse();

    Livewire::actingAs(operator('Owner'), 'operator')
        ->test(AdminSettings::class)
        ->assertSee(__('admin_settings.update_no_agent'));
});

it('never reports "up to date" when it simply does not know', function () {
    // Null and zero are different answers. Showing the first as the second is
    // how an installation sits three versions behind looking healthy.
    expect(app(UpdateChannel::class)->state()['available'])->toBeFalse();

    // Asserted on the state, not on the markup: "Aktuell" is also a substring
    // of "Aktuelles Passwort" elsewhere on the same page.
    Livewire::actingAs(operator('Owner'), 'operator')
        ->test(AdminSettings::class)
        ->assertSee(__('admin_settings.update_unknown'))
        ->assertViewHas('update', fn (array $u) => $u['behind'] === null && $u['available'] === false);
});

it('reports an available update once the agent has looked', function () {
    writeStatus(['state' => 'idle', 'checked_at' => now()->toIso8601String(), 'behind' => 3]);

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

    expect($state['available'])->toBeTrue()->and($state['behind'])->toBe(3);

    Livewire::actingAs(operator('Owner'), 'operator')
        ->test(AdminSettings::class)
        ->assertSee(__('admin_settings.update_available', ['n' => 3]));
});

it('leaves a request the agent can find', function () {
    writeStatus(['state' => 'idle', 'checked_at' => now()->toIso8601String(), 'behind' => 1]);

    Livewire::actingAs(operator('Owner'), 'operator')
        ->test(AdminSettings::class)
        ->call('requestUpdate')
        ->assertHasNoErrors();

    $request = json_decode(File::get(storage_path('app/deploy/update-request.json')), true);

    expect($request['requested_by'])->toBeString()->not->toBeEmpty()
        ->and($request['requested_at'])->toBeString();
});

it('does not queue a second run when the button is pressed twice', function () {
    $channel = app(UpdateChannel::class);

    expect($channel->request('someone@example.com'))->toBeTrue()
        // Impatience, not intent — and two overlapping runs of update.sh fight
        // over the checkout.
        ->and($channel->request('someone@example.com'))->toBeFalse();
});

it('forgets a request nobody ever collected', function () {
    // Written while the agent was down. Firing it days later, whenever the
    // agent happens to come back, is not what anyone asked for.
    File::ensureDirectoryExists(storage_path('app/deploy'));
    File::put(storage_path('app/deploy/update-request.json'), json_encode([
        'requested_at' => Carbon::now()->subHours(3)->toIso8601String(),
        'requested_by' => 'someone@example.com',
    ]));

    $channel = app(UpdateChannel::class);

    expect($channel->pendingRequest())->toBeNull()
        ->and($channel->state()['running'])->toBeFalse()
        // And a fresh request is accepted again rather than blocked forever.
        ->and($channel->request('someone@example.com'))->toBeTrue();
});

it('needs the capability, not merely an operator account', function () {
    Livewire::actingAs(operator('Support'), 'operator')
        ->test(AdminSettings::class)
        ->call('requestUpdate')
        ->assertForbidden();
});

it('survives a status file that is corrupt', function () {
    // This is the page an operator opens when something is already wrong. It
    // must not be the second thing that is broken.
    File::ensureDirectoryExists(storage_path('app/deploy'));
    File::put(storage_path('app/deploy/update-status.json'), '{ this is not json');

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

    expect($state['agent_seen'])->toBeFalse();

    Livewire::actingAs(operator('Owner'), 'operator')
        ->test(AdminSettings::class)
        ->assertOk();
});

it('says a queued update is starting, and never counts down to it', function () {
    // There was a live countdown here to the agent's next tick. It ran
    // backwards a few seconds and snapped back to a full minute, over and
    // over, and it did so on every server whose systemd timer is longer than
    // the interval the agent reports — the target was recomputed on each poll
    // and had already gone by, so the "honest upper bound" fallback moved it
    // forward again every time. Reported exactly that way.
    //
    // Nothing counts down now. The agent is woken when the request lands.
    writeStatus([
        'state' => 'idle',
        'checked_at' => now()->toIso8601String(),
        'check_interval_minutes' => 1,
        'behind' => 1,
        'request_watch' => true,
    ]);

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

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

    expect($state['running'])->toBeTrue()
        ->and($state)->not->toHaveKey('next_check_at')
        // `instant` is what selects the sentence. Asserted here rather than on
        // the settings component: the sentence moved into the full-page overlay
        // in layouts/admin, because it was being shown in BOTH and an operator
        // saw one panel appear and a second cover it seconds later.
        ->and($state['instant'])->toBeTrue();
});

it('names the step the deployment is on, in the operator’s language', function () {
    // The step comes from the file update.sh rewrites as it goes, NOT from the
    // status document: the agent writes that once and then blocks for the whole
    // run, so a phase taken from there is frozen at the first step forever.
    writeStatus([
        'state' => 'running',
        'checked_at' => now()->toIso8601String(),
        'started_at' => now()->subMinutes(2)->toIso8601String(),
        'behind' => 1,
    ]);
    writePhase('migrate');

    // Through the endpoint the overlay actually reads. That is the whole path
    // now: no Livewire, no component state, no assets — because the thing being
    // reported on restarts the containers serving the page.
    $this->actingAs(operator('Owner'), 'operator')
        ->getJson(route('admin.update.state'))
        ->assertOk()
        ->assertJsonPath('step', __('admin_settings.update_step', [
            'step' => __('admin_settings.update_phase.migrate'),
        ]));
});

it('follows the step file as the deployment moves on', function () {
    writeStatus(['state' => 'running', 'checked_at' => now()->toIso8601String(), 'behind' => 1]);

    writePhase('composer');
    expect(app(UpdateChannel::class)->state()['phase'])->toBe(__('admin_settings.update_phase.composer'));

    writePhase('restart');
    expect(app(UpdateChannel::class)->state()['phase'])->toBe(__('admin_settings.update_phase.restart'));
});

it('does not print a raw phase key it has no translation for', function () {
    // A newer deployment script against older translations. Better to say
    // nothing than to put `update_phase.something` in front of an operator.
    writeStatus([
        'state' => 'running',
        'checked_at' => now()->toIso8601String(),
        'behind' => 1,
    ]);
    writePhase('something_new');

    expect(app(UpdateChannel::class)->state()['phase'])->toBeNull();
});

it('does not present the last failure’s step as this update’s progress', function () {
    // A failed run leaves its phase file in place on purpose — that is how the
    // failing step gets named. Queueing the next update must not then show it
    // as already mid-migration, hiding the only thing that is actually known:
    // when it will start.
    Carbon::setTestNow(Carbon::parse('2026-07-27 12:34:00'));

    writeStatus([
        'state' => 'idle',
        'checked_at' => now()->toIso8601String(),
        'check_interval_minutes' => 5,
        'behind' => 1,
    ]);
    writePhase('migrate');

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

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

    expect($state['running'])->toBeTrue()
        ->and($state['phase'])->toBeNull()
        // Queued, not instant — which is the sentence the overlay picks.
        ->and($state['instant'])->toBeFalse();

    // And the endpoint the overlay reads says nothing about a step either.
    $this->actingAs(operator('Owner'), 'operator')
        ->getJson(route('admin.update.state'))
        ->assertJsonPath('step', null);
});

it('ignores a step written before the run that is going on now', function () {
    // A manual `bash deploy/update.sh` on the shell leaves a phase file the
    // agent never cleared.
    Carbon::setTestNow(Carbon::parse('2026-07-27 12:34:00'));

    File::ensureDirectoryExists(storage_path('app/deploy'));
    File::put(
        storage_path('app/deploy/update-phase'),
        "assets\t".now()->subHour()->toIso8601String()."\n",
    );

    writeStatus([
        'state' => 'running',
        'checked_at' => now()->toIso8601String(),
        'started_at' => now()->subMinute()->toIso8601String(),
        'behind' => 1,
    ]);

    expect(app(UpdateChannel::class)->state()['phase'])->toBeNull();
});

it('promises no start time at all, on the exact configuration that used to loop', function () {
    // The reported case, reproduced: the agent reports a one-minute interval
    // (the value in the repo) while the host's systemd timer is still five, so
    // checked_at + interval is in the past for four minutes out of every five.
    // The old code answered "now + interval" afresh on every poll, which is a
    // countdown that resets forever instead of reaching zero.
    //
    // There is nothing left to get wrong: no time is promised anywhere.
    Carbon::setTestNow(Carbon::parse('2026-07-27 12:34:00'));

    writeStatus([
        'state' => 'idle',
        'checked_at' => now()->subMinutes(4)->toIso8601String(),
        'check_interval_minutes' => 1,
        'behind' => 1,
    ]);

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

    expect(app(UpdateChannel::class)->state())->not->toHaveKey('next_check_at');

    Livewire::actingAs(operator('Owner'), 'operator')
        ->test(AdminSettings::class)
        // No clock, and — the part that actually broke — no element for a
        // browser-side timer to keep resetting.
        ->assertDontSee('x-data="countdown', escape: false);
});

it('names the step a failed update stopped at', function () {
    writeStatus(['state' => 'idle', 'checked_at' => now()->toIso8601String(), 'behind' => 1]);
    writeLastRun([
        'state' => 'failed',
        'started_at' => now()->subMinutes(3)->toIso8601String(),
        'finished_at' => now()->toIso8601String(),
        'phase' => 'migrate',
        'error' => 'update_failed',
        'exit_code' => 1,
    ]);

    Livewire::actingAs(operator('Owner'), 'operator')
        ->test(AdminSettings::class)
        ->assertSee(__('admin_settings.update_failed_at', [
            'step' => __('admin_settings.update_phase.migrate'),
        ]));
});

function writeStatus(array $status): void
{
    File::ensureDirectoryExists(storage_path('app/deploy'));
    File::put(storage_path('app/deploy/update-status.json'), json_encode($status));
}

/** What deploy/update.sh writes as it moves from step to step. */
function writePhase(string $key): void
{
    File::ensureDirectoryExists(storage_path('app/deploy'));
    File::put(storage_path('app/deploy/update-phase'), $key."\t".now()->toIso8601String()."\n");
}

function writeLastRun(array $run): void
{
    File::ensureDirectoryExists(storage_path('app/deploy'));
    File::put(storage_path('app/deploy/update-last-run.json'), json_encode($run));
}

it('stops trusting an agent that has gone quiet', function () {
    // A status file written once by an agent since stopped would otherwise
    // leave the button enabled forever, writing requests nobody collects.
    writeStatus(['state' => 'idle', 'checked_at' => now()->subHour()->toIso8601String(), 'behind' => 2]);

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

    expect($state['agent_seen'])->toBeFalse()
        ->and($state['behind'])->toBeNull()
        ->and($state['available'])->toBeFalse();

    Livewire::actingAs(operator('Owner'), 'operator')
        ->test(AdminSettings::class)
        ->assertSee(__('admin_settings.update_no_agent'));
});

it('shows the agent’s failure in the operator’s language', function () {
    // The agent is a shell script and is not translated; it reports a code.
    writeStatus(['state' => 'idle', 'checked_at' => now()->toIso8601String(), 'behind' => 1]);
    writeLastRun(['state' => 'failed', 'finished_at' => now()->toIso8601String(), 'error' => 'update_failed', 'exit_code' => 3]);

    expect(app(UpdateChannel::class)->state()['last_error'])
        ->toBe(__('admin_settings.update_error.update_failed', ['code' => '3']));

    // An unknown code is passed through rather than swallowed: a message
    // nobody has worded yet still beats silence while an update is failing.
    writeLastRun(['state' => 'failed', 'finished_at' => now()->toIso8601String(), 'error' => 'something_new']);

    expect(app(UpdateChannel::class)->state()['last_error'])->toBe('something_new');
});

it('still shows a failed update after the next routine check', function () {
    // The check runs on a timer. Writing both into one file meant a failure
    // was usually gone before anyone looked at it.
    writeLastRun(['state' => 'failed', 'finished_at' => now()->toIso8601String(), 'error' => 'update_failed', 'exit_code' => 2]);
    writeStatus(['state' => 'idle', 'checked_at' => now()->toIso8601String(), 'behind' => 0]);

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

    expect($state['last_state'])->toBe('failed')
        ->and($state['last_error'])->toBe(__('admin_settings.update_error.update_failed', ['code' => '2']));
});

it('refuses a second request while the agent is mid-run', function () {
    // The agent deletes the request before it starts, so between that moment
    // and the end of the run there is nothing "pending" to block on.
    writeStatus(['state' => 'running', 'checked_at' => now()->toIso8601String(), 'behind' => 1]);

    $channel = app(UpdateChannel::class);

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

    expect(File::exists(storage_path('app/deploy/update-request.json')))->toBeFalse();
});

/**
 * "Nach Aktualisierungen suchen" — check only, never apply.
 *
 * The request carries a `kind` so the agent can tell the two apart. These
 * cover the panel's half: a check must never read as "running" (no
 * maintenance mode, no full-screen overlay for a question that was only "is
 * there something new"), and the one request slot is still shared, so
 * pressing either button while the other is outstanding is refused the same
 * way pressing the same one twice already was.
 */
it('writes a distinct request kind for a check, not a flag on the existing one', function () {
    writeStatus(['state' => 'idle', 'checked_at' => now()->toIso8601String(), 'behind' => 0]);

    Livewire::actingAs(operator('Owner'), 'operator')
        ->test(AdminSettings::class)
        ->call('requestCheck')
        ->assertHasNoErrors();

    $request = json_decode(File::get(storage_path('app/deploy/update-request.json')), true);

    expect($request['kind'])->toBe('check')
        ->and($request['requested_by'])->toBeString()->not->toBeEmpty();
});

it('does not treat a pending check as "running" — no maintenance overlay for a question', function () {
    writeStatus(['state' => 'idle', 'checked_at' => now()->toIso8601String(), 'behind' => 0]);

    $channel = app(UpdateChannel::class);
    expect($channel->requestCheck('owner@example.com'))->toBeTrue();

    $state = $channel->state();

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

it('does treat a pending run as "running", not "checking"', function () {
    writeStatus(['state' => 'idle', 'checked_at' => now()->toIso8601String(), 'behind' => 1]);

    $channel = app(UpdateChannel::class);
    expect($channel->request('owner@example.com'))->toBeTrue();

    $state = $channel->state();

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

it('does not queue a second check when the button is pressed twice', function () {
    $channel = app(UpdateChannel::class);

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

it('blocks a check while a run is pending, and a run while a check is pending', function () {
    $channel = app(UpdateChannel::class);

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

    File::delete(storage_path('app/deploy/update-request.json'));

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

it('needs the capability to check, not merely an operator account', function () {
    Livewire::actingAs(operator('Support'), 'operator')
        ->test(AdminSettings::class)
        ->call('requestCheck')
        ->assertForbidden();
});

it('confirms a check was requested, worded for a check rather than an update', function () {
    writeStatus(['state' => 'idle', 'checked_at' => now()->toIso8601String(), 'behind' => 0]);

    Livewire::actingAs(operator('Owner'), 'operator')
        ->test(AdminSettings::class)
        ->call('requestCheck')
        ->assertDispatched('notify', message: __('admin_settings.update_check_requested'));
});

/**
 * A check is a question, and a question does not get a countdown.
 *
 * Reported verbatim: "ich suche nur nach neuen Updates, was ist das da für ein
 * Timer, den ich nicht gestartet habe?" The check had been routed through the
 * same timer as a real update, so the card showed the only thing it had —
 * "Startet in 3:44" — for an operator who had asked for nothing to start.
 *
 * The agent is now woken the moment the request lands (the path unit in
 * deploy/install-agent.sh) and reports whether that trigger exists, because a
 * server that has not re-run the installer still waits for the timer and must
 * not be told otherwise.
 */
it('shows a pending check its badge and nothing else — no time, no clock', function () {
    // Even on a host with no path unit, where the answer really does wait for
    // the timer. A check starts nothing, so there is nothing to announce; the
    // badge saying it is looking is the whole of what can honestly be said.
    writeStatus([
        'state' => 'idle',
        'checked_at' => now()->toIso8601String(),
        'check_interval_minutes' => 5,
        'behind' => 0,
        'request_watch' => false,
    ]);

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

    Livewire::actingAs(operator('Owner'), 'operator')
        ->test(AdminSettings::class)
        ->assertSee(__('admin_settings.update_checking'))
        ->assertDontSee(__('admin_settings.update_starting'))
        ->assertDontSee(__('admin_settings.update_starting_queued'));
});

it('tells a queued update apart from an immediate one, without either naming a time', function () {
    // The one thing 'instant' still decides: whether the console may say the
    // update starts NOW, or has to admit it starts when the agent gets to it.
    // Neither answer contains a number.
    writeStatus([
        'state' => 'idle',
        'checked_at' => now()->toIso8601String(),
        'check_interval_minutes' => 5,
        'behind' => 1,
        'request_watch' => false,
    ]);

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

    // On the state, which is what the overlay in layouts/admin picks its
    // sentence from. The settings card no longer says any of this — it said it
    // at the same time as the overlay, in a smaller box, and an operator saw
    // the page arrange itself twice.
    expect(app(UpdateChannel::class)->state()['instant'])->toBeFalse();
});

it('does not believe a stale agent about being woken on demand', function () {
    // The field says what was installed when the agent last ran. From an agent
    // that has since stopped it is not a promise anybody can keep.
    writeStatus([
        'state' => 'idle',
        'checked_at' => now()->subHour()->toIso8601String(),
        'behind' => 0,
        'request_watch' => true,
    ]);

    expect(app(UpdateChannel::class)->state()['instant'])->toBeFalse();
});

it('keeps the panel, the agent and the installer in step about the on-demand trigger', function () {
    // Three files, one contract: the installer creates the path unit, the agent
    // reports whether it is active, the panel decides from that whether it may
    // say "now". If they drift, the console promises an instant answer on a
    // server that still waits five minutes for it — which is the exact failure
    // this replaced.
    $installer = File::get(base_path('deploy/install-agent.sh'));
    $agent = File::get(base_path('deploy/update-agent.sh'));

    expect($installer)->toContain('/etc/systemd/system/clupilot-update-agent.path')
        ->and($installer)->toContain('PathChanged=')
        ->and($installer)->toContain('systemctl enable --now clupilot-update-agent.path')
        ->and($agent)->toContain('is-active --quiet clupilot-update-agent.path')
        ->and($agent)->toContain('"request_watch"');
});

/**
 * An update is a released version, never the head of a branch.
 *
 * The console used to offer "Jetzt aktualisieren" unconditionally, because
 * `behind` counted commits on main and was a reading a minute old — refusing on
 * a stale reading cost the deployment. That whole problem belonged to following
 * a branch. Nothing is installed now unless a tag exists whose version is
 * higher than the one deployed, and pressing the check button answers within a
 * second, so a stale reading is no longer something to design around.
 */
it('refuses to queue an update when nothing newer has been released', function () {
    writeStatus([
        'checked_at' => now()->toIso8601String(),
        'state' => 'idle',
        'behind' => 0,
    ]);

    Livewire::actingAs(operator('Owner'), 'operator')
        ->test(AdminSettings::class)
        ->assertSee(__('admin_settings.update_only_releases'))
        ->call('requestUpdate')
        ->assertDispatched('notify', message: __('admin_settings.update_nothing_released'));

    // Not merely a disabled button: a Livewire action is a public endpoint, and
    // a queued run for nothing costs a maintenance window.
    expect(app(UpdateChannel::class)->pendingRequest())->toBeNull();
});

it('offers the update, by version, once a release above the installed one exists', function () {
    writeStatus([
        'checked_at' => now()->toIso8601String(),
        'state' => 'idle',
        'behind' => 1,
        'target_release' => 'v1.1.0',
    ]);

    Livewire::actingAs(operator('Owner'), 'operator')
        ->test(AdminSettings::class)
        // The number somebody can decide about, not "1 Aktualisierung".
        ->assertSee(__('admin_settings.update_available_release', ['version' => '1.1.0']))
        ->assertDontSee(__('admin_settings.update_only_releases'))
        ->call('requestUpdate')
        ->assertDispatched('notify', message: __('admin_settings.update_requested'));

    expect(app(UpdateChannel::class)->pendingRequest())->not->toBeNull();
});

it('does not name a release the agent never reported', function () {
    // An older agent writes no target_release. The count is all there is, and
    // inventing a version number from it would be worse than the count.
    writeStatus([
        'checked_at' => now()->toIso8601String(),
        'state' => 'idle',
        'behind' => 2,
    ]);

    expect(app(UpdateChannel::class)->state()['target_release'])->toBeNull();

    Livewire::actingAs(operator('Owner'), 'operator')
        ->test(AdminSettings::class)
        ->assertSee(__('admin_settings.update_available', ['n' => 2]));
});

it('decides what is newer by version and installs only a tag', function () {
    // The agent's half of the same rule, which no PHP test can reach: it asks
    // one question regardless of what the checkout is attached to, compares by
    // version rather than by branch position, and hands update.sh a RELEASE
    // rather than a BRANCH.
    $agent = File::get(base_path('deploy/update-agent.sh'));

    expect($agent)->toContain('version_gt')
        ->and($agent)->toContain('sort -V')
        ->and($agent)->toContain('RELEASE="$TARGET_RELEASE"')
        // No branch left to fall back onto, which is what made a commit on main
        // installable in the first place.
        ->and($agent)->not->toContain('BRANCH="$BRANCH" "$ROOT/deploy/update.sh"');
});

it('offers both actions together whenever the agent is reachable and idle', function () {
    // Two separate buttons now, not one whose label changed with the
    // reading: looking and applying are different intentions.
    writeStatus([
        'checked_at' => now()->toIso8601String(),
        'state' => 'idle',
        'behind' => 0,
    ]);

    Livewire::actingAs(operator('Owner'), 'operator')
        ->test(AdminSettings::class)
        ->assertSee(__('admin_settings.update_check'))
        ->assertSee(__('admin_settings.update_now'));
});

it('still refuses when there is no agent to pick the request up', function () {
    // The one case where either button really would go nowhere: nothing is
    // running that would ever read the request file.
    writeStatus([
        'checked_at' => now()->subDay()->toIso8601String(),
        'state' => 'idle',
        'behind' => 0,
    ]);

    Livewire::actingAs(operator('Owner'), 'operator')
        ->test(AdminSettings::class)
        ->assertSee(__('admin_settings.update_unknown'))
        ->assertDontSee(__('admin_settings.update_check'))
        ->assertDontSee(__('admin_settings.update_now'));
});

it('carries the watcher onto every console page, not only settings', function () {
    // wire:poll cannot see the run through to the end, because the run
    // restarts the containers answering the poll — so the watcher (and the
    // maintenance overlay it drives) lives in the layout, not this one page,
    // and has to be there however an operator reaches the console.
    $this->actingAs(operator('Owner'), 'operator')
        ->get(route('admin.overview'))
        ->assertOk()
        ->assertSee('updateWatcher(', escape: false);
});

it('binds the watcher exactly once when the settings page is open', function () {
    // The settings card used to bind its own copy as well as the layout now
    // doing so for every page — two pollers racing each other against the
    // same endpoint.
    writeStatus([
        'checked_at' => now()->toIso8601String(),
        'state' => 'idle',
        'behind' => 2,
    ]);

    $html = $this->actingAs(operator('Owner'), 'operator')
        ->get(route('admin.settings'))
        ->assertOk()
        ->getContent();

    expect(substr_count($html, 'updateWatcher('))->toBe(1);
});

it('answers the watcher without Livewire in the way', function () {
    writeStatus(['checked_at' => now()->toIso8601String(), 'state' => 'idle', 'behind' => 0]);

    $this->actingAs(operator('Owner'), 'operator')
        ->get(route('admin.update.state'))
        ->assertOk()
        ->assertJsonStructure(['running', 'commit', 'behind', 'last_finished_at', 'step', 'running_since', 'log'])
        ->assertHeader('Cache-Control', 'no-store, private');
});

it('carries the current step in the watcher JSON, already in the operator’s language', function () {
    // The overlay only ever displays this string — it must arrive ready to
    // print, the same sentence the settings card itself renders.
    writeStatus([
        'state' => 'running',
        'checked_at' => now()->toIso8601String(),
        'started_at' => now()->subMinute()->toIso8601String(),
        'behind' => 1,
    ]);
    writePhase('migrate');

    $this->actingAs(operator('Owner'), 'operator')
        ->get(route('admin.update.state'))
        ->assertOk()
        ->assertJson(['step' => __('admin_settings.update_step', [
            'step' => __('admin_settings.update_phase.migrate'),
        ])]);
});

it('carries no step in the watcher JSON before the deployment has announced one', function () {
    writeStatus(['state' => 'idle', 'checked_at' => now()->toIso8601String(), 'behind' => 0]);

    $this->actingAs(operator('Owner'), 'operator')
        ->get(route('admin.update.state'))
        ->assertOk()
        ->assertJson(['step' => null]);
});

it('carries how long a run has been going in the watcher JSON, already worded', function () {
    // Ready to print, the same sentence update_since renders on the settings
    // card — the overlay never assembles this from a raw timestamp either.
    writeStatus([
        'state' => 'running',
        'checked_at' => now()->toIso8601String(),
        'started_at' => now()->subMinutes(2)->toIso8601String(),
        'behind' => 1,
    ]);
    writePhase('migrate');

    $this->actingAs(operator('Owner'), 'operator')
        ->get(route('admin.update.state'))
        ->assertOk()
        ->assertJson(['running_since' => __('admin_settings.update_since', [
            'when' => now()->subMinutes(2)->diffForHumans(),
        ])]);
});

it('carries the tail of the run log in the watcher JSON once a run has actually started', function () {
    writeStatus([
        'state' => 'running',
        'checked_at' => now()->toIso8601String(),
        'started_at' => now()->subMinute()->toIso8601String(),
        'behind' => 1,
    ]);
    writePhase('migrate');
    File::ensureDirectoryExists(storage_path('app/deploy'));
    File::put(storage_path('app/deploy/update-last-run.log'), "line one\nline two\n");

    $this->actingAs(operator('Owner'), 'operator')
        ->get(route('admin.update.state'))
        ->assertOk()
        ->assertJson(['log' => "line one\nline two"]);
});

it('carries no running-since or log while only queued, not yet actually running', function () {
    // Otherwise this would be the PREVIOUS run's leftovers, presented as the
    // progress of a run that has not begun.
    writeStatus(['state' => 'idle', 'checked_at' => now()->toIso8601String(), 'behind' => 1]);
    File::ensureDirectoryExists(storage_path('app/deploy'));
    File::put(storage_path('app/deploy/update-last-run.log'), "an earlier run\n");

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

    $this->actingAs(operator('Owner'), 'operator')
        ->get(route('admin.update.state'))
        ->assertOk()
        ->assertJson(['running_since' => null, 'log' => null])
        // Nothing for the overlay to count down to, either.
        ->assertJsonMissing(['next_check_at' => null]);
});

it('does not answer the watcher for somebody who is not an operator', function () {
    $this->actingAs(User::factory()->create())
        ->get(route('admin.update.state'))
        ->assertForbidden();
});

it('shows one design while updating, not two swapping mid-run', function () {
    // Reported as "the first screen must not be there". It was the console's
    // own overlay in the app's light tokens; then the containers went down and
    // the 503 page took over with a completely different face. Two designs for
    // one event, replacing each other mid-update.
    //
    // Both render the same partial now, so there is nothing left that can
    // disagree.
    $console = File::get(resource_path('views/layouts/admin.blade.php'));
    $maintenance = File::get(resource_path('views/errors/503.blade.php'));

    expect($console)->toContain('partials.updating-panel')
        ->and($maintenance)->toContain('partials.updating-panel')
        // And nothing left behind that would render a second look.
        ->and($console)->not->toContain('update_overlay_title')
        ->and($maintenance)->not->toContain('<style>');
});

it('keeps the deployment step in the overlay, and nothing else from the run', function () {
    // The panel is shared with a page customers see, so the operator detail is
    // behind a flag rather than always present — but the STEP has to still be
    // there, because "läuft gerade" on its own is what sends somebody to the
    // shell.
    //
    // The elapsed time and the deployment's raw output are deliberately gone.
    // Neither is the question anybody staring at a maintenance screen has: the
    // log was rsync listing every font file it copied, and reading it meant a
    // file read on every console page load whether or not anything was
    // running. The settings page still shows it, where somebody looks for it.
    $panel = File::get(resource_path('views/partials/updating-panel.blade.php'));

    expect($panel)->toContain('x-text="step"')
        ->not->toContain('x-text="runningSince"')
        ->not->toContain('x-text="log"');
});

it('shows that something is happening without asking anybody to read', function () {
    // The one question a person on this page has is whether anything is moving
    // at all. The bar answers it in CSS — this page is served while the
    // application is down, so it must not depend on JavaScript having loaded or
    // on the server answering another request — and it is indeterminate on
    // purpose: a deployment has no honest percentage, and a bar that claims one
    // always stalls at 90 %.
    $panel = File::get(resource_path('views/partials/updating-panel.blade.php'));

    expect($panel)->toContain('cpu-bar')
        ->toContain('@keyframes cpu-slide')
        // Everyone sees it, not only the console: a customer on the 503 is
        // asking the same question.
        ->and(str_contains(
            Str::before($panel, '@if ($detail ?? false)'),
            'cpu-bar" aria-hidden="true"',
        ))->toBeTrue()
        // And it survives somebody who has switched motion off.
        ->and($panel)->toContain('prefers-reduced-motion');
});

it('has no dark mode of its own, in a product that has none', function () {
    // Reported from a phone: the update screen — and only the update screen —
    // came up dark blue. It carried a prefers-color-scheme block nothing else
    // in this product has, so it was the one surface that flipped, and it
    // flipped into Tailwind's slate ramp rather than the product's warm
    // neutrals.
    // Comments stripped first. The panel documents at length what it used to
    // get wrong, naming the exact values — and a scan over the raw file reads
    // that explanation as the offence it warns about. Twice now this file has
    // had to learn the same thing: measure the thing, not the note about it.
    $panel = preg_replace(
        '/\{\{--.*?--\}\}/s',
        '',
        File::get(resource_path('views/partials/updating-panel.blade.php')),
    );

    expect($panel)->not->toContain('prefers-color-scheme')
        // The slate values it used. Named, so a paste of the same ramp is
        // caught rather than merely discouraged by a comment.
        ->not->toContain('#0f172a')
        ->not->toContain('#64748b')
        ->not->toContain('#e2e8f0')
        ->not->toContain('#0b0f19')
        // And the tokens it should be using instead.
        ->toContain('#f6f6f8')
        ->toContain('#17171c')
        ->toContain('#6e6e7a');
});

it('shows the step in one place, not in two of different sizes', function () {
    // The settings card grew its own little "läuft gerade" block the instant a
    // run started, and up to three seconds later the full-page overlay covered
    // it saying the same thing. Reported as "first a small window, then the big
    // one".
    $card = File::get(resource_path('views/livewire/admin/settings.blade.php'));
    $layout = File::get(resource_path('views/layouts/admin.blade.php'));

    expect($card)->not->toContain('admin_settings.update_offline_hint')
        ->and($layout)->toContain('admin_settings.update_offline_hint');
});

it('opens the overlay on the click, not on the next poll', function () {
    // Three seconds is long enough to see the page arrange itself twice.
    $settings = File::get(app_path('Livewire/Admin/Settings.php'));
    $layout = File::get(resource_path('views/layouts/admin.blade.php'));

    expect($settings)->toContain("dispatch('update-started')")
        ->and($layout)->toContain('@update-started.window="wasRunning = true"');
});

it('does not cover the console because a button was pressed over nothing', function () {
    // The overlay opens on the event, so the event must only fire once a run is
    // actually pending. A second press, which the channel refuses, must not
    // black out the page again.
    writeStatus(['state' => 'idle', 'checked_at' => now()->toIso8601String(), 'behind' => 1]);

    $component = Livewire::actingAs(operator('Owner'), 'operator')->test(AdminSettings::class);

    $component->call('requestUpdate')->assertDispatched('update-started');
    $component->call('requestUpdate')->assertNotDispatched('update-started');
});

it('does not read the agent’s start-up gap as the end of the run', function () {
    // The reported loop, in the one place it can be reproduced: the agent
    // consumes the request file before it resolves the release, and writes
    // `state: running` only once it has decided to go ahead. In between, the
    // endpoint honestly answers "nothing is running" — and the watcher read
    // that as "finished" and reloaded, so the overlay opened on the click,
    // vanished a poll later, and the 503 arrived after it.
    $watcher = File::get(resource_path('js/app.js'));

    // The overlay may only close once the SERVER has confirmed a run and then
    // stopped reporting it.
    expect($watcher)->toContain('this.wasRunning && this.serverConfirmed && !state.running')
        // And it may not wait forever: the agent refuses a request with nothing
        // to install by writing nothing at all.
        ->and($watcher)->toContain('this.unconfirmedPolls > 20');
});

it('reproduces the gap the console used to reload on', function () {
    // The state the endpoint reports between the two agent steps: the request
    // has been consumed, the status still says idle. `running` is false here
    // and that is correct — the fix is on the reader, not on this value.
    writeStatus(['state' => 'idle', 'checked_at' => now()->toIso8601String(), 'behind' => 1]);

    app(UpdateChannel::class)->request('owner@example.com');
    expect(app(UpdateChannel::class)->state()['running'])->toBeTrue();

    // The agent takes the request…
    File::delete(storage_path('app/deploy/update-request.json'));

    // …and has not written its status yet.
    expect(app(UpdateChannel::class)->state()['running'])->toBeFalse();
});

it('does not hold the console behind the panel when the application comes back broken', function () {
    // Reported together with a 500: after the update every request failed, the
    // watcher treated each failure as "still restarting", and the overlay
    // stayed up over a console nobody could reach to find out why.
    //
    // A restart is seconds. Two minutes of nothing is an installation that came
    // back broken, and then the operator needs the page.
    $watcher = File::get(resource_path('js/app.js'));

    expect($watcher)->toContain('this.failedPolls > 40')
        ->toContain('this.stuck = true')
        // A successful answer clears it: one bad minute must not leave a
        // "something is wrong" notice sitting there for the rest of the run.
        ->toContain('this.failedPolls = 0');
});

it('offers the way out only once it is true, and only to an operator', function () {
    // An always-present close button would be a lie: the deployment does not
    // stop because somebody dismissed a panel. And the 503 page has no Alpine
    // at all, so the affordance lives behind the same operator flag as the step
    // and the log.
    $panel = File::get(resource_path('views/partials/updating-panel.blade.php'));

    expect($panel)->toContain('x-show="stuck"')
        ->toContain('@click="dismiss()"');

    // Two operator-only blocks, both gated by the same flag: the deployment
    // detail and this. The 503 page passes no `detail`, so a customer sees
    // neither the step nor a button suggesting they can call the update off.
    expect(substr_count($panel, '@if ($detail ?? false)'))->toBe(2);
});

it('runs the workers as the same user as the web process', function () {
    // Blade compiles a view and then touch()es the compiled file to the
    // source's mtime. touch() with an explicit time needs ownership — so a view
    // compiled by a ROOT worker can never be refreshed by the web process
    // again, and every request for it answers 500 with "touch(): Utime failed".
    // A compose service without `user:` runs as root, and the queue is what
    // renders mails.
    $compose = File::get(base_path('docker-compose.yml'));

    foreach (['queue', 'reverb', 'scheduler'] as $service) {
        // From the service key to the next one at the same indentation. An
        // earlier version cut at the first "\n  ", which is the end of the
        // block's own first line.
        preg_match('/\n  '.$service.':\n(.*?)(?=\n  \S|\z)/s', $compose, $match);

        expect($match[1] ?? '')->toContain('user: "www-data"');
    }

    // And the deployment repairs whatever a root process left behind, at the
    // end of the run as well as at the start.
    expect(substr_count(
        File::get(base_path('deploy/update.sh')),
        "\nnormalise_ownership\n",
    ))->toBe(2);
});

it('keeps answering its own status while the application is down', function () {
    // The deployment's status is the one thing that must be readable DURING
    // the deployment. Behind maintenance mode it answered 503 for the whole
    // run, so the overlay had nothing to show but "still waiting" and could
    // never name the step it was on.
    expect(File::get(base_path('bootstrap/app.php')))
        ->toContain('preventRequestsDuringMaintenance(except: [')
        ->toContain("'update/state'")
        ->toContain("'*/update/state'");
});

it('does not let a Livewire request during maintenance replace the page', function () {
    // Reported with the network panel open: `update` and `state` both 503, and
    // Livewire's default on a failed request is to render the response body —
    // so a wire:poll that happened to fire mid-deployment swapped the console
    // for Laravel's 503 page. Overlay, then error page, then reload.
    //
    // 503 is not a fault here. It IS the event being watched.
    $app = File::get(resource_path('js/app.js'));

    expect($app)->toContain('if (status === 503) {')
        // And the poll that used to fire it stops for the duration.
        ->and(File::get(resource_path('views/livewire/admin/settings.blade.php')))
        ->toContain("@if (! \$update['running']) wire:poll");
});

/** Das Lebenszeichen, das der Agent als ERSTES bei jedem Lauf schreibt. */
function writeAlive(array $alive): void
{
    File::ensureDirectoryExists(storage_path('app/deploy'));
    File::put(storage_path('app/deploy/agent-alive.json'), json_encode($alive));
}

/**
 * ── Der Agent lebt, kommt aber nicht an die Arbeit ────────────────────────
 *
 * Ein Lauf, der an einer gehaltenen Sperre aussteigt, hinterliess nichts:
 * kein Journal-Eintrag (systemd sieht einen sauberen Lauf), keine Zeile in der
 * Statusdatei. Die Konsole sah zwanzig Minuten lang keine neue Zahl und
 * meldete „Update-Dienst laeuft nicht" — samt der Aufforderung,
 * install-agent.sh zu fahren, das an einer gehaltenen Sperre nichts aendert.
 * Ein Betreiber hat dem zweiundachtzig Minuten lang geglaubt.
 */
it('calls the agent alive on its heartbeat, even when its last check is old', function () {
    writeStatus(['state' => 'idle', 'checked_at' => now()->subHour()->toIso8601String(), 'behind' => 3]);
    writeAlive(['at' => now()->toIso8601String(), 'state' => 'blocked', 'since' => now()->subMinutes(40)->toIso8601String(), 'held_by' => '4242 40:12 git fetch']);

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

    expect($state['agent_seen'])->toBeTrue()
        ->and($state['blocked_since'])->not->toBeNull()
        ->and($state['blocked_by'])->toBe('4242 40:12 git fetch');
});

it('does not pass off an hour-old figure as current while the agent is blocked', function () {
    // Die Zahlen stammen von VOR der Blockade und altern still weiter. „Drei
    // Updates zurueck" von vor einer Stunde liest sich genau wie von jetzt.
    writeStatus(['state' => 'idle', 'checked_at' => now()->subHour()->toIso8601String(), 'behind' => 3, 'target_release' => 'v9.9.9']);
    writeAlive(['at' => now()->toIso8601String(), 'state' => 'blocked', 'since' => now()->subMinutes(40)->toIso8601String(), 'held_by' => '']);

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

    expect($state['behind'])->toBeNull()
        ->and($state['available'])->toBeFalse()
        ->and($state['target_release'])->toBeNull();
});

it('says the service is blocked, not that it is not running', function () {
    writeStatus(['state' => 'idle', 'checked_at' => now()->subHour()->toIso8601String(), 'behind' => 1]);
    writeAlive(['at' => now()->toIso8601String(), 'state' => 'blocked', 'since' => now()->subMinutes(40)->toIso8601String(), 'held_by' => '4242 40:12 git fetch']);

    Livewire::actingAs(operator('Owner'), 'operator')
        ->test(AdminSettings::class)
        ->assertDontSee(__('admin_settings.update_no_agent'))
        ->assertSee('4242 40:12 git fetch');
});

it('keeps the old rule for an agent that writes no heartbeat at all', function () {
    // Ein Agent von vor dieser Aenderung kennt die Datei nicht. Ihn dafuer fuer
    // tot zu erklaeren waere derselbe Fehler mit umgekehrtem Vorzeichen.
    writeStatus(['state' => 'idle', 'checked_at' => now()->toIso8601String(), 'behind' => 2]);

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

    expect($state['agent_seen'])->toBeTrue()
        ->and($state['behind'])->toBe(2)
        ->and($state['blocked_since'])->toBeNull();
});

it('does not call the agent alive on a heartbeat that is itself stale', function () {
    writeAlive(['at' => now()->subHour()->toIso8601String(), 'state' => 'running', 'since' => '', 'held_by' => '']);

    expect(app(UpdateChannel::class)->state()['agent_seen'])->toBeFalse();
});
