<?php

declare(strict_types=1);

namespace PpPhp\Core\Deploy;

use PpPhp\Core\DB;

/**
 * Orchestrates a deployment: resolve the file set, mirror files to the server
 * over the selected transport, track what was uploaded for incremental runs,
 * and record the run in the database.
 *
 * The chosen live-database connection is applied ONLY to the uploaded copy of
 * config/database.php (rendered to a temp file) — the local output on disk is
 * never modified, so the local app keeps its own database configuration.
 */
class DeployManager
{
    public const MODE_CHANGED     = 'changed';
    public const MODE_ALL_PROJECT = 'all-project';
    public const MODE_ALL_OUTPUT  = 'all-output';

    private const ALWAYS_SKIP = ['.git', '.DS_Store', '.hg', '.svn', 'Thumbs.db'];
    private const PROJECT_SKIP_DIRS = ['node_modules', '.idea', '.vscode'];
    private const DB_CONFIG_REL = 'config/database.php';

    /** @var callable|null fn(array $event): void */
    private $progress = null;

    public function onProgress(callable $cb): void
    {
        $this->progress = $cb;
    }

    /**
     * @param array $profile transport profile (see TransportFactory)
     * @param array $opts [
     *     'mode', 'outputDir', 'connectionId', 'projectId', 'createdBy',
     *     'dbProfile' => [...]|null, 'dbLabel' => 'Production', 'dryRun' => bool,
     * ]
     */
    public function deploy(string $slug, array $profile, array $opts): array
    {
        $mode      = $opts['mode'] ?? self::MODE_CHANGED;
        $outputDir = rtrim((string) ($opts['outputDir'] ?? ''), '/');
        $dryRun    = !empty($opts['dryRun']);
        $connId    = (string) ($opts['connectionId'] ?? ($profile['id'] ?? 'default'));
        $dbProfile = (!empty($opts['dbProfile']) && is_array($opts['dbProfile'])) ? $opts['dbProfile'] : null;

        if ($outputDir === '' || !is_dir($outputDir)) {
            return $this->fail('Output directory does not exist. Build the project first.');
        }

        $log   = [];
        $start = microtime(true);
        $tmpDir = null;

        // The live-DB choice is rendered to a temp copy of config/database.php.
        $dbTempFile = null;
        if ($dbProfile !== null) {
            $tmpDir = sys_get_temp_dir() . '/ppdeploy_' . bin2hex(random_bytes(4));
            @mkdir($tmpDir, 0775, true);
            $dbTempFile = $tmpDir . '/database.php';
            file_put_contents($dbTempFile, $this->renderDatabaseConfig($dbProfile));
            $note = "Live database for the upload: '{$dbProfile['name']}' → " . ($dbProfile['host'] ?? '') . ':' . ($dbProfile['port'] ?? '') . '/' . ($dbProfile['dbname'] ?? '') . ' (local config unchanged).';
            $log[] = ['type' => 'info', 'message' => $note];
            $this->emit(['type' => 'info', 'message' => $note]);
        }

        // Resolve the candidate file list.
        $files = $this->collectFiles($outputDir, $mode);
        if ($files === []) {
            $this->cleanup($tmpDir);
            return $this->fail('No files to upload were found in the output directory.');
        }

        $manifest = new FileManifest($slug, $connId);

        // Build the upload queue, computing the *effective* bytes/hash per file.
        $queue = [];
        foreach ($files as $rel => $abs) {
            $srcPath = $abs;
            if ($dbTempFile !== null && $rel === self::DB_CONFIG_REL) {
                $srcPath = $dbTempFile; // upload the transformed copy
            }
            $hash = (string) @md5_file($srcPath);
            if ($mode === self::MODE_CHANGED && !$manifest->hasChanged($rel, $hash)) {
                continue;
            }
            $queue[$rel] = ['src' => $srcPath, 'hash' => $hash, 'size' => (int) @filesize($srcPath)];
        }

        if ($queue === []) {
            $this->cleanup($tmpDir);
            return [
                'success'  => true,
                'message'  => 'Everything is already up to date — no changed files to upload.',
                'uploaded' => 0, 'skipped' => count($files), 'failed' => 0, 'bytes' => 0,
                'duration' => round(microtime(true) - $start, 2),
                'log'      => $log ?: [['type' => 'info', 'message' => 'Nothing to do.']],
            ];
        }

        $total = count($queue);
        $this->emit(['type' => 'info', 'message' => "Preparing {$total} file(s) over " . strtoupper((string) ($profile['protocol'] ?? 'ftp')) . '.', 'total' => $total]);

        // Dry run — report the plan without connecting.
        if ($dryRun) {
            foreach ($queue as $rel => $_) {
                $log[] = ['type' => 'plan', 'path' => $rel];
            }
            $this->cleanup($tmpDir);
            return [
                'success'  => true,
                'message'  => "Dry run: {$total} file(s) would be uploaded to " . ($profile['host'] ?? '') . '.',
                'uploaded' => 0, 'skipped' => count($files) - $total, 'failed' => 0, 'bytes' => 0,
                'duration' => round(microtime(true) - $start, 2),
                'log'      => $log, 'dryRun' => true,
            ];
        }

        // Connect and upload.
        $transport = TransportFactory::make($profile);
        try {
            $transport->connect();
        } catch (TransportException $e) {
            $this->cleanup($tmpDir);
            return $this->fail($e->getMessage(), $log);
        }

        $remoteRoot = '/' . trim((string) ($profile['remotePath'] ?? ''), '/');
        if ($remoteRoot === '/') {
            $remoteRoot = '';
        }

        $uploaded = 0; $failed = []; $bytes = 0; $index = 0;
        $fileRecords = [];

        foreach ($queue as $rel => $info) {
            $index++;
            $remotePath = ($remoteRoot === '' ? '' : $remoteRoot) . '/' . $rel;
            $ok = $transport->put($info['src'], $remotePath);

            if ($ok) {
                $uploaded++;
                $bytes += $info['size'];
                $manifest->record($rel, $info['hash'], $info['size']);
                $log[] = ['type' => 'file', 'path' => $rel, 'size' => $info['size']];
                $fileRecords[] = ['path' => $rel, 'size' => $info['size'], 'status' => 'uploaded'];
                $this->emit(['type' => 'file', 'path' => $rel, 'index' => $index, 'total' => $total]);
            } else {
                $failed[] = $rel;
                $log[] = ['type' => 'error', 'path' => $rel, 'message' => 'Upload failed'];
                $fileRecords[] = ['path' => $rel, 'size' => $info['size'], 'status' => 'failed'];
                $this->emit(['type' => 'error', 'path' => $rel, 'index' => $index, 'total' => $total]);
            }
        }

        $transport->close();
        $manifest->save();
        $this->cleanup($tmpDir);

        $duration = round(microtime(true) - $start, 2);
        $host     = (string) ($profile['host'] ?? '');
        $where    = $host . ($remoteRoot ?: '/');

        if ($uploaded === 0 && $failed !== []) {
            $result = $this->fail('Upload failed for all files. Check the remote path and permissions.', $log);
            $this->record($slug, $profile, $opts, $mode, 'failed', 0, count($failed), count($files) - $total, 0, $duration, $result['message'], $fileRecords);
            return $result;
        }

        $status  = $failed === [] ? 'success' : 'partial';
        $message = "Uploaded {$uploaded} file(s) (" . $this->humanBytes($bytes) . ") to {$where} in {$duration}s.";
        if ($failed !== []) {
            $message .= ' ' . count($failed) . ' file(s) failed.';
        }

        $uploadId = $this->record($slug, $profile, $opts, $mode, $status, $uploaded, count($failed), count($files) - $total, $bytes, $duration, $message, $fileRecords);

        return [
            'success'  => true,
            'message'  => $message,
            'uploaded' => $uploaded,
            'skipped'  => count($files) - $total,
            'failed'   => count($failed),
            'failures' => array_slice($failed, 0, 20),
            'bytes'    => $bytes,
            'duration' => $duration,
            'where'    => $where,
            'uploadId' => $uploadId,
            'log'      => $log,
        ];
    }

