<?php

namespace App\Services;

use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;

/**
 * Resolves the newest published release tag for the configured channel and tells whether the
 * installed version is behind it. Shared by the Versions page (which forces a fresh check on the
 * explicit action) and by the global sidebar update badge + the scheduled refresh (which read /
 * warm a short-lived cache so no page render or sidebar ever makes a network call).
 *
 * Anonymous + read-only against the public Git host's tag API — no token ever leaves the panel.
 */
class ReleaseChecker
{
    /** Cache the resolved tag long enough that the scheduled refresh keeps it continuously warm. */
    private const TTL_MINUTES = 60;

    private function cacheKey(string $channel): string
    {
        return 'clusev:latest-release:'.$this->repoTag().':'.$channel;
    }

    /**
     * Short discriminator of the configured repository, mixed into every cache key so a cached
     * tag/changelog can never be reused across a host/repo change (e.g. the promotion pipeline
     * re-points CLUSEV_REPOSITORY from the private mirror to the public repo).
     */
    private function repoTag(): string
    {
        return substr(md5((string) config('clusev.repository')), 0, 8);
    }

    public function channel(): string
    {
        return 'stable';
    }

    /** Last resolved latest tag (no network). Null when unknown. */
    public function cachedLatest(?string $channel = null): ?string
    {
        $value = Cache::get($this->cacheKey($channel ?? $this->channel()));

        return is_string($value) && $value !== '' ? $value : null;
    }

    /**
     * Whether a newer release than the installed version is known from the cache (no network) — the
     * single source for the sidebar badge. A stale cache below the installed version never counts.
     */
    public function updateAvailable(): bool
    {
        $latest = $this->cachedLatest();
        if ($latest === null) {
            return false;
        }

        return version_compare(ltrim($latest, 'vV'), ltrim((string) config('clusev.version'), 'vV'), '>');
    }

    /**
     * Force a fresh remote lookup, cache it, and return the newest tag for the channel. Null on any
     * failure (offline, non-2xx, malformed) so callers degrade gracefully.
     */
    public function refresh(?string $channel = null): ?string
    {
        $channel ??= $this->channel();
        $tag = $this->fetchRemoteLatestTag($channel);
        if ($tag !== null) {
            Cache::put($this->cacheKey($channel), $tag, now()->addMinutes(self::TTL_MINUTES));
        }

        return $tag;
    }

    /**
     * Parse the configured repository URL into [base, owner, name], or null when unset/unparseable.
     * The single place that understands the repository URL — both the tag API and the raw-file
     * (changelog) endpoint derive from it, so a new host is taught in exactly one spot.
     *
     * @return array{0: string, 1: string, 2: string}|null
     */
    private function repoParts(): ?array
    {
        $repo = (string) config('clusev.repository');
        if (! preg_match('#^(https?://[^/]+)/([^/]+)/([^/]+?)(?:\.git)?/?$#', $repo, $m)) {
            return null;
        }

        return [$m[1], $m[2], $m[3]]; // base, owner, name
    }

    /** Is the configured host GitHub? GitHub's API + raw host differ from a self-hosted git host. */
    private function isGithubHost(string $base): bool
    {
        return (bool) preg_match('#^https?://(www\.)?github\.com/?$#i', $base);
    }

    /**
     * Newest tag from the host's tag API. Self-hosted: <base>/api/v1/repos/<o>/<r>/tags?limit=.
     * GitHub: https://api.github.com/repos/<o>/<r>/tags?per_page= (a User-Agent is mandatory there).
     * Returns the version WITHOUT the leading "v" (e.g. "0.9.50"); null on any failure.
     */
    public function fetchRemoteLatestTag(string $channel): ?string
    {
        $parts = $this->repoParts();
        if ($parts === null) {
            return null;
        }
        [$base, $owner, $name] = $parts;

        $github = $this->isGithubHost($base);
        $url = $github
            ? "https://api.github.com/repos/{$owner}/{$name}/tags"
            : "{$base}/api/v1/repos/{$owner}/{$name}/tags";

        try {
            $res = Http::timeout(5)->acceptJson()
                ->withHeaders(['User-Agent' => 'Clusev-Panel']) // GitHub rejects UA-less requests
                ->get($url, $github ? ['per_page' => 50] : ['limit' => 50]);

            if (! $res->successful()) {
                return null;
            }

            $names = array_map(
                static fn ($t): string => is_array($t) ? (string) ($t['name'] ?? '') : '',
                (array) $res->json(),
            );

            return $this->newestVersion($names, $channel);
        } catch (\Throwable $e) {
            report($e);

            return null;
        }
    }

    /**
     * Fetch CHANGELOG.md at a ref (tag) from the configured host, for the "what's new" preview.
     * Self-hosted: the /raw API with ?ref=. GitHub: https://raw.githubusercontent.com/<o>/<r>/<ref>/…
     * Cached briefly so a page render never repeats the call. Null on any failure (degrade quietly).
     */
    public function fetchChangelog(string $ref): ?string
    {
        $parts = $this->repoParts();
        if ($parts === null) {
            return null;
        }
        [$base, $owner, $name] = $parts;

        $key = 'clusev:remote-changelog:'.$this->repoTag().':'.$ref;
        $cached = Cache::get($key);
        if (is_string($cached)) {
            return $cached;
        }

        try {
            $res = $this->isGithubHost($base)
                ? Http::timeout(5)->withHeaders(['User-Agent' => 'Clusev-Panel'])
                    ->get("https://raw.githubusercontent.com/{$owner}/{$name}/{$ref}/CHANGELOG.md")
                : Http::timeout(5)->get("{$base}/api/v1/repos/{$owner}/{$name}/raw/CHANGELOG.md", ['ref' => $ref]);

            if (! $res->successful()) {
                return null;
            }

            $body = $res->body();
            Cache::put($key, $body, now()->addMinutes(10)); // only successes are cached

            return $body;
        } catch (\Throwable $e) {
            report($e);

            return null;
        }
    }

    /**
     * Highest version among the tag names, honouring the channel (stable rejects prereleases).
     * Returns the version without the "v" prefix.
     *
     * @param  array<int, string>  $tagNames
     */
    public function newestVersion(array $tagNames, string $channel): ?string
    {
        $pattern = '/^v?\d+\.\d+\.\d+$/';

        $versions = [];
        foreach (array_unique($tagNames) as $tag) {
            $tag = trim((string) $tag);
            if ($tag !== '' && preg_match($pattern, $tag)) {
                $versions[] = ltrim($tag, 'vV');
            }
        }

        if ($versions === []) {
            return null;
        }

        usort($versions, fn (string $a, string $b): int => version_compare($b, $a));

        return $versions[0];
    }
}
