<?php

declare(strict_types=1);

namespace PpPhp\Core\Migrate;

use PpPhp\Core\Databases\DbCloner;
use PpPhp\Core\Databases\ManagedDatabaseService;

/**
 * Phase "snapshot": turn the current install into a self-describing, checksummed
 * BUNDLE on disk that the transport phase can push verbatim to the new server.
 *
 * The bundle is deliberately plain — SQL text + zip archives + one manifest — so
 * it can be inspected, re-used, or restored by hand if anything downstream fails.
 * Nothing here touches the destination and nothing here is destructive on the
 * source: it only reads and writes into the job's own `bundle/` folder.
 *
 *   bundle/
 *     manifest.json            – versions, scope, and a sha256 for every artifact
 *     db/platform.sql          – logical dump of the platform database
 *     db/managed/<id>-<name>.sql
 *     files/projects/<owner>__<slug>.zip
 *     files/sets/<key>.zip     – uploads / storage / output / customfiles
 *
 * The manifest's checksums are what the VERIFY phase later re-computes on the
 * destination to prove the copy is byte-identical.
 */
final class SnapshotBuilder
{
    public function __construct(private MigrationJob $job, private MigrationPlan $plan) {}

    /**
     * Build the whole bundle. Every artifact is logged and checksummed; a failure
     * on any single managed database is recorded but does not abort the platform
     * copy (the operator sees exactly what was skipped and why).
     *
     * @return array{ok:bool,bytes:int,files:int,manifest:array}
     */
    public function build(): array
    {
        $bundle = $this->job->bundleDir();
        @mkdir($bundle . '/db/managed', 0700, true);
        @mkdir($bundle . '/files/projects', 0700, true);
        @mkdir($bundle . '/files/sets', 0700, true);

        $manifest = [
            'format'      => 1,
            'created'     => gmdate('c'),
            'source'      => [
                'baseUrl'     => $this->sourceBaseUrl(),
                'phpVersion'  => PHP_VERSION,
                'platform'    => $this->platformVersion(),
            ],
            'scope'       => $this->plan->scopeSummary(),
            'databases'   => [],
            'files'       => [],
            'warnings'    => [],
        ];

        // ── Platform database ─────────────────────────────────────────
        if ($this->plan->platformDb) {
            $conn = $this->platformConn();
            $this->job->log('Dumping platform database `' . $conn['dbname'] . '`…');
            $res = DbCloner::dump($conn);
            if ($res['success'] ?? false) {
                $rel = 'db/platform.sql';
                $this->writeArtifact($rel, (string) $res['sql']);
                $manifest['databases'][] = [
                    'kind'   => 'platform',
                    'name'   => $conn['dbname'],
                    'file'   => $rel,
                    'tables' => (int) ($res['tables'] ?? 0),
                    'rows'   => (int) ($res['rows'] ?? 0),
                    'sha256' => hash_file('sha256', $bundle . '/' . $rel),
                ];
                $this->job->log('  → ' . ($res['tables'] ?? 0) . ' tables, ' . ($res['rows'] ?? 0) . ' rows.');
            } else {
                $manifest['warnings'][] = 'Platform DB: ' . ($res['message'] ?? 'dump failed');
                $this->job->log('  ! platform dump failed: ' . ($res['message'] ?? ''));
            }
        }

        // ── Managed user databases ────────────────────────────────────
        foreach ($this->selectedManagedDbs() as $db) {
            $name = (string) $db['name'];
            if ($name === '') { continue; }
            $this->job->log('Dumping user database `' . $name . '`…');
            $conn = $this->managedConn($name);
            $res = DbCloner::dump($conn);
            if ($res['success'] ?? false) {
                $rel = 'db/managed/' . $db['id'] . '-' . $this->slug($name) . '.sql';
                $this->writeArtifact($rel, (string) $res['sql']);
                $manifest['databases'][] = [
                    'kind'     => 'managed',
                    'id'       => (int) $db['id'],
                    'name'     => $name,
                    'assigned' => (string) ($db['assigned'] ?? ''),
                    'file'     => $rel,
                    'tables'   => (int) ($res['tables'] ?? 0),
                    'rows'     => (int) ($res['rows'] ?? 0),
                    'sha256'   => hash_file('sha256', $bundle . '/' . $rel),
                ];
                $this->job->log('  → ' . ($res['tables'] ?? 0) . ' tables, ' . ($res['rows'] ?? 0) . ' rows.');
            } else {
                $manifest['warnings'][] = 'User DB "' . $name . '": ' . ($res['message'] ?? 'dump failed');
                $this->job->log('  ! skipped `' . $name . '`: ' . ($res['message'] ?? ''));
            }
        }

        // ── Projects (code + designs + custom files) ──────────────────
        foreach ($this->plan->projectIds as $pid) {
            [$owner, $slug] = array_pad(explode('/', $pid, 2), 2, '');
            $src = constant('BASE_PATH') . '/projects/' . $owner . '/' . $slug;
            if ($owner === '' || $slug === '' || !is_dir($src)) { continue; }
            $rel = 'files/projects/' . $this->slug($owner) . '__' . $this->slug($slug) . '.zip';
            $this->job->log('Packaging project ' . $pid . '…');
            $n = $this->zipDir($src, $bundle . '/' . $rel, 'projects/' . $owner . '/' . $slug);
            if ($n >= 0) {
                $manifest['files'][] = [
                    'kind'   => 'project',
                    'id'     => $pid,
                    'file'   => $rel,
                    'count'  => $n,
                    'bytes'  => (int) @filesize($bundle . '/' . $rel),
                    'sha256' => hash_file('sha256', $bundle . '/' . $rel),
                ];
                $this->job->log('  → ' . $n . ' file(s).');
            } else {
                $manifest['warnings'][] = 'Project "' . $pid . '": could not create archive';
            }
        }

        // ── Non-project file sets (uploads / storage / output / customfiles) ──
        foreach ($this->selectedFileSets() as $set) {
            $src = constant('BASE_PATH') . '/' . $set['path'];
            if (!is_dir($src)) { continue; }
            $rel = 'files/sets/' . $this->slug($set['key']) . '.zip';
            $this->job->log('Packaging "' . $set['label'] . '"…');
            $n = $this->zipDir($src, $bundle . '/' . $rel, $set['path'], $this->setExcludes($set['key']));
            if ($n >= 0) {
                $manifest['files'][] = [
                    'kind'   => 'set',
                    'key'    => $set['key'],
                    'label'  => $set['label'],
                    'path'   => $set['path'],
                    'file'   => $rel,
                    'count'  => $n,
                    'bytes'  => (int) @filesize($bundle . '/' . $rel),
                    'sha256' => hash_file('sha256', $bundle . '/' . $rel),
                ];
                $this->job->log('  → ' . $n . ' file(s).');
            } else {
                $manifest['warnings'][] = 'File set "' . $set['key'] . '": could not create archive';
            }
        }

        // ── Manifest + totals ─────────────────────────────────────────
        @file_put_contents($bundle . '/manifest.json',
            json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));

