<?php

namespace App\Console\Commands;

use Illuminate\Console\Command;
use Illuminate\Support\Str;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use RuntimeException;
use Symfony\Component\Process\Process;
use ZipArchive;

/**
 * Packages a deploy-ready, file-count-optimised release zip for cPanel shared
 * hosting (≈300k inode budget). Builds the front-end, stages only runtime files
 * (no node_modules, no dev vendor, no agent/dev fluff, no source maps or TS
 * sources), optionally installs a production-only vendor, ships a
 * fully-populated .env, and zips the result so the operator only has to
 * upload + extract + migrate.
 *
 * NOTE: this build no longer generates a database dump. The server database
 * must be created and migrated separately (e.g. `php artisan migrate --seed`
 * run once against the target DB, or an SSH/CI step). See DEPLOY.txt for the
 * exact steps this command writes into the release.
 *
 * NOTE: vendor/ is NOT shipped by default. Pass --vendor to have this
 * command run `composer install --no-dev` into the release. Without it,
 * the target host is responsible for running `composer install --no-dev`
 * itself (DEPLOY.txt is adjusted accordingly).
 */
class BuildRelease extends Command
{
    protected $signature = 'release:build
        {--skip-npm : Reuse the existing public/build instead of rebuilding the front-end}
        {--vendor : Install a production-only vendor/ into the release (omitted by default)}
        {--no-zip : Stage the release but do not create the zip archive}';

    protected $description = 'Build a one-shot, deploy-ready release zip (Laravel + compiled front-end + populated .env) for cPanel.';

    /**
     * Whole directories (matched by relative path, "/" separated) that are never shipped.
     *
     * @var list<string>
     */
    private array $excludedDirs = [
        // Dependencies / build tooling — regenerated or compiled away.
        'node_modules', 'vendor', 'bootstrap/cache', 'resources/js', 'resources/css',
        // VCS, CI, agent and editor metadata.
        '.git', '.github', '.claude', '.agents', '.codex', '.cursor', '.gemini',
        '.idea', '.vscode', '.zed', '.nova', '.fleet',
        // Not needed at runtime.
        'tests', 'docs', 'legacy-system-data',
        // Dev/runtime state — recreated empty in the release.
        'storage', 'public/build/.vite', 'public/hot', 'public/storage',
    ];

    /**
     * Exact files (relative path) that are never shipped.
     *
     * @var list<string>
     */
    private array $excludedFiles = [
        '.env', '.env.backup', '.env.production', 'auth.json',
        '.editorconfig', '.prettierignore', '.prettierrc', '.gitattributes',
        '.gitignore', '.mcp.json', 'boost.json', '.phpactor.json', '.phpunit.result.cache',
        'AGENTS.md', 'CLAUDE.md', 'GEMINI.md', 'README.md',
        'ISMS_GAP_ANALYSIS_AND_BUILD_PLAN.md', 'coding_principles.md', 'requirement.md', 'permissions.md',
        'eslint.config.js', 'tsconfig.json', 'components.json', 'vite.config.js',
        'phpunit.xml', 'package.json', 'package-lock.json',
    ];

