<?php

namespace App\Services;

use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Http;
use ZipArchive;

/**
 * One-click in-app updates. No vendored Zip facade / Guzzle wrapper: the
 * Laravel HTTP client streams the download (so Http::fake covers it in tests)
 * and PHP's ZipArchive extracts it.
 *
 * Contract (confirmed live against the update hub + verify server):
 *   - manifest:  GET {update.manifest_url} (public S3)
 *                → {version, archive, description, release_date}
 *   - download:  GET {update.download_url}/{purchaseCode}/{archive} (gated by
 *                support window) → {type, url, version:{file_name}} where `url`
 *                is a short-lived signed S3 link to the actual ZIP.
 *
 * The license is perpetual; the SUPPORT window only gates one-click updates
 * (Envato support === updates). Lapsed support → block one-click, point at the
 * manual-update doc. The app never locks on support expiry — see License.
 *
 * SHARED-HOSTING NOTE: the download is streamed to disk (never buffered in
 * memory) and the install extracts over base_path() in a single request — the
 * same self-overwrite the whole CodeCanyon ecosystem relies on (PHP has already
 * loaded the running request's classes, so rewriting their files is safe;
 * migrations run after). The release archive MUST NOT contain `.env` or
 * `storage/` so buyer config + uploads survive the extract.
 */
class Updater
{
    /**
     * Where streamed update archives + the download-progress file live.
     */
    private static function workDir(): string
    {
        $dir = storage_path('app/updates');

        if (! File::isDirectory($dir)) {
            File::makeDirectory($dir, 0775, true);
        }

        return $dir;
    }

    private static function progressFile(): string
    {
        return self::workDir() . '/percent.txt';
    }

    /**
     * Written by extract() once the archive is fully unpacked — proof the
     * extraction completed even if the extract request itself was killed
     * mid-flight (it overwrites its own running code). finalize() polls for it.
     * Lives under storage/, which the update archive never touches, so the
     * extract can't clobber it.
     */
    private static function markerFile(): string
    {
        return self::workDir() . '/extracted.txt';
    }

    /**
     * Installed version — the canonical `VERSION` file at the project root
     * (the same file License reads + the verify payload sends).
     */
    public static function currentVersion(): string
    {
        $raw = @file_get_contents(base_path('VERSION'));

        return trim($raw ?: '') ?: '0.0.0';
    }

    /**
     * Fetch the public release manifest. Returns null on any network/parse
     * failure (the UI degrades to "couldn't check" rather than erroring).
     *
     * @return array{version: string, archive: string, description: ?string, release_date: ?string}|null
     */
    public static function manifest(): ?array
    {
        try {
            $response = Http::acceptJson()->timeout(15)->get(config('license.update.manifest_url'));
        } catch (\Throwable) {
            return null;
        }

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

        $body = $response->json();

        if (! is_array($body) || empty($body['version']) || empty($body['archive'])) {
            return null;
        }

        return [
            'version' => (string) $body['version'],
            'archive' => (string) $body['archive'],
            'description' => isset($body['description']) ? (string) $body['description'] : null,
            'release_date' => isset($body['release_date']) ? (string) $body['release_date'] : null,
        ];
    }

    /**
     * True when the buyer's Envato support window is still open. Null/unknown
     * supported_until counts as lapsed (we can't confirm = no one-click).
     */
    public static function supportActive(): bool
    {
        $until = Settings::get('license.supported_until');

        if (empty($until)) {
            return false;
        }

        try {
            return Carbon::parse($until)->endOfDay()->isFuture();
        } catch (\Throwable) {
            return false;
        }
    }

    /**
     * Local-only status (no network) for the settings page payload. The remote
     * check is a separate, explicit user action (`check()`), never on page load.
     *
     * @return array{current_version: string, supported_until: ?string, support_active: bool, licensed: bool, product_url: string}
     */
    public static function localState(): array
    {
        return [
            'current_version' => self::currentVersion(),
            'supported_until' => Settings::get('license.supported_until'),
            'support_active' => self::supportActive(),
            'licensed' => License::isLicensed(),
            'product_url' => (string) config('license.product_url'),
        ];
    }

