<?php

namespace App\Livewire\Versions;

use App\Models\AuditEvent;
use App\Services\DeploymentService;
use App\Services\ReleaseChecker;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\RateLimiter;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Url;
use Livewire\Component;

/**
 * Version & releases page. Everything shown here is real: the installed version comes from
 * config, the latest published release is the newest git TAG in the channel — read from the
 * project's public Git host over its read-only tag API (the production image ships no .git, so
 * a local read would always be empty; local .git is only a dev fallback) — and the release
 * history is the project's own CHANGELOG.md parsed BY VERSION. There is no in-app updater
 * (Clusev ships as a Docker image; updating is a host-side image pull + migrate, see the page);
 * the check honestly compares the installed version against the newest tag in the channel.
 */
#[Layout('layouts.app')]
class Index extends Component
{
    /**
     * Cache (Redis in prod) marker for an in-flight update: holds the version that was running
     * when the update was requested. Persisted in Redis — NOT the sentinel file (which the host
     * watcher consumes before update.sh runs) — so the "update läuft" state survives BOTH a page
     * reload and the app-container rebuild. Cleared on completion/timeout; auto-expires as a
     * backstop so a failed update can never wedge the banner. Completion = running version > base.
     */
    private const UPDATE_FLAG = 'clusev:update-in-progress';

    /** Patch releases shown per page within a series — keeps the changelog from endless-scrolling. */
    private const CHANGELOG_PER_PAGE = 8;

    /** Selected major.minor changelog series ('' = auto: the newest). Drives the series rail.
     *  Synced to ?series= so a series is deep-linkable / survives reload + back-button. */
    #[Url(as: 'series')]
    public string $series = '';

    /** 1-based page within the selected series' release list. Synced to ?page= (omitted while 1). */
    #[Url(as: 'page')]
    public int $changelogPage = 1;

    public ?string $lastChecked = null;

    public ?string $updateStatus = null;

    /** 'current' | 'update' | null — drives the banner tone. */
    public ?string $updateState = null;

    /** Set once an update has been requested this session — the stack is rebuilding. */
    public bool $updateRequested = false;

    /** Running version captured WHEN the update was requested — completion = version moved past it. */
    public ?string $updateBaseVersion = null;

    /** Poll ticks since the update request, to time out a stuck/failed update instead of spinning. */
    public int $updatePolls = 0;

    /**
     * On load, in order:
     *  1. If an update is in flight (Redis marker): either announce it just finished (running
     *     version now past the marked base → success toast + clear) or resume the "läuft" state +
     *     poll. This is what makes a reload mid-update show the right thing (the marker lives in
     *     Redis, surviving both the reload and the app rebuild).
     *  2. Otherwise surface an available update straight from the cached check (no network), so the
     *     button shows immediately when the badge already knows a newer version — no re-click.
     */
    public function mount(): void
    {
        $installed = (string) config('clusev.version');
        $marker = Cache::get(self::UPDATE_FLAG);

        if (is_array($marker) && isset($marker['base'])) {
            if (version_compare(ltrim($installed, 'vV'), ltrim((string) $marker['base'], 'vV'), '>')) {
                // Finished while the operator was away (or reloaded) — announce + clear.
                Cache::forget(self::UPDATE_FLAG);
                $this->updateState = 'current';
                $this->updateStatus = __('versions.status_current', ['installed' => $installed, 'channel' => $this->channel()]);
                $this->dispatch('notify', message: __('versions.update_done', ['version' => $installed]));

                return;
            }
            // Still running — resume the notice + polling.
            $this->updateRequested = true;
            $this->updateBaseVersion = (string) $marker['base'];

            return;
        }

        $latest = $this->resolveLatestTag($this->channel()); // cached / local .git — never network on load
        if ($latest !== null && $this->isNewer($latest, $installed)) {
            $this->updateState = 'update';
            $this->updateStatus = __('versions.status_update_available', ['latest' => $latest, 'channel' => $this->channel()]);
        }
    }