    public function handle(): int
    {
        $base = base_path();
        $stage = storage_path('app/release/build');
        $stamp = now()->format('Ymd-His');
        $zipPath = storage_path("app/release/vellix-release-{$stamp}.zip");
        $includeVendor = (bool) $this->option('vendor');

        if (! $this->option('skip-npm')) {
            $this->components->task('Building front-end (npm run build)', fn () => $this->runProcess(['npm', 'run', 'build'], $base));
        }

        if (! is_file($base.'/public/build/manifest.json')) {
            $this->components->error('public/build/manifest.json is missing. Run "npm run build" first or drop --skip-npm.');

            return self::FAILURE;
        }

        $this->components->task('Cleaning staging directory', function () use ($stage) {
            $this->deleteDir($stage);
            @mkdir($stage, 0755, true);
        });

        $count = 0;
        $this->components->task('Copying runtime files', function () use ($base, $stage, &$count) {
            $count = $this->copyTree($base, $stage);
        });

        $this->components->task('Recreating storage skeleton', fn () => $this->scaffoldStorage($stage));

        if ($includeVendor) {
            $this->components->task('Installing production vendor (composer --no-dev)', fn () => $this->runProcess([
                'composer', 'install', '--no-dev', '--optimize-autoloader',
                '--classmap-authoritative', '--no-interaction', '--no-progress', '--no-scripts',
            ], $stage));
        } else {
            $this->components->info('Skipping vendor/ — pass --vendor to bundle it, or run "composer install --no-dev" on the target after upload.');
        }

        $this->writeReleaseEnv($stage);
        $this->writeDeployNotes($stage, $includeVendor);
        $this->writeApacheHtaccess($stage);

        if ($this->option('no-zip')) {
            $this->components->info("Staged release at: {$stage}");

            return self::SUCCESS;
        }

        $zipStats = ['files' => 0, 'dirs' => 0];
        $this->components->task('Creating zip archive', function () use ($stage, $zipPath, &$zipStats) {
            $zipStats = $this->zipDir($stage, $zipPath);
        });

        $sizeMb = round(filesize($zipPath) / 1048576, 1);
        $inodes = $zipStats['files'] + $zipStats['dirs'];
        $budgetPct = round($inodes / 300000 * 100, 1);

        $this->newLine();
        $this->components->info('Release built successfully.');
        $this->table(['Metric', 'Value'], [
            ['Zip', $zipPath],
            ['Size', "{$sizeMb} MB"],
            ['Files in archive', number_format($zipStats['files'])],
            ['Directories in archive', number_format($zipStats['dirs'])],
            ['Estimated inodes', number_format($inodes).' ('.$budgetPct.'% of 300k budget)'],
            ['Inode budget', '300,000'],
            ['Vendor', $includeVendor ? 'included (composer install --no-dev)' : 'NOT included — run composer install on the target (see DEPLOY.txt)'],
            ['Database dump', 'NOT included — migrate the server DB manually (see DEPLOY.txt)'],
            ['.env', 'included, fully populated'],
        ]);

        $this->components->warn('This release does NOT include a database dump. You must create the database and run migrations/seeders against the target server yourself before the app will boot. See DEPLOY.txt.');

        if (! $includeVendor) {
            $this->components->warn('This release does NOT include vendor/. Run "composer install --no-dev" on the target before the app will boot, or rebuild with --vendor. See DEPLOY.txt.');
        }

        if ($inodes > 280000) {
            $this->components->warn('Inode count is close to the 300k cPanel limit — review the exclude list.');
        }

        return self::SUCCESS;
    }

    private function shouldSkip(string $relative, bool $isDir): bool
    {
        $relative = str_replace('\\', '/', $relative);

        if ($isDir) {
            return in_array($relative, $this->excludedDirs, true);
        }

        if (in_array($relative, $this->excludedFiles, true)) {
            return true;
        }

        // Drop log files and JS source maps anywhere in the tree.
        return Str::endsWith($relative, ['.log', '.map']);
    }

    /**
     * Copy $src into $dst honouring the exclude lists. Returns the file count copied.
     */
    private function copyTree(string $src, string $dst): int
    {
        $count = 0;
        /** @var RecursiveDirectoryIterator $items */
        $items = new RecursiveIteratorIterator(
            new RecursiveDirectoryIterator($src, RecursiveDirectoryIterator::SKIP_DOTS),
            RecursiveIteratorIterator::SELF_FIRST,
        );

        $items->setMaxDepth(-1);

        foreach ($items as $item) {
            $relative = $this->relativePath($item->getPathname(), $src);

            // Prune excluded directories (and everything beneath them).
            $segments = explode('/', $relative);
            $skip = false;
            for ($i = 1; $i <= count($segments); $i++) {
                if (in_array(implode('/', array_slice($segments, 0, $i)), $this->excludedDirs, true)) {
                    $skip = true;
                    break;
                }
            }
            if ($skip) {
                continue;
            }

            if ($item->isDir()) {
                if (! $this->shouldSkip($relative, true)) {
                    @mkdir($dst.'/'.$relative, 0755, true);
                }

                continue;
            }

            if ($this->shouldSkip($relative, false)) {
                continue;
            }

            @mkdir(dirname($dst.'/'.$relative), 0755, true);
            copy($item->getPathname(), $dst.'/'.$relative);
            $count++;
        }

        return $count;
    }