    /** Render a config/database.php pointing at the chosen live connection. */
    private function renderDatabaseConfig(array $dbProfile): string
    {
        $out = [
            'type'    => $dbProfile['type'] ?? 'mysql',
            'host'    => $dbProfile['host'] ?? '127.0.0.1',
            'port'    => (string) ($dbProfile['port'] ?? '3306'),
            'dbname'  => $dbProfile['dbname'] ?? $dbProfile['name'] ?? '',
            'user'    => $dbProfile['user'] ?? '',
            'pass'    => $dbProfile['pass'] ?? '',
            'charset' => $dbProfile['charset'] ?? 'utf8mb4',
        ];
        return "<?php\n\ndeclare(strict_types=1);\n\n// Generated for live deployment — do not edit.\nreturn " . var_export($out, true) . ";\n";
    }

    /** Persist a run to deploy_uploads (+ per-file rows). Returns upload id. */
    private function record(string $slug, array $profile, array $opts, string $mode, string $status, int $up, int $failed, int $skipped, int $bytes, float $duration, string $message, array $fileRecords): int
    {
        $projectId = (int) ($opts['projectId'] ?? 0);
        if ($projectId <= 0) {
            return 0;
        }
        try {
            DeploySchema::ensureSchema();
            DB::Init();
            DB::SetConnection();

            $connId = (int) ($profile['id'] ?? 0);
            $uploadId = (int) DB::Insert('deploy_uploads', [
                'project_id'      => $projectId,
                'connection_id'   => $connId > 0 ? $connId : null,
                'connection_name' => (string) ($profile['name'] ?? $profile['host'] ?? ''),
                'protocol'        => (string) ($profile['protocol'] ?? 'ftp'),
                'host'            => (string) ($profile['host'] ?? ''),
                'remote_path'     => (string) ($profile['remotePath'] ?? ''),
                'mode'            => $mode,
                'status'          => $status,
                'files_uploaded'  => $up,
                'files_failed'    => $failed,
                'files_skipped'   => $skipped,
                'bytes'           => $bytes,
                'duration'        => $duration,
                'db_connection'   => (string) ($opts['dbLabel'] ?? ''),
                'message'         => mb_substr($message, 0, 490),
                'created_by'      => $opts['createdBy'] ?? null,
            ]);

            if ($uploadId > 0 && $connId > 0) {
                DB::Update('deploy_connections', ['last_used_at' => date('Y-m-d H:i:s')], ['id' => $connId, 'project_id' => $projectId]);
            }

            // Per-file detail (uploaded + failed only, capped for safety).
            foreach (array_slice($fileRecords, 0, 2000) as $fr) {
                DB::Insert('deploy_upload_files', [
                    'upload_id' => $uploadId,
                    'path'      => mb_substr($fr['path'], 0, 990),
                    'size'      => $fr['size'],
                    'status'    => $fr['status'],
                ]);
            }
            return $uploadId;
        } catch (\Throwable $e) {
            return 0; // recording is best-effort; never break a deploy over it
        }
    }