    /**
     * Poll while an update runs (driven by wire:poll on the "running" notice). Completion is
     * detected purely from the running version moving PAST the value captured at request time —
     * the rebuilt container reports the new version, so no host round-trip is needed. Times out
     * after ~5 min so a failed update surfaces a hint instead of spinning forever.
     */
    public function pollUpdate(): void
    {
        if (! $this->updateRequested) {
            return;
        }
        $this->updatePolls++;
        $current = (string) config('clusev.version');
        $this->updateBaseVersion ??= $current;

        if (version_compare(ltrim($current, 'vV'), ltrim($this->updateBaseVersion, 'vV'), '>')) {
            Cache::forget(self::UPDATE_FLAG);
            $this->updateRequested = false;
            $this->updatePolls = 0;
            $this->updateState = 'current';
            $this->updateStatus = __('versions.status_current', ['installed' => $current, 'channel' => $this->channel()]);
            $this->dispatch('notify', message: __('versions.update_done', ['version' => $current]));

            return;
        }

        if ($this->updatePolls >= 60) { // ~5 min at 5s — almost certainly failed; stop spinning.
            Cache::forget(self::UPDATE_FLAG);
            $this->updateRequested = false;
            $this->updatePolls = 0;
            $this->dispatch('notify', message: __('versions.update_stalled'), level: 'error');
        }
    }

    /**
     * Auto-check on page load (wire:init) so an available update + button appear without the
     * operator clicking "check" first. No-op while an update is running, so it never clobbers the
     * "läuft"/poll state. Async (wire:init), so it never blocks the initial render.
     */
    public function autoCheck(): void
    {
        if ($this->updateRequested) {
            return;
        }

        $this->checkUpdates();
    }

    /**
     * Honest update check: compare the installed version against the newest release
     * tag visible in the channel. No external server, no stars, no CVE feed.
     */
    public function checkUpdates(): void
    {
        $this->lastChecked = now()->format('H:i');

        $channel = $this->channel();
        $installed = (string) config('clusev.version');
        // Force a fresh remote lookup — this is the explicit "check for updates" action.
        $latest = $this->resolveLatestTag($channel, forceRemote: true);

        if ($latest === null) {
            $this->updateState = 'current';
            $this->updateStatus = __('versions.status_none_tagged', ['installed' => $installed, 'channel' => $channel]);
        } elseif ($this->isNewer($latest, $installed)) {
            $this->updateState = 'update';
            $this->updateStatus = __('versions.status_update_available', ['latest' => $latest, 'channel' => $channel]);
        } else {
            $this->updateState = 'current';
            $this->updateStatus = __('versions.status_current', ['installed' => $installed, 'channel' => $channel]);
        }
    }

    /**
     * Operator-triggered update: write the update sentinel a host watcher reacts to by running
     * update.sh (git pull + idempotent re-install). Re-checks that a newer release than the
     * installed one is actually published in the channel (never fires blindly), is per-user
     * throttled (auto-expiring — never a control-plane lockout) and audited. The container never
     * runs git/Docker itself. The dashboard briefly goes down while the stack rebuilds, so we
     * say so and do not await a result.
     */
    public function requestUpdate(DeploymentService $deployment): mixed
    {
        abort_unless(auth()->user()?->can('manage-panel'), 403);

        $channel = $this->channel();
        $installed = (string) config('clusev.version');
        $latest = $this->resolveLatestTag($channel, forceRemote: true);

        // Reflect the freshly-checked state, then refuse if there is nothing newer to apply.
        $this->lastChecked = now()->format('H:i');
        if ($latest === null || ! $this->isNewer($latest, $installed)) {
            $this->updateState = 'current';
            $this->updateStatus = __('versions.status_current', ['installed' => $installed, 'channel' => $channel]);
            $this->dispatch('notify', message: __('versions.update_not_available'), level: 'error');

            return null;
        }
        $this->updateState = 'update';
        $this->updateStatus = __('versions.status_update_available', ['latest' => $latest, 'channel' => $channel]);

        $key = 'update-request:'.Auth::id();
        if (RateLimiter::tooManyAttempts($key, 3)) {
            $this->dispatch('notify', message: __('versions.update_throttled', ['seconds' => RateLimiter::availableIn($key)]), level: 'error');

            return null;
        }
        RateLimiter::hit($key, 600);

        // Write the sentinel; if it can't be written (e.g. the bind-mounted dir isn't writable by
        // the app user), surface that instead of a "running" state that would never resolve.
        if (! $deployment->requestUpdate()) {
            $this->dispatch('notify', message: __('versions.update_write_failed'), level: 'error');

            return null;
        }
        // Persist the in-progress state in Redis (NOT the consumed sentinel) so the "läuft" notice
        // survives a reload AND the app rebuild; auto-expires as a backstop. Completion = version
        // moves past this base. 15 min TTL > the poll timeout, so a reload can still resume.
        Cache::put(self::UPDATE_FLAG, ['base' => $installed], now()->addMinutes(15));

        AuditEvent::create([
            'user_id' => Auth::id(),
            'actor' => Auth::user()?->name ?? 'system',
            'action' => 'deploy.update_request',
            'target' => $installed.' → '.$latest.' ('.$channel.')',
            'ip' => request()->ip(),
        ]);

        $this->updateRequested = true;
        $this->updateBaseVersion = $installed; // completion = the running version moves past this
        $this->updatePolls = 0;
        $this->dispatch('notify', message: __('versions.update_started'));

        // Redirect to the self-contained progress page BEFORE the container goes down.
        // navigate:false forces a full browser navigation (the page must be Livewire-independent
        // to survive the teardown — see resources/views/update-progress.blade.php).
        $prev = url()->previous();
        $parsedPrev = parse_url($prev, PHP_URL_PATH);
        $returnPath = (is_string($parsedPrev) && str_starts_with($parsedPrev, '/') && ! str_starts_with($parsedPrev, '//'))
            ? $parsedPrev
            : route('dashboard', absolute: false);

        // Pass the pre-update version: the progress page finishes only when the running version
        // moves past it (the old container answers /up throughout the build — see the page).
        return $this->redirect(
            route('update.progress', ['return' => $returnPath, 'from' => $installed]),
            navigate: false,
        );
    }

