<?php

namespace App\Services\System;

use App\Models\Module;
use App\Models\ModuleUpdateLog;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use ZipArchive;

/**
 * ModuleUpdater
 *
 * Handles checking for and applying updates to installed modules.
 *
 * Update data flows through the main UpdateService::checkForUpdates() call —
 * ChargePanda-site returns a `modules` key alongside the core update response.
 * checkAll() reads that cached data; no extra HTTP calls are made.
 *
 * check() remains available for on-demand single-module checks (admin panel).
 *
 * Apply flow (atomic):
 *   1. Download ZIP to a temp file
 *   2. Verify SHA256 checksum
 *   3. Extract to a staging directory
 *   4. Rename current module dir → backup dir (atomic move)
 *   5. Move staging dir → production dir
 *   6. Run migrations
 *   7. Update modules table
 *   8. Log success
 *   On any failure between steps 4–7: restore backup dir and log rollback.
 */
class ModuleUpdater
{
    private const DL_TIMEOUT = 120; // seconds for ZIP download

    // -------------------------------------------------------------------------
    // Check for updates
    // -------------------------------------------------------------------------

    /**
     * Return update status for all installed modules, keyed by slug.
     *
     * Data comes from the cached response of the last UpdateService::checkForUpdates()
     * call rather than making individual HTTP requests per module.
     *
     * @return array<string, array> Keyed by slug
     */
    public function checkAll(): array
    {
        $cached    = app(UpdateService::class)->getModuleUpdateCache();
        $installed = Module::all()->keyBy('slug');
        $results   = [];

        foreach ($cached as $slug => $data) {
            $module = $installed->get($slug);

            if (! $module) {
                continue;
            }

            $results[$slug] = [
                'slug'             => $slug,
                'current_version'  => $module->version ?? '0.0.0',
                'latest_version'   => $data['version'] ?? null,
                'update_available' => (bool) ($data['update_available'] ?? false),
                'download_url'     => $data['download_url'] ?? null,
                'checksum'         => $data['checksum'] ?? null,
                'changelog'        => $data['changelog'] ?? null,
                'entitled'         => (bool) ($data['entitled'] ?? false),
                'support_active'   => (bool) ($data['support_active'] ?? false),
                'support_expires_at' => $data['support_expires_at'] ?? null,
                'error'            => $data['error'] ?? null,
            ];
        }

        return $results;
    }

    // -------------------------------------------------------------------------
    // Install new module
    // -------------------------------------------------------------------------

    /**
     * Download, verify, extract, and install a module from a signed URL.
     *
     * Used for first-time installations from the marketplace. Unlike apply(),
     * there is no existing production directory to back up or restore.
     *
     * @param  string   $downloadUrl  Signed URL to the module ZIP
     * @param  string   $checksum     Expected checksum in "sha256:<hex>" format
     * @param  string   $version      Version being installed (from catalog)
     * @param  string   $slug         Module slug (used for staging dir naming and logging)
     * @param  int|null $initiatedBy  Admin user ID (nullable for CLI use)
     * @return Module                  Newly created / updated Module record
     *
     * @throws \RuntimeException on failure (partial files cleaned up automatically)
     */
    public function installFromUrl(
        string  $downloadUrl,
        string  $checksum,
        string  $version,
        string  $slug,
        ?int    $initiatedBy = null
    ): Module {
        $log = ModuleUpdateLog::create([
            'module_slug'  => $slug,
            'from_version' => null,
            'to_version'   => $version,
            'status'       => 'pending',
            'initiated_by' => $initiatedBy,
        ]);

        $tempZip    = null;
        $stagingDir = null;
        $destDir    = null;

        try {
            $log->update(['status' => 'downloading']);
            $tempZip = $this->downloadZip($downloadUrl);

            $log->update(['status' => 'verifying']);
            $this->verifyChecksum($tempZip, $checksum);

            $stagingDir = $this->extractToStaging($tempZip, $slug);

            $manifest  = $this->readManifest($stagingDir);
            $directory = $this->directoryFromManifest($manifest, $slug);
            $destDir   = ModuleLoader::modulesPath($directory);

            if (File::isDirectory($destDir)) {
                throw new \RuntimeException(
                    "Module directory [{$directory}] already exists. Use the update flow to upgrade an installed module."
                );
            }

            $log->update(['status' => 'applying']);
            File::moveDirectory($stagingDir, $destDir);
            $stagingDir = null; // moved — no cleanup needed

            $this->runMigrationsForDirectory($directory);

            $module = ModuleLoader::install($destDir);
            ModuleLoader::linkAssets($directory);

            $log->markCompleted();

            Log::info("ModuleUpdater: {$slug} installed at v{$version}.");

            return $module;

        } catch (\Throwable $e) {
            if ($destDir && File::isDirectory($destDir)) {
                try { File::deleteDirectory($destDir); } catch (\Throwable) {}
            }
            $log->markFailed($e->getMessage());

            throw new \RuntimeException(
                "Module install failed for [{$slug}]: " . $e->getMessage(),
                0,
                $e
            );
        } finally {
            if ($tempZip && file_exists($tempZip)) {
                @unlink($tempZip);
            }
            if ($stagingDir && File::isDirectory($stagingDir)) {
                File::deleteDirectory($stagingDir);
            }
        }
    }