    private function scaffoldStorage(string $stage): void
    {
        $dirs = [
            'storage/app/public', 'storage/app/private',
            'storage/framework/cache/data', 'storage/framework/sessions',
            'storage/framework/views', 'storage/framework/testing',
            'storage/logs', 'bootstrap/cache',
            // Real directory (not a symlink): with FILESYSTEM_PUBLIC_DISK_IS_PUBLIC_PATH=true
            // in the shipped .env the public disk writes here, so uploaded files are served
            // without a storage:link step (no shell access on shared cPanel).
            'public/storage',
        ];

        foreach ($dirs as $dir) {
            @mkdir($stage.'/'.$dir, 0755, true);
            file_put_contents($stage.'/'.$dir.'/.gitignore', "*\n!.gitignore\n");
        }
    }

    private function writeDeployNotes(string $stage, bool $includeVendor): void
    {
        $vendorStep = $includeVendor
            ? '   (vendor/ is already bundled in this zip — nothing to install.)'
            : <<<'TXT'
               This zip does NOT include vendor/. Before step 6, install PHP
               dependencies on the target:
                 - With SSH: cd into the extracted app folder and run
                   `composer install --no-dev --optimize-autoloader --classmap-authoritative`.
                 - Without SSH: rebuild locally with `php artisan release:build --vendor`
                   and re-upload, or use your host's Composer/terminal tool if cPanel
                   provides one (Setup Node.js/PHP App, Terminal, etc).
            TXT;

        $notes = <<<TXT
        VELLIX POINT — cPanel one-shot deploy (no shell required)
        ========================================================

        PART A — Before uploading
        -------------------------
        1. In cPanel → "MySQL® Databases":
             - Create the database    : leomclxr_vellix_point
             - Create the user        : leomclxr_vellix_point_user
             - Set the password to the one in the shipped .env
               (or, if cPanel forces different credentials, edit the .env in this
               zip before going live — the app reads .env at runtime).
             - Add the user to the database with ALL PRIVILEGES.
         2. DNS / SSL:
              - Add a single DNS A record  pnt.vellixglobal.com → this server's IP
                (the bare domain — tenant workspaces live under /<slug>/app paths
                now, no subdomains, no wildcard certificate).
                Optionally add A records for any future hosts — there is only one
                app host in this topology (docs/path-based-tenancy-plan.md Phase 7).
         3. In cPanel → "Domains": the pnt.vellixglobal.com document root must be
             <app>/public — never the Laravel project root. No wildcard
             subdomain or admin subdomain hosts are needed.

        PART B — Upload & install
        -------------------------
        4. Upload this zip and extract it inside your app folder, e.g. ~/vellix
           (NOT inside public_html — the document root must stay <app>/public).
        5. Ensure storage/ , public/storage/ and bootstrap/cache/ are writable by
           the PHP user. cPanel defaults are normally fine; if you see permission
           errors, set them to 755/775.
        {$vendorStep}
        6. Migrate the database. This release does NOT ship a SQL dump — run the
           migrations (and any seeders you need) against the database you created
           in Part A yourself, e.g. via SSH (`php artisan migrate --seed`) if your
           host provides shell access, or via a one-off deploy script / CI job if
           it doesn't. Do this before hitting the app in a browser.
        7. Done. No other `php artisan` commands need to run on the server — the
           .env is already filled in (APP_KEY, DB, tenancy domains) and the
           front-end is pre-compiled in public/build (no Node on the server).
        8. REQUIREMENTS: the server must run PHP 8.2+ (cPanel → "MultiPHP Manager",
           select the PHP version for this domain/account) and the MySQL database
           must use utf8mb4 (the default).

        PART C — Notes
        --------------
        - MAIL_* still contains <FILL_IN>: email (OTP login, notifications) is
          inactive until you paste real SMTP credentials into .env.
         - SESSION_DOMAIN=null and SESSION_PATH=/ are pre-set: one session per
           browser. Visiting a different tenant's workspace signs that session
           out (by design — docs/path-based-tenancy-plan.md Phase 3).
        - Queues run synchronously (QUEUE_CONNECTION=sync) — no worker needed on
          shared hosting.
        - Uploaded tenant files are stored under public/storage (real directory),
          so /storage/... URLs work without storage:link.
        - Whatever seeder/provisioning step creates your first tenant and
          landlord admin accounts is now on you to run and record — this build
          no longer generates or prints any credentials.
        TXT;

        // Strip the leading per-line indentation added by the heredoc.
        $lines = array_map(
            fn (string $line) => ltrim($line),
            explode("\n", $notes),
        );

        file_put_contents($stage.'/DEPLOY.txt', implode("\n", $lines));
    }