    /** Resolve the configured channel, clamped to the user-facing set. */
    private function channel(): string
    {
        return app(ReleaseChecker::class)->channel();
    }

    /**
     * Newest release tag for the channel. Prefers the public Git host's tag API (the
     * production image ships NO .git, so a local read would always be empty there), and
     * falls back to local .git tags for dev. The remote result is cached so a page render
     * never makes a network call — only the explicit "check for updates" action does, by
     * passing $forceRemote. Returns null when nothing usable is found anywhere.
     */
    private function resolveLatestTag(string $channel, bool $forceRemote = false): ?string
    {
        $checker = app(ReleaseChecker::class);

        if ($forceRemote) {
            $remote = $checker->refresh($channel);
            if ($remote !== null) {
                return $remote;
            }
            // Remote unreachable — fall through to whatever we last cached / can read locally.
        }

        return $checker->cachedLatest($channel) ?? $this->localLatestTag($channel);
    }

    /**
     * Newest release tag from local .git, without needing the git binary. Reads loose refs
     * under refs/tags plus packed-refs. Empty in the production image (no .git) — that is why
     * the remote API is the primary source above.
     */
    private function localLatestTag(string $channel): ?string
    {
        $root = base_path('.git');
        $tags = [];

        $dir = $root.'/refs/tags';
        if (is_dir($dir)) {
            foreach (scandir($dir) ?: [] as $entry) {
                if ($entry !== '.' && $entry !== '..' && is_file($dir.'/'.$entry)) {
                    $tags[] = $entry;
                }
            }
        }

        foreach (preg_split('/\R/', (string) @file_get_contents($root.'/packed-refs')) as $line) {
            if (preg_match('#\srefs/tags/(\S+)$#', trim($line), $m)) {
                $tags[] = $m[1];
            }
        }

        return app(ReleaseChecker::class)->newestVersion($tags, $channel);
    }

    /** Is the given tag a newer semantic version than the installed one? */
    private function isNewer(string $tag, string $installed): bool
    {
        return version_compare(ltrim($tag, 'vV'), ltrim($installed, 'vV'), '>');
    }