    /**
     * Full status incl. a live manifest fetch — the "Check for updates" action.
     *
     * @return array{current_version: string, latest_version: ?string, available: bool, archive: ?string, description: ?string, release_date: ?string, support_active: bool, can_one_click: bool, checked: bool}
     */
    public static function check(): array
    {
        $current = self::currentVersion();
        $manifest = self::manifest();
        $supportActive = self::supportActive();

        if ($manifest === null) {
            return [
                'current_version' => $current,
                'latest_version' => null,
                'available' => false,
                'archive' => null,
                'description' => null,
                'release_date' => null,
                'support_active' => $supportActive,
                'can_one_click' => false,
                'checked' => false,
            ];
        }

        $available = version_compare($manifest['version'], $current, '>');

        return [
            'current_version' => $current,
            'latest_version' => $manifest['version'],
            'available' => $available,
            'archive' => $manifest['archive'],
            'description' => $manifest['description'],
            'release_date' => $manifest['release_date'],
            'support_active' => $supportActive,
            'can_one_click' => $available && $supportActive && License::isLicensed(),
            'checked' => true,
        ];
    }

    /**
     * Resolve the gated, short-lived signed download URL for an archive. Needs
     * the activated purchase code; the server only returns it when support is
     * open.
     *
     * @return array{ok: bool, url?: string, message?: string}
     */
    public static function resolveDownloadUrl(string $archive): array
    {
        $code = Settings::get('license.purchase_code');

        if (empty($code)) {
            return ['ok' => false, 'message' => 'This installation is not activated.'];
        }

        $url = rtrim((string) config('license.update.download_url'), '/')
            . '/' . rawurlencode((string) $code)
            . '/' . rawurlencode($archive);

        try {
            $response = Http::acceptJson()->timeout(20)->get($url);
        } catch (\Throwable) {
            return ['ok' => false, 'message' => 'Could not reach the update server. Please try again.'];
        }

        $body = $response->json();

        if ($response->successful() && is_array($body) && ! empty($body['url'])) {
            return ['ok' => true, 'url' => (string) $body['url']];
        }

        $message = is_array($body) && ! empty($body['message'])
            ? (string) $body['message']
            : 'The update download could not be authorized. Your support window may have lapsed.';

        return ['ok' => false, 'message' => $message];
    }

    /**
     * Stream the archive to storage/app/updates, writing percent progress to a
     * file the UI polls. Validates the result is a real ZIP.
     *
     * @return array{ok: bool, path?: string, message?: string}
     */
    public static function download(string $archive): array
    {
        self::relaxPhpLimits();
        File::put(self::progressFile(), '0');

        $resolved = self::resolveDownloadUrl($archive);

        if (! $resolved['ok']) {
            return ['ok' => false, 'message' => $resolved['message'] ?? 'Download authorization failed.'];
        }

        $target = self::workDir() . '/' . basename($archive);

        if (File::exists($target)) {
            File::delete($target);
        }

        try {
            Http::withOptions([
                'sink' => $target,
                'progress' => function ($downloadTotal, $downloadedBytes) {
                    $percent = $downloadTotal > 0
                        ? round(($downloadedBytes / $downloadTotal) * 100, 1)
                        : 0;
                    File::put(self::progressFile(), (string) $percent);
                },
            ])->timeout(600)->get($resolved['url']);
        } catch (\Throwable $e) {
            return ['ok' => false, 'message' => 'Download failed: ' . $e->getMessage()];
        }

        if (! File::exists($target) || filesize($target) === 0) {
            return ['ok' => false, 'message' => 'Download failed — empty file. Please try again.'];
        }

        $zip = new ZipArchive();
        if ($zip->open($target) !== true) {
            File::delete($target);

            return ['ok' => false, 'message' => 'Downloaded file is not a valid update archive.'];
        }
        $zip->close();

        File::put(self::progressFile(), '100');

        return ['ok' => true, 'path' => $target];
    }