    /**
     * Writes a fully-populated production .env into the staged release. No
     * placeholders are left for booting (only MAIL_*, which is optional and
     * only affects sending email). Values are hard-coded for the
     * *.pnt.vellixglobal.com stack.
     */
    private function writeReleaseEnv(string $stage): void
    {
        $env = <<<'ENV'
        APP_NAME="Vellix Point"
        APP_ENV=local
        APP_KEY=base64:GgEFHS7oXpnotU+bXEsID//AZdzEOu4SvQSHAEgjQHU=
        APP_DEBUG=true
        APP_TIMEZONE=Asia/Colombo
          APP_URL=https://pnt.vellixglobal.com

        APP_LOCALE=en
        APP_FALLBACK_LOCALE=en
        APP_FAKER_LOCALE=en_US

        APP_MAINTENANCE_DRIVER=file

        BCRYPT_ROUNDS=12

        LOG_CHANNEL=stack
        LOG_STACK=single
        LOG_DEPRECATIONS_CHANNEL=null
        LOG_LEVEL=error

        DB_CONNECTION=mysql
        DB_HOST=127.0.0.1
        DB_PORT=3306
        DB_DATABASE=leomclxr_vellix_point
        DB_USERNAME=leomclxr_vellix_point_user
        DB_PASSWORD="EM8[vQRPNpWr!qdu"

          SESSION_DRIVER=database
          SESSION_LIFETIME=120
          SESSION_ENCRYPT=false
          SESSION_PATH=/
          # Single host + path-based tenancy: one session per browser. The
          # cookie is shared across tenants on purpose and IdentifyTenant
          # severs a session that belongs to a different tenant (the primary
          # isolation boundary — see docs/path-based-tenancy-plan.md Phase 3).
          SESSION_DOMAIN=null
          SESSION_SECURE_COOKIE=false

        BROADCAST_CONNECTION=log
        FILESYSTEM_DISK=local
        # Uploads live under public/storage (a real directory shipped in this
        # release) so the app serves them with no storage:link / shell on cPanel.
        # This is only the fallback local disk though — every tenant's actual
        # uploads go through the platform's one Cloudflare R2 bucket below
        # (App\Services\Storage\StorageConfig), not this disk.
        FILESYSTEM_PUBLIC_DISK_IS_PUBLIC_PATH=true
        # Shared cPanel does not run a persistent worker by default. Use database
        # only when a cron-driven queue worker has been configured.
        QUEUE_CONNECTION=sync

        CACHE_STORE=database
        CACHE_PREFIX=

        # The platform's one Cloudflare R2 bucket, shared by every tenant. Also
        # seeded into storage_settings by StorageSettingSeeder (part of the
        # `--seed` step in DEPLOY.txt) so it shows up in Master Control →
        # Storage rather than only working invisibly from .env. Override either
        # layer post-deploy without a rebuild: edit this file, or use that page
        # (config isn't cached on this stack, so edits take effect immediately).
        CLOUDFLARE_R2_ACCESS_KEY_ID=8e2d998049931885778db3a897504464
        CLOUDFLARE_R2_SECRET_ACCESS_KEY=d5ee1b71ad6e4830a4672e5188bbcec291e6cae38d7c3e82a46fbecac0578c27
        CLOUDFLARE_R2_BUCKET=vellix-point
        CLOUDFLARE_R2_ENDPOINT=https://1c9c4c73d9760a7160a78b4a63add6d8.r2.cloudflarestorage.com
        CLOUDFLARE_R2_URL=https://pub-e594e886b20b4811bfff6fd643a853c6.r2.dev

        MAIL_MAILER=smtp
        MAIL_HOST=
        MAIL_PORT=587
        MAIL_USERNAME=
        MAIL_PASSWORD=
        MAIL_ENCRYPTION=tls
        MAIL_FROM_ADDRESS=
        MAIL_FROM_NAME="${APP_NAME}"

          # -----------------------------------------------------------------
          # Tenancy / path-based routing (one host, one certificate)
          # pnt.vellixglobal.com/admin → master-control panel
          # pnt.vellixglobal.com/<slug>/app → tenant workspaces
          # -----------------------------------------------------------------
          TENANCY_ROUTING=path
          TENANCY_CENTRAL_PREFIX=admin
          TENANCY_APP_DOMAIN=pnt.vellixglobal.com

        VITE_APP_NAME="${APP_NAME}"
        ENV;

        // Strip the leading per-line indentation added by the heredoc.
        $lines = array_map(
            fn (string $line) => ltrim($line),
            explode("\n", $env),
        );

        file_put_contents($stage.'/.env', implode("\n", $lines)."\n");
    }