    /**
     * Resolve the deployed commit + branch. In production this comes from config (install.sh
     * baked it into .env from the host's .git — the prod image ships none); in dev it falls back
     * to a live .git read. Branch defaults to the channel only when nothing is known.
     */
    private function build(): array
    {
        $sha = config('clusev.build.sha');
        $branch = config('clusev.build.branch');
        if ($sha || $branch) {
            return ['sha' => $sha, 'branch' => $branch ?: $this->channel()];
        }

        $root = base_path('.git');
        $head = @file_get_contents($root.'/HEAD');

        if ($head === false) {
            return ['sha' => null, 'branch' => $this->channel()];
        }

        $head = trim($head);

        if (! str_starts_with($head, 'ref: ')) {
            return ['sha' => substr($head, 0, 7), 'branch' => $this->channel()];
        }

        $ref = substr($head, 5);
        $branch = preg_replace('#^refs/heads/#', '', $ref);
        $sha = @file_get_contents($root.'/'.$ref);

        if ($sha === false) {
            // packed-refs fallback
            foreach (preg_split('/\R/', (string) @file_get_contents($root.'/packed-refs')) as $line) {
                if (str_ends_with(trim($line), ' '.$ref)) {
                    $sha = explode(' ', trim($line))[0];
                    break;
                }
            }
        }

        return ['sha' => $sha ? substr(trim($sha), 0, 7) : null, 'branch' => $branch];
    }

    /** Switch the visible major.minor series and jump back to its first page. */
    public function selectSeries(string $series): void
    {
        $this->series = $series;
        $this->changelogPage = 1;
    }

    /** Jump to a page within the active series (lower bound clamped here; upper clamp in render). */
    public function gotoChangelogPage(int $page): void
    {
        $this->changelogPage = max(1, $page);
    }

    /**
     * Group parsed releases into major.minor series (e.g. "0.9"), newest series first. Each series
     * keeps its releases (still newest-first) plus a count and the latest date. An Unreleased node,
     * if present, becomes its own pseudo-series pinned to the top. This is what lets the changelog
     * show a compact series index + paginated detail instead of one endless list.
     *
     * @param  array<int, array{version:string, date:?string, unreleased:bool, groups:array<string, array<int, string>>}>  $releases
     * @return array<int, array{key:string, label:string, unreleased:bool, count:int, latest:?string, releases:array<int, array<string, mixed>>}>
     */
    private function groupBySeries(array $releases): array
    {
        $buckets = [];
        foreach ($releases as $rel) {
            if ($rel['unreleased']) {
                $key = 'unreleased';
            } elseif (preg_match('/^v?(\d+)\.(\d+)\./', $rel['version'], $m)) {
                // Prefix the series key with a non-numeric 'v' so the ?series= URL value is never a
                // bare decimal — Livewire's #[Url] otherwise type-juggles "0.10" → 0.1 (the trailing
                // zero is lost), silently switching the selected series on reload. The label strips it.
                $key = 'v'.$m[1].'.'.$m[2];
            } else {
                $key = 'other';
            }
            $buckets[$key][] = $rel;
        }

        $series = [];
        foreach ($buckets as $key => $rels) {
            $dates = array_values(array_filter(array_map(static fn (array $r): ?string => $r['date'], $rels)));
            $series[] = [
                'key' => $key,
                'label' => match ($key) {
                    'unreleased' => __('versions.unreleased'),
                    'other' => __('versions.series_other'),
                    default => ltrim($key, 'v'), // display the dotted series (the 'v' is only for the URL)
                },
                'unreleased' => $key === 'unreleased',
                'count' => count($rels),
                'latest' => $dates[0] ?? null, // releases are newest-first → the first date is the latest
                'releases' => $rels,
            ];
        }

        // Unreleased first, then series by version descending, 'other' last.
        usort($series, static function (array $a, array $b): int {
            $rank = static fn (array $s): int => $s['key'] === 'unreleased' ? 0 : ($s['key'] === 'other' ? 2 : 1);
            $ra = $rank($a);
            $rb = $rank($b);
            if ($ra !== $rb) {
                return $ra <=> $rb;
            }

            return $ra === 1 ? version_compare(ltrim($b['key'], 'v').'.0', ltrim($a['key'], 'v').'.0') : 0;
        });

        return $series;
    }

    /**
     * Compact pagination window — the full range up to 7 pages, otherwise first + last plus a
     * 3-wide window around the current page with '…' gaps. Returns ints and '…' string markers.
     *
     * @return array<int, int|string>
     */
    private function pageWindow(int $current, int $total): array
    {
        if ($total <= 7) {
            return range(1, $total);
        }

        $pages = [1];
        $from = max(2, $current - 1);
        $to = min($total - 1, $current + 1);
        if ($from > 2) {
            $pages[] = '…';
        }
        for ($i = $from; $i <= $to; $i++) {
            $pages[] = $i;
        }
        if ($to < $total - 1) {
            $pages[] = '…';
        }
        $pages[] = $total;

        return $pages;
    }