    /**
     * Step 1 of install — extract the archive over base_path() ONLY. Migrations
     * + version stamp happen separately in finalize(), polled until the marker
     * lands. The split mirrors the proven shared-hosting pattern: this request
     * may get its own PHP killed mid-extract (it overwrites the running code),
     * so a separate finalize request finishes the job once the files are down.
     *
     * @return array{ok: bool, message?: string}
     */
    public static function extract(string $archive): array
    {
        self::relaxPhpLimits();

        $target = self::workDir() . '/' . basename($archive);

        if (! File::exists($target)) {
            return ['ok' => false, 'message' => 'Update archive not found. Please download it again.'];
        }

        // Clear a stale marker so finalize() can't fire before this extract done.
        if (File::exists(self::markerFile())) {
            File::delete(self::markerFile());
        }

        $zip = new ZipArchive();
        if ($zip->open($target) !== true) {
            return ['ok' => false, 'message' => 'Could not open the update archive.'];
        }

        if (! $zip->extractTo(base_path())) {
            $zip->close();

            return ['ok' => false, 'message' => 'Failed to extract the update. Check filesystem permissions.'];
        }
        $zip->close();

        // Drop any compiled config/route/event cache from the PRIOR version.
        // This request is still running on the old (booted) code, so unlinking
        // here is safe; the next request (finalize poll) then boots against the
        // freshly-extracted code with no stale cache — which is what stops a
        // newer provider (e.g. Reverb in 1.0.03) fataling on a config key the
        // old cache never had. The bootstrap/app.php VERSION-mtime guard is the
        // belt to this suspenders (covers the FTP/manual update path too).
        $cacheDir = base_path('bootstrap/cache');
        @unlink($cacheDir . '/config.php');
        foreach (glob($cacheDir . '/routes*.php') ?: [] as $routeCache) {
            @unlink($routeCache);
        }
        @unlink($cacheDir . '/events.php');

        // Marker = proof the extract finished (storage/ is untouched by the
        // archive, so this survives). finalize() keys off it.
        File::put(self::markerFile(), basename($archive));

        return ['ok' => true];
    }

    /**
     * Step 2 of install (the checkIfFileExtracted equivalent) — once extract()
     * has dropped its marker, run migrations, stamp the new VERSION (when known
     * — auto updates pass the manifest version; an uploaded ZIP carries its own
     * so we leave it), clear caches, clean up, and audit. Polled by the client:
     * returns {ok:false, extracted:false} while the extract is still running.
     *
     * @return array{ok: bool, extracted: bool, message?: string, version?: string}
     */
    public static function finalize(string $archive, ?string $version = null): array
    {
        $marker = self::markerFile();

        // Extraction not finished (or never started) — keep polling.
        if (! File::exists($marker) || trim((string) File::get($marker)) !== basename($archive)) {
            return ['ok' => false, 'extracted' => false];
        }

        self::relaxPhpLimits();

        // Stamp version when the caller knows it (auto update). The archive
        // should also ship VERSION, so this is defensive.
        if ($version) {
            File::put(base_path('VERSION'), trim($version) . "\n");
        }

        try {
            Artisan::call('migrate', ['--force' => true]);
        } catch (\Throwable $e) {
            return ['ok' => false, 'extracted' => true, 'message' => 'Files updated but migrations failed: ' . $e->getMessage()];
        }

        // Heal installs that predate the public/user-uploads switch — move any
        // legacy storage/app/public files so their media keeps resolving.
        self::relocateLegacyUploads();

        Artisan::call('optimize:clear');

        $target = self::workDir() . '/' . basename($archive);
        foreach ([$target, $marker, self::progressFile()] as $f) {
            if (File::exists($f)) {
                File::delete($f);
            }
        }

        AuditLogger::record('app.updated', null, 'Application updated', [
            'version' => self::currentVersion(),
        ]);

        return ['ok' => true, 'extracted' => true, 'version' => self::currentVersion()];
    }

    /**
     * Recovery action — run pending migrations.
     * Not support-gated: it's used after a manual FTP overwrite or a half-applied
     * update, regardless of the support window.
     *
     * @return array{ok: bool, message: string}
     */
    public static function runMigrations(): array
    {
        self::relaxPhpLimits();

        try {
            Artisan::call('migrate', ['--force' => true]);
        } catch (\Throwable $e) {
            return ['ok' => false, 'message' => 'Migration failed: ' . $e->getMessage()];
        }

        // Same legacy-uploads heal for the manual-FTP path (buyer clicks this
        // after overwriting files).
        self::relocateLegacyUploads();

        AuditLogger::record('app.migrated', null, 'Database migrated (manual)', []);

        return ['ok' => true, 'message' => 'Database migrated successfully.'];
    }