    /**
     * The app is only safe when Apache serves Laravel's public directory. This
     * file belongs in that document root and forwards every non-file request
     * to the front controller while preserving bearer-auth headers.
     */
    private function writeApacheHtaccess(string $stage): void
    {
        $htaccess = <<<'HTACCESS'
        <IfModule mod_rewrite.c>
            <IfModule mod_negotiation.c>
                Options -MultiViews -Indexes
            </IfModule>

            RewriteEngine On

            RewriteCond %{HTTP:Authorization} .
            RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]

            RewriteCond %{REQUEST_FILENAME} !-d
            RewriteCond %{REQUEST_URI} (.+)/$
            RewriteRule ^ %1 [L,R=301]

            RewriteCond %{REQUEST_FILENAME} !-d
            RewriteCond %{REQUEST_FILENAME} !-f
            RewriteRule ^ index.php [L]
        </IfModule>
        HTACCESS;

        $lines = array_map(
            fn (string $line) => ltrim($line),
            explode("\n", $htaccess),
        );

        file_put_contents($stage.'/public/.htaccess', implode("\n", $lines)."\n");
    }

    /**
     * @return array{files: int, dirs: int}
     */
    private function zipDir(string $stage, string $zipPath): array
    {
        @mkdir(dirname($zipPath), 0755, true);
        @unlink($zipPath);

        $zip = new ZipArchive;
        if ($zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
            throw new RuntimeException("Unable to create {$zipPath}");
        }

        $files = 0;
        $dirs = 0;
        /** @var RecursiveDirectoryIterator $items */
        $items = new RecursiveIteratorIterator(
            new RecursiveDirectoryIterator($stage, RecursiveDirectoryIterator::SKIP_DOTS),
            RecursiveIteratorIterator::SELF_FIRST,
        );

        foreach ($items as $item) {
            $relative = $this->relativePath($item->getPathname(), $stage);
            if ($item->isDir()) {
                $zip->addEmptyDir($relative);
                $dirs++;
            } else {
                $zip->addFile($item->getPathname(), $relative);
                $files++;
            }
        }

        $zip->close();

        return ['files' => $files, 'dirs' => $dirs];
    }

    private function deleteDir(string $dir): void
    {
        if (! is_dir($dir)) {
            return;
        }

        $items = new RecursiveIteratorIterator(
            new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS),
            RecursiveIteratorIterator::CHILD_FIRST,
        );

        foreach ($items as $item) {
            $item->isDir() ? @rmdir($item->getPathname()) : @unlink($item->getPathname());
        }

        @rmdir($dir);
    }

    /**
     * Produce a forward-slash relative path across Windows and Linux hosts.
     */
    private function relativePath(string $path, string $root): string
    {
        $path = str_replace('\\', '/', $path);
        $root = rtrim(str_replace('\\', '/', $root), '/');

        return ltrim(Str::after($path, $root), '/');
    }

    /**
     * @param  list<string>  $command
     */
    private function runProcess(array $command, string $cwd): void
    {
        $process = new Process($command, $cwd, null, null, 1800);
        $process->run(fn ($type, $buffer) => $this->output->write($buffer));

        if (! $process->isSuccessful()) {
            throw new RuntimeException('Command failed: '.implode(' ', $command));
        }
    }
}