    /**
     * Parse CHANGELOG.md BY VERSION into release nodes. Each node carries its
     * version, optional date, and the changes grouped by Keep-a-Changelog
     * subsection (Hinzugefügt / Geändert / Behoben / Sicherheit / …), newest
     * first (Unreleased on top).
     *
     * @return array<int, array{version:string, date:?string, unreleased:bool, groups:array<string, array<int, string>>}>
     */
    private function releases(): array
    {
        $path = base_path('CHANGELOG.md');
        if (! is_file($path)) {
            return [];
        }

        return $this->parseChangelog((string) file_get_contents($path));
    }

    /**
     * Parse a Keep-a-Changelog document into release nodes (shared by the local history and the
     * remote "what's new" preview).
     *
     * @return array<int, array{version:string, date:?string, unreleased:bool, groups:array<string, array<int, string>>}>
     */
    private function parseChangelog(string $content): array
    {
        $releases = [];
        $current = null;
        $group = null;

        foreach (preg_split('/\R/', $content) as $line) {
            // Version header: "## [0.1.0] - 2026-06-13" or "## [Unreleased]"
            if (preg_match('/^##\s+\[([^\]]+)\](?:\s*-\s*(.+))?\s*$/', $line, $m)) {
                if ($current !== null) {
                    $releases[] = $current;
                }
                $version = trim($m[1]);
                $unreleased = strcasecmp($version, 'Unreleased') === 0;
                $current = [
                    'version' => $version,
                    'date' => isset($m[2]) ? trim($m[2]) : null,
                    'unreleased' => $unreleased,
                    'groups' => [],
                ];
                $group = null;

                continue;
            }

            if ($current === null) {
                continue;
            }

            // Subsection header: "### Hinzugefügt"
            if (preg_match('/^###\s+(.+?)\s*$/', $line, $m)) {
                $group = trim($m[1]);
                $current['groups'][$group] ??= [];

                continue;
            }

            // Bullet line under the active subsection (handles wrapped continuations).
            if ($group !== null && preg_match('/^\s*-\s+(.+?)\s*$/', $line, $m)) {
                $current['groups'][$group][] = trim($m[1]);

                continue;
            }
            if ($group !== null && $current['groups'][$group] !== []
                && preg_match('/^\s{2,}(\S.*)$/', $line, $m)) {
                // Continuation of the previous bullet (Keep-a-Changelog wrapping).
                $last = array_key_last($current['groups'][$group]);
                $current['groups'][$group][$last] .= ' '.trim($m[1]);
            }
        }

        if ($current !== null) {
            $releases[] = $current;
        }

        // Drop empty nodes (e.g. a bare Unreleased) and link-reference tails.
        return array_values(array_filter($releases, fn (array $r): bool => $r['groups'] !== []));
    }

    /**
     * The changelog entries the operator would GAIN by updating: the remote CHANGELOG.md at the
     * latest tag, parsed and filtered to released versions newer than the installed one — so the
     * "what's new" can be read BEFORE applying the update. Empty when there is no update or the
     * fetch fails (degrade gracefully; the update button is unaffected).
     *
     * @return array<int, array{version:string, date:?string, unreleased:bool, groups:array<string, array<int, string>>}>
     */
    private function availableChangelog(string $installed, ?string $latestTag): array
    {
        if ($latestTag === null || ! $this->isNewer($latestTag, $installed)) {
            return [];
        }

        // Tags are published as "vX.Y.Z" but $latestTag is the bare version — ref must carry the "v".
        $content = app(ReleaseChecker::class)->fetchChangelog('v'.ltrim($latestTag, 'vV'));
        if ($content === null) {
            return [];
        }

        $inst = ltrim($installed, 'vV');

        return array_values(array_filter(
            $this->parseChangelog($content),
            static fn (array $r): bool => ! $r['unreleased']
                && preg_match('/^\d+\.\d+\.\d+/', $r['version']) === 1
                && version_compare(ltrim($r['version'], 'vV'), $inst, '>'),
        ));
    }