    /** @return array<string,string> relative-path => absolute-path */
    private function collectFiles(string $outputDir, string $mode): array
    {
        $result = [];
        $iter = new \RecursiveIteratorIterator(
            new \RecursiveDirectoryIterator($outputDir, \RecursiveDirectoryIterator::SKIP_DOTS),
            \RecursiveIteratorIterator::LEAVES_ONLY
        );
        $rootLen = strlen($outputDir) + 1;
        foreach ($iter as $file) {
            if (!$file->isFile()) {
                continue;
            }
            $rel = str_replace('\\', '/', substr($file->getPathname(), $rootLen));
            if ($this->isExcluded($rel, $mode)) {
                continue;
            }
            $result[$rel] = $file->getPathname();
        }
        ksort($result);
        return $result;
    }

    private function isExcluded(string $rel, string $mode): bool
    {
        $segments = explode('/', $rel);
        foreach ($segments as $seg) {
            if (in_array($seg, self::ALWAYS_SKIP, true)) {
                return true;
            }
        }
        if ($mode === self::MODE_ALL_PROJECT) {
            if (in_array($segments[0] ?? '', self::PROJECT_SKIP_DIRS, true)) {
                return true;
            }
        }
        return false;
    }

    private function cleanup(?string $dir): void
    {
        if ($dir !== null && is_dir($dir)) {
            array_map('unlink', glob($dir . '/*') ?: []);
            @rmdir($dir);
        }
    }

    private function fail(string $message, array $log = []): array
    {
        return ['success' => false, 'message' => $message, 'uploaded' => 0, 'failed' => 0, 'log' => $log];
    }

    private function emit(array $event): void
    {
        if ($this->progress !== null) {
            ($this->progress)($event);
        }
    }

    private function humanBytes(int $bytes): string
    {
        if ($bytes < 1024) {
            return $bytes . ' B';
        }
        $units = ['KB', 'MB', 'GB'];
        $n = $bytes / 1024;
        foreach ($units as $u) {
            if ($n < 1024) {
                return round($n, $n < 10 ? 1 : 0) . ' ' . $u;
            }
            $n /= 1024;
        }
        return round($n, 1) . ' TB';
    }
}
