<?php

namespace App\Services\Addons;

use App\Models\Addon;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;

/**
 * Handles the ZIP-upload → /addons/{slug}/ pipeline.
 *
 * Flow:
 *   1. Save the uploaded ZIP to a temp folder
 *   2. Open it with the PHP `zip` extension and read the manifest
 *   3. Validate the slug + version
 *   4. Extract the contents into /addons/{slug}/ (clearing any previous copy)
 *   5. Upsert the `addons` row with status='inactive' so the admin can
 *      review the manifest before activating
 *
 * Throws on every error — the controller catches + reports.
 */
class AddonInstaller
{
    public function __construct(protected AddonManager $manager) {}

    /**
     * Install an addon from an uploaded ZIP. Returns the resulting Addon row.
     *
     * @throws \RuntimeException on validation / extraction failure
     */
    public function installFromZip(UploadedFile $zip): Addon
    {
        if (!class_exists(\ZipArchive::class)) {
            throw new \RuntimeException('PHP zip extension is required to install addons. Enable ext-zip on your server.');
        }

        AddonManager::ensureRootExists();

        // 1. Stash the upload in storage/app/addon-uploads with a random
        //    name so two admins uploading the same file in parallel don't
        //    collide. The temp file is deleted in a `finally` block below.
        $tmpDir = storage_path('app/addon-uploads');
        if (!File::isDirectory($tmpDir)) {
            File::makeDirectory($tmpDir, 0755, true);
        }
        $zipPath = $tmpDir . '/' . Str::random(20) . '.zip';
        $zip->move($tmpDir, basename($zipPath));

        // 2. Extract to a scratch folder first so we can read the manifest
        //    and validate before touching /addons/. If anything fails the
        //    real addons folder is untouched.
        $scratch = $tmpDir . '/extract-' . Str::random(10);
        File::makeDirectory($scratch, 0755, true);

        try {
            $za = new \ZipArchive();
            if ($za->open($zipPath) !== true) {
                throw new \RuntimeException('Could not open the uploaded ZIP file. It may be corrupted.');
            }

            // Reject any entry that tries to escape the extract folder via
            // `../` traversal (Zip-Slip). Most clients block this but it's
            // cheap insurance and `ZipArchive::extractTo` is happy to follow
            // a `../../../etc/passwd` entry if we don't pre-screen.
            for ($i = 0; $i < $za->numFiles; $i++) {
                $name = $za->getNameIndex($i);
                if ($name === false) continue;
                if (str_contains($name, '..') || str_starts_with($name, '/') || preg_match('/^[a-zA-Z]:/', $name)) {
                    $za->close();
                    throw new \RuntimeException("ZIP contains unsafe path: {$name}");
                }
            }

            $za->extractTo($scratch);
            $za->close();

            // 3. Some ZIPs nest everything inside a single top-level folder
            //    ("instagram-addon-v1.0/"). Detect that and re-point at the
            //    inner folder so the manifest read works either way.
            $manifestFolder = $this->resolveManifestRoot($scratch);
            $manifest = AddonManager::readManifest($manifestFolder);
            $slug = $manifest['slug'];

            // 4. Move scratch → /addons/{slug}/. If a previous version of
            //    the same slug already exists, wipe it first so the new
            //    files definitely take effect.
            $target = AddonManager::root() . '/' . $slug;
            if (File::isDirectory($target)) {
                File::deleteDirectory($target);
            }
            // Copy (not rename) so we can leave the scratch in place to
            // delete via the finally block — File::moveDirectory has
            // cross-platform quirks on Windows + open handles.
            File::copyDirectory($manifestFolder, $target);

            // 5. Upsert the DB row. status stays 'inactive' so the admin
            //    has to consciously activate (which triggers migrations).
            $addon = Addon::updateOrCreate(
                ['slug' => $slug],
                [
                    'name' => $manifest['name'],
                    'version' => $manifest['version'],
                    'description' => $manifest['description'] ?? null,
                    'author' => $manifest['author'] ?? null,
                    'author_url' => $manifest['author_url'] ?? null,
                    'manifest' => $manifest,
                    'status' => 'inactive',
                    'installed_at' => now(),
                ]
            );

            return $addon;
        } finally {
            // Always clean the scratch + uploaded ZIP, even on exception.
            if (isset($scratch) && File::isDirectory($scratch)) {
                File::deleteDirectory($scratch);
            }
            if (File::exists($zipPath)) {
                File::delete($zipPath);
            }
        }
    }

    /**
     * Some ZIPs put the manifest at the root, others nest it one folder deep.
     * Return whichever folder actually contains addon.json.
     */
    protected function resolveManifestRoot(string $extractedTo): string
    {
        if (File::exists($extractedTo . '/addon.json')) {
            return $extractedTo;
        }
        $entries = File::directories($extractedTo);
        if (count($entries) === 1 && File::exists($entries[0] . '/addon.json')) {
            return $entries[0];
        }
        throw new \RuntimeException('addon.json was not found at the root of the ZIP (or inside a single top-level folder).');
    }
}