    /**
     * Links shown on the "Projekt" card. EVERY install lists the public open-core repo. The
     * dev/release-control box ALSO lists its actual update source first (which may be the private
     * upstream) — staging and stable never do, so a non-dev server can never expose a private source
     * regardless of how its CLUSEV_REPOSITORY is set. (The update SOURCE is still
     * config('clusev.repository'); this only governs the displayed links.)
     *
     * release_controls=true is the dev-box invariant: it also gates the dev-only /release page + the
     * host release bridge, so a staging/stable box never has it on — and even if it did, that box's
     * CLUSEV_REPOSITORY is the public repo anyway. The public link is built from the public slug
     * ONLY, and a malformed slug (empty / slashed / a full URL) falls back to the canonical public
     * repo — never to a private host.
     *
     * @return list<array{url: string, host: string, path: string}>
     */
    private function projectLinks(): array
    {
        $slug = trim((string) config('clusev.public_slug'), '/');
        if (! preg_match('#^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$#', $slug)) {
            $slug = 'clusev/clusev';
        }
        $urls = ['https://github.com/'.$slug];

        // Dev box only: ALSO surface the actual update source (e.g. the private upstream), shown first.
        // Require a real URL (a parseable host) so a misconfigured/garbage source is dropped rather
        // than rendered as an empty link — the public repo still shows.
        if (config('clusev.release_controls')) {
            $source = (string) config('clusev.repository');
            if (parse_url($source, PHP_URL_HOST) !== null && ! in_array($source, $urls, true)) {
                array_unshift($urls, $source);
            }
        }

        return array_map(static fn (string $u): array => [
            'url' => $u,
            'host' => (string) parse_url($u, PHP_URL_HOST),
            'path' => trim((string) parse_url($u, PHP_URL_PATH), '/'),
        ], $urls);
    }

    public function render()
    {
        $releases = $this->releases();
        $seriesList = $this->groupBySeries($releases);

        // Active series: keep the operator's choice while it still exists, else the newest.
        $keys = array_column($seriesList, 'key');
        $activeKey = in_array($this->series, $keys, true) ? $this->series : ($keys[0] ?? '');
        $activeSeries = null;
        foreach ($seriesList as $s) {
            if ($s['key'] === $activeKey) {
                $activeSeries = $s;
                break;
            }
        }

        // Paginate the active series' releases so a long history never becomes an endless scroll.
        $activeReleases = $activeSeries['releases'] ?? [];
        $totalPages = max(1, (int) ceil(count($activeReleases) / self::CHANGELOG_PER_PAGE));
        $page = min(max(1, $this->changelogPage), $totalPages);
        $this->changelogPage = $page; // make render the single clamp authority — no stale out-of-range value lingers
        $pagedReleases = array_slice($activeReleases, ($page - 1) * self::CHANGELOG_PER_PAGE, self::CHANGELOG_PER_PAGE);

        // Mark the series that holds the running version (a small "installed" cue in the rail).
        $installed = ltrim((string) config('clusev.version'), 'vV');
        $currentSeries = preg_match('/^(\d+)\.(\d+)\./', $installed, $m) ? 'v'.$m[1].'.'.$m[2] : null;

        // Passive (no network): cached remote result if a check has run, else local .git.
        $latestTag = $this->resolveLatestTag($this->channel());
        // A stale cache can hold a "latest" older than what's now installed (e.g. right after an
        // update, before the next check). Never present yourself as behind a release you've
        // already passed — hide it until a fresh check resolves the true latest.
        if ($latestTag !== null && version_compare(ltrim($latestTag, 'vV'), $installed, '<')) {
            $latestTag = null;
        }

        return view('livewire.versions.index', [
            'version' => config('clusev.version'),
            'channel' => $this->channel(),
            'repositories' => $this->projectLinks(),
            'license' => config('clusev.license'),
            'build' => $this->build(),
            'seriesList' => $seriesList,
            'activeKey' => $activeKey,
            'activeSeries' => $activeSeries,
            'pagedReleases' => $pagedReleases,
            'changelogPage' => $page,
            'totalPages' => $totalPages,
            'pageWindow' => $this->pageWindow($page, $totalPages),
            'currentSeries' => $currentSeries,
            'totalReleases' => count($releases),
            'latestTag' => $latestTag,
            // What the operator would gain by updating — shown BEFORE applying (only when behind).
            'availableUpdates' => $this->updateState === 'update'
                ? $this->availableChangelog((string) config('clusev.version'), $latestTag)
                : [],
        ])->title(__('versions.title'));
    }
}