        [$bytes, $files] = $this->bundleTotals($bundle);
        $this->job->setBundleStats($bytes, $files);

        return ['ok' => empty($manifest['warnings']) || $manifest['databases'] !== [],
                'bytes' => $bytes, 'files' => $files, 'manifest' => $manifest];
    }

    // ── Connections ───────────────────────────────────────────────────

    /** @return array{host:string,port:int,dbname:string,user:string,pass:string} */
    private function platformConn(): array
    {
        $cfg = require constant('BASE_PATH') . '/config/database.php';
        return [
            'host'   => (string) ($cfg['host'] ?? '127.0.0.1'),
            'port'   => (int) ($cfg['port'] ?? 3306),
            'dbname' => (string) ($cfg['dbname'] ?? $cfg['database'] ?? ''),
            'user'   => (string) ($cfg['user'] ?? ''),
            'pass'   => (string) ($cfg['pass'] ?? ''),
        ];
    }

    /**
     * A connection for a managed database. On this platform the managed databases
     * live on the same MySQL account as the platform, so the platform credentials
     * read them. (A managed DB physically on a different server would need its own
     * credentials — recorded as a warning by build() when the dump cannot connect.)
     */
    private function managedConn(string $dbName): array
    {
        return ['dbname' => $dbName] + $this->platformConn();
    }

    // ── Scope resolution ──────────────────────────────────────────────

    private function selectedManagedDbs(): array
    {
        if ($this->plan->managedDbIds === []) { return []; }
        $want = array_flip($this->plan->managedDbIds);
        return array_values(array_filter(
            MigrationInventory::managedDatabases(),
            static fn ($d) => isset($want[(int) $d['id']])
        ));
    }

    private function selectedFileSets(): array
    {
        if ($this->plan->fileSetKeys === []) { return []; }
        $want = array_flip($this->plan->fileSetKeys);
        return array_values(array_filter(
            MigrationInventory::fileSets(),
            static fn ($s) => isset($want[(string) $s['key']])
        ));
    }

    /**
     * The "customfiles" set is the whole projects tree; when the operator also
     * picked individual projects those are already packaged separately, so exclude
     * them here to avoid shipping every project twice.
     *
     * @return list<string> relative path prefixes to skip
     */
    private function setExcludes(string $key): array
    {
        if ($key !== 'customfiles') { return []; }
        return array_map(
            fn ($pid) => 'projects/' . $pid,
            $this->plan->projectIds
        );
    }

    // ── Artifact writers ──────────────────────────────────────────────

    private function writeArtifact(string $rel, string $contents): void
    {
        $abs = $this->job->bundleDir() . '/' . $rel;
        @mkdir(dirname($abs), 0700, true);
        @file_put_contents($abs, $contents);
    }

    /**
     * Zip a directory tree into $zipPath. Returns the file count, or -1 on failure.
     *
     * @param list<string> $excludePrefixes archive-relative prefixes to skip
     */
    private function zipDir(string $srcAbs, string $zipPath, string $arcPrefix, array $excludePrefixes = []): int
    {
        if (!class_exists('ZipArchive')) { return -1; }
        $zip = new \ZipArchive();
        if ($zip->open($zipPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) {
            return -1;
        }
        $arcPrefix = trim($arcPrefix, '/');
        $count = 0;
        try {
            $it = new \RecursiveIteratorIterator(
                new \RecursiveDirectoryIterator($srcAbs, \FilesystemIterator::SKIP_DOTS),
                \RecursiveIteratorIterator::SELF_FIRST
            );
            foreach ($it as $f) {
                $abs = $f->getPathname();
                $relInside = ltrim(str_replace($srcAbs, '', $abs), '/\\');
                $relInside = str_replace('\\', '/', $relInside);
                if ($this->isNoise($relInside)) { continue; }
                $arc = $arcPrefix . '/' . $relInside;
                // Skip anything under an excluded prefix.
                foreach ($excludePrefixes as $ex) {
                    if (str_starts_with($arc, trim($ex, '/') . '/') || $arc === trim($ex, '/')) {
                        continue 2;
                    }
                }
                if ($f->isDir()) {
                    $zip->addEmptyDir($arc);
                } elseif ($f->isFile()) {
                    $zip->addFile($abs, $arc);
                    $count++;
                }
            }
        } catch (\Throwable $e) {
            $zip->close();
            return -1;
        }
        $zip->close();
        return $count;
    }

    /** Junk that should never travel: OS cruft and VCS metadata. */
    private function isNoise(string $rel): bool
    {
        $base = basename($rel);
        if (in_array($base, ['.DS_Store', 'Thumbs.db', '.git'], true)) { return true; }
        return str_contains($rel, '/.git/') || str_starts_with($rel, '.git/');
    }

    // ── Helpers ───────────────────────────────────────────────────────

    private function sourceBaseUrl(): string
    {
        // base_url is computed at runtime (BASE_URL constant), not stored in config.
        if (defined('BASE_URL')) { return (string) constant('BASE_URL'); }
        return '';
    }

    private function platformVersion(): string
    {
        $f = constant('BASE_PATH') . '/VERSION';
        return is_file($f) ? trim((string) @file_get_contents($f)) : '';
    }

    private function slug(string $s): string
    {
        return (string) preg_replace('/[^A-Za-z0-9_\-]+/', '-', $s);
    }

    /** @return array{0:int,1:int} total bytes, total files under the bundle */
    private function bundleTotals(string $bundle): array
    {
        $bytes = 0; $files = 0;
        try {
            $it = new \RecursiveIteratorIterator(
                new \RecursiveDirectoryIterator($bundle, \FilesystemIterator::SKIP_DOTS)
            );
            foreach ($it as $f) {
                if ($f->isFile()) { $bytes += (int) $f->getSize(); $files++; }
            }
        } catch (\Throwable $e) {}
        return [$bytes, $files];
    }
}