    // -------------------------------------------------------------------------
    // Apply update
    // -------------------------------------------------------------------------

    /**
     * Download, verify, and apply a module update atomically.
     *
     * @param  Module  $module      The module to update
     * @param  string  $downloadUrl Where to fetch the ZIP from
     * @param  string  $checksum    Expected SHA256 in format "sha256:<hex>"
     * @param  string  $toVersion   The version being installed
     * @param  string  $changelog   Release notes (stored in update log)
     * @param  int|null $initiatedBy Admin user ID (nullable for CLI/scheduler use)
     *
     * @throws \RuntimeException on unrecoverable failure
     */
    public function apply(
        Module  $module,
        string  $downloadUrl,
        string  $checksum,
        string  $toVersion,
        string  $changelog    = '',
        ?int    $initiatedBy  = null
    ): void {
        $log = ModuleUpdateLog::create([
            'module_slug'  => $module->slug,
            'from_version' => $module->version,
            'to_version'   => $toVersion,
            'status'       => 'pending',
            'changelog'    => $changelog,
            'initiated_by' => $initiatedBy,
        ]);

        $tempZip    = null;
        $stagingDir = null;
        $backupDir  = null;
        $prodDir    = null;

        try {
            // Step 1: Download.
            $log->update(['status' => 'downloading']);
            $tempZip = $this->downloadZip($downloadUrl);

            // Step 2: Verify checksum.
            $log->update(['status' => 'verifying']);
            $this->verifyChecksum($tempZip, $checksum);

            // Step 3: Extract to staging.
            $stagingDir = $this->extractToStaging($tempZip, $module->slug);

            // Step 4: Atomic swap.
            $log->update(['status' => 'applying']);
            $prodDir   = base_path('modules/' . $this->moduleDirectory($module));
            $backupDir = $prodDir . '_backup_' . time();

            if (File::isDirectory($prodDir)) {
                // Move production → backup (fast rename on same filesystem).
                File::moveDirectory($prodDir, $backupDir);
            }

            // Move staging → production.
            File::moveDirectory($stagingDir, $prodDir);
            $stagingDir = null; // moved, no longer needs cleanup

            // Step 5: Run migrations.
            $this->runMigrations($module, $prodDir);

            // Step 6: Update modules table.
            $module->update(['version' => $toVersion]);

            // Step 7: Clean up backup and log success.
            if ($backupDir && File::isDirectory($backupDir)) {
                File::deleteDirectory($backupDir);
            }

            $log->markCompleted($changelog);

            Log::info("ModuleUpdater: {$module->slug} updated from {$module->version} to {$toVersion}.");

        } catch (\Throwable $e) {
            // Attempt rollback if we have a backup.
            if ($backupDir && $prodDir && File::isDirectory($backupDir)) {
                try {
                    if (File::isDirectory($prodDir)) {
                        File::deleteDirectory($prodDir);
                    }
                    File::moveDirectory($backupDir, $prodDir);
                    $log->markRolledBack('Rolled back after error: ' . $e->getMessage());
                } catch (\Throwable $rollbackEx) {
                    Log::critical('ModuleUpdater: rollback failed for ' . $module->slug, [
                        'original_error' => $e->getMessage(),
                        'rollback_error' => $rollbackEx->getMessage(),
                    ]);
                    $log->markFailed('Update AND rollback failed: ' . $e->getMessage());
                }
            } else {
                $log->markFailed($e->getMessage());
            }

            throw new \RuntimeException(
                "Module update failed for [{$module->slug}]: " . $e->getMessage(),
                0,
                $e
            );
        } finally {
            // Clean up temp files.
            if ($tempZip && file_exists($tempZip)) {
                @unlink($tempZip);
            }
            if ($stagingDir && File::isDirectory($stagingDir)) {
                File::deleteDirectory($stagingDir);
            }
        }
    }

    // -------------------------------------------------------------------------
    // Private helpers
    // -------------------------------------------------------------------------

    /**
     * Download the ZIP to a unique temp file and return its path.
     *
     * @throws \RuntimeException on download failure
     */
    private function downloadZip(string $url): string
    {
        $tempFile = tempnam(sys_get_temp_dir(), 'chargepanda_mod_');
        if ($tempFile === false) {
            throw new \RuntimeException('Could not create a temporary file for download.');
        }

        $response = Http::timeout(self::DL_TIMEOUT)
            ->withHeaders(['User-Agent' => 'ChargePanda-Updater/1.0'])
            ->withOptions(['verify' => config('update.verify_ssl', true)])
            ->sink($tempFile)
            ->get($url);

        if ($response->failed()) {
            @unlink($tempFile);
            throw new \RuntimeException('Download failed with HTTP ' . $response->status() . '.');
        }

        return $tempFile;
    }