    /**
     * Move legacy storage/app/public uploads into public/user-uploads for
     * installs that predate the storage switch. The single home for the move
     * logic — called directly by the command, the update paths, AND the
     * boot-time auto-heal (NOT via Artisan::call, which can't resolve app
     * commands from HTTP/boot context). Idempotent, skip-if-exists,
     * best-effort: file ops are guarded so it never throws into a caller.
     *
     * @return array{moved: int, skipped: int, failed: int}
     */
    public static function relocateLegacyUploads(bool $dryRun = false): array
    {
        $old = storage_path('app/public');
        $new = public_path('user-uploads');
        $result = ['moved' => 0, 'skipped' => 0, 'failed' => 0];

        if (! is_dir($old) || realpath($old) === realpath($new)) {
            return $result;
        }

        File::ensureDirectoryExists($new);

        $skip = ['.gitignore', '.htaccess', '.DS_Store', '.gitkeep'];

        // Collect first — moving files while iterating the same tree is unsafe.
        $files = [];
        try {
            $it = new \RecursiveIteratorIterator(
                new \RecursiveDirectoryIterator($old, \FilesystemIterator::SKIP_DOTS),
                \RecursiveIteratorIterator::LEAVES_ONLY
            );
            foreach ($it as $file) {
                if ($file->isFile() && ! in_array($file->getFilename(), $skip, true)) {
                    $files[] = $file->getPathname();
                }
            }
        } catch (\Throwable) {
            return $result;
        }

        foreach ($files as $path) {
            $rel = ltrim(str_replace($old, '', $path), '/\\');
            $target = $new . '/' . $rel;

            if (file_exists($target)) {
                $result['skipped']++;
                continue;
            }
            if ($dryRun) {
                $result['moved']++;
                continue;
            }
            File::ensureDirectoryExists(dirname($target));
            if (@rename($path, $target)) {
                $result['moved']++;
            } elseif (@copy($path, $target)) {
                @unlink($path);
                $result['moved']++;
            } else {
                $result['failed']++;
            }
        }

        // Drain the now-empty legacy subdirs (rename leaves them behind), so
        // the boot-heal's presence check flips to false and stops re-firing.
        if (! $dryRun) {
            self::pruneEmptyDirs($old);
        }

        if (! $dryRun && $result['moved'] > 0) {
            AuditLogger::record('media.relocated', null, 'Legacy uploads moved to public/user-uploads', $result);
        }

        return $result;
    }

    /**
     * Best-effort removal of empty subdirectories under $root (deepest first).
     * @rmdir only succeeds on empty dirs, so populated paths are left intact;
     * $root itself is never targeted (the iterator walks inside it).
     */
    private static function pruneEmptyDirs(string $root): void
    {
        try {
            $it = new \RecursiveIteratorIterator(
                new \RecursiveDirectoryIterator($root, \FilesystemIterator::SKIP_DOTS),
                \RecursiveIteratorIterator::CHILD_FIRST
            );
            foreach ($it as $entry) {
                if ($entry->isDir()) {
                    @rmdir($entry->getPathname());
                }
            }
        } catch (\Throwable) {
            // best-effort
        }
    }

    /**
     * Recovery action — clear + rebuild caches.
     *
     * @return array{ok: bool, message: string}
     */
    public static function clearCaches(): array
    {
        Artisan::call('optimize:clear');

        AuditLogger::record('app.cache_cleared', null, 'Caches cleared (manual)', []);

        return ['ok' => true, 'message' => 'Caches cleared successfully.'];
    }

    /**
     * Current download progress (0-100). Returns 0 when no download is active.
     */
    public static function progress(): float
    {
        $file = self::progressFile();

        if (! File::exists($file)) {
            return 0;
        }

        return (float) trim(File::get($file) ?: '0');
    }

    /**
     * Best-effort lift of execution/memory limits for the long download +
     * extract. Shared hosts may ignore this (hence the streaming approach).
     */
    private static function relaxPhpLimits(): void
    {
        try {
            @set_time_limit(0);
            @ini_set('memory_limit', '512M');
        } catch (\Throwable) {
            // Ignore — locked-down hosts; streaming keeps memory flat anyway.
        }
    }
}