    /**
     * Verify the SHA256 checksum of a file.
     * Expected format: "sha256:<hex_hash>"
     *
     * @throws \RuntimeException on mismatch
     */
    private function verifyChecksum(string $filePath, string $expected): void
    {
        if (! str_starts_with($expected, 'sha256:')) {
            throw new \RuntimeException("Unsupported checksum algorithm in '{$expected}'. Only sha256 is supported.");
        }

        $expectedHash = substr($expected, 7);
        $actualHash   = hash_file('sha256', $filePath);

        if (! hash_equals($expectedHash, $actualHash)) {
            throw new \RuntimeException(
                "Checksum mismatch. Expected: {$expectedHash}. Got: {$actualHash}. The file may be corrupt or tampered with."
            );
        }
    }

    /**
     * Extract the ZIP into a unique staging directory and return its path.
     *
     * Handles both flat ZIPs (module files at root) and wrapper ZIPs
     * (single subdirectory containing the module).
     *
     * @throws \RuntimeException on extraction failure
     */
    private function extractToStaging(string $zipPath, string $moduleSlug): string
    {
        $stagingDir = storage_path('app/modules_update_staging/' . $moduleSlug . '_' . uniqid());
        File::makeDirectory($stagingDir, 0755, true, true);

        $zip = new ZipArchive();
        if ($zip->open($zipPath) !== true) {
            File::deleteDirectory($stagingDir);
            throw new \RuntimeException('Could not open the downloaded ZIP archive.');
        }

        $zip->extractTo($stagingDir);
        $zip->close();

        // If the ZIP contains a single wrapper directory, step into it.
        $entries = File::directories($stagingDir);
        if (count($entries) === 1 && ! File::exists($stagingDir . '/module.json')) {
            $inner      = $entries[0];
            $innerFiles = File::allFiles($inner);
            $innerDirs  = File::directories($inner);

            // Move inner contents up to stagingDir.
            $newStaging = $stagingDir . '_inner';
            File::moveDirectory($inner, $newStaging);
            File::deleteDirectory($stagingDir);

            return $newStaging;
        }

        return $stagingDir;
    }

    /**
     * Run pending migrations for the module at its (new) production path.
     */
    private function runMigrations(Module $module, string $prodDir): void
    {
        $this->runMigrationsForDirectory($this->moduleDirectory($module));
    }

    /**
     * Run migrations for a module given its directory name directly.
     * Used by installFromUrl() where no Module record exists yet.
     */
    private function runMigrationsForDirectory(string $directory): void
    {
        $relPath = 'modules/' . $directory . '/database/migrations';

        if (! File::isDirectory(base_path($relPath))) {
            $relPath = 'modules/' . $directory . '/Database/Migrations';
        }

        if (File::isDirectory(base_path($relPath))) {
            Artisan::call('migrate', [
                '--path'  => $relPath,
                '--force' => true,
            ]);
        }
    }

    /**
     * Parse and return the module.json manifest from an extracted staging directory.
     *
     * @throws \RuntimeException if missing or malformed
     */
    private function readManifest(string $dir): array
    {
        $path = rtrim($dir, '/\\') . DIRECTORY_SEPARATOR . 'module.json';

        if (! file_exists($path)) {
            throw new \RuntimeException('module.json not found in the extracted archive.');
        }

        $manifest = json_decode(file_get_contents($path), true);

        if (json_last_error() !== JSON_ERROR_NONE || empty($manifest['slug'])) {
            throw new \RuntimeException('module.json is malformed or missing the "slug" field.');
        }

        return $manifest;
    }

    /**
     * Derive the modules/ subdirectory name from the manifest.
     * Uses the provider class namespace (Modules\{Dir}\...) if present, else StudlyCase slug.
     */
    private function directoryFromManifest(array $manifest, string $fallbackSlug): string
    {
        $provider = $manifest['provider'] ?? '';
        $parts    = explode('\\', $provider);

        if (count($parts) >= 2 && $parts[0] === 'Modules') {
            return $parts[1];
        }

        return \Illuminate\Support\Str::studly($fallbackSlug);
    }

    /**
     * Derive the filesystem directory name from the module record.
     */
    private function moduleDirectory(Module $module): string
    {
        if (! empty($module->provider_class)) {
            $parts = explode('\\', $module->provider_class);
            if (count($parts) >= 2 && $parts[0] === 'Modules') {
                return $parts[1];
            }
        }

        return \Illuminate\Support\Str::studly($module->slug);
    }
}
