<?php

declare(strict_types=1);

namespace PpPhp\Core\Build;

use PpPhp\Core\ModuleState;
use PpPhp\Core\Designer\Build\DesignerBuildRuntime;
use PpPhp\Core\Designer\Generation\DesignerGenerationRuntime;
use PpPhp\Core\Designer\Shell\DesignerApplicationShellRuntime;

final class BuildEngine
{
    private array $logs = [];

    /**
     * Execute a full build for a project.
     *
     * @param string $slug       Project slug
     * @param array  $options    Options: outputDir, title, clean (bool), compress (bool)
     */
    public function build(string $slug, array $options = []): BuildResult
    {
        $start = microtime(true);
        $this->log('Build started for project: ' . $slug);

        try {
            // 1. Load module states
            $this->log('Loading project configuration…');
            $tablesConfig   = ModuleState::load($slug, 'tables') ?: [];
            $fieldsConfig   = ModuleState::load($slug, 'fields') ?: [];
            $outputConfig   = ModuleState::load($slug, 'output') ?: [];
            $miscConfig     = ModuleState::load($slug, 'misc') ?: [];
            $eventsConfig   = ModuleState::load($slug, 'events') ?: [];
            $designerConfig = ModuleState::load($slug, 'designer') ?: [];
            $pagesConfig    = ModuleState::load($slug, 'pages') ?: [];
            $securityConfig = ModuleState::load($slug, 'security') ?: [];
            $queryConfig    = ModuleState::load($slug, 'query') ?: [];
            $calendarConfig = ModuleState::load($slug, 'calendar') ?: [];
            $mobileConfig   = ModuleState::load($slug, 'mobile') ?: [];
            $notifRulesConfig = ModuleState::load($slug, 'notifRules') ?: [];
            $themesState    = ModuleState::load($slug, 'themes') ?: [];

            // 2. Determine output directory (default: <platform>/output/<slug>)
            $outputDir = trim((string) ($options['outputDir'] ?? $outputConfig['general']['outputDir'] ?? ''));
            if ($outputDir === '') {
                $outputDir = dirname(__DIR__, 2) . '/output/' . $slug;
                $this->log('No output directory configured — using default: ' . $outputDir);
            }

            // Resolve relative paths against the platform root
            if (!str_starts_with($outputDir, '/')) {
                $outputDir = dirname(__DIR__, 2) . '/' . $outputDir;
            }
            $outputDir = $this->normalizePath(rtrim($outputDir, '/'));
            $this->log('Output directory: ' . $outputDir);

            // 3. Clean output directory if requested
            $clean = $options['clean'] ?? $outputConfig['general']['cleanBeforeBuild'] ?? false;
            if ($clean) {
                $this->log('Cleaning output directory…');
                $writer = new BuildWriter();
                $writer->cleanOutputDir($outputDir);
            }

            // 3b. Pre-build hook (Output module → Advanced)
            $preHook = trim((string) ($outputConfig['advanced']['preBuildHook'] ?? ''));
            if ($preHook !== '') {
                $this->log('Running pre-build hook…');
                if (!is_dir($outputDir)) {
                    mkdir($outputDir, 0755, true);
                }
                $hookOut = shell_exec('cd ' . escapeshellarg($outputDir) . ' && (' . $preHook . ') 2>&1');
                if (is_string($hookOut) && trim($hookOut) !== '') {
                    $this->log('pre-build hook output: ' . trim($hookOut));
                }
            }

            // 4. Extract selected tables and their fields. A STANDALONE project has
            // no database and therefore no tables — it builds entirely from its
            // Designer standalone/common pages, so an empty table set is allowed
            // and we skip the project-DB connection entirely.
            $standalone = \PpPhp\Core\ProjectDatabaseProfile::isStandaloneSlug($slug);
            $selectedTables = $tablesConfig['selectedTables'] ?? [];
            if (empty($selectedTables) && !$standalone) {
                return BuildResult::error('No tables selected. Go to the Tables module and select at least one table.', $this->logs);
            }

            // Point DB at the PROJECT's database before reading any schema (not for
            // standalone — there is no project DB). The field list and column types
            // are introspected from it, so the build must not depend on whichever
            // connection the caller left active.
            if (!$standalone) {
                try {
                    \PpPhp\Core\DB::Init($slug);
                    \PpPhp\Core\DB::SetConnection($slug);
                } catch (\Throwable $e) {
                    $this->log('Warning: could not open the project database (' . $e->getMessage()
                        . ') — field defaults cannot be read from the schema.');
                }
            } else {
                $this->log('Standalone project — no database; building pages only.');
            }

            $tables = [];
            $primaryKeys = [];
            $primaryKeyAuto = [];
            foreach ($selectedTables as $tableName) {
                $fields = $this->extractFields($fieldsConfig, $tableName);
                $tables[$tableName] = $fields;
                // Detect the real PK + whether it is AUTO_INCREMENT HERE, while the
                // project DB connection is guaranteed active — later build phases may
                // switch it, so the writer can't reliably re-introspect. An
                // auto-increment PK must be rendered read-only on forms (the DB
                // assigns it) and never appear in INSERT/UPDATE column lists; a
                // manual PK (no AI) stays user-editable on Add. Passed via withContext().
                $pkInfo = $this->detectPrimaryKey((string) $tableName);
                $primaryKeys[(string) $tableName]     = $pkInfo['name'];
                $primaryKeyAuto[(string) $tableName]  = $pkInfo['auto'];
            }
            $this->log('Found ' . count($tables) . ' tables with ' . array_sum(array_map('count', $tables)) . ' fields total');

            // 5. Build the application via Designer pipeline
            $appId = 'project_' . preg_replace('/[^a-z0-9_]/i', '_', $slug);
            $title = $options['title'] ?? $miscConfig['appTitle'] ?? ucfirst($slug) . ' App';

            $this->log('Generating application: ' . $title);
            $buildRuntime = new DesignerBuildRuntime();
            $manifest = $buildRuntime->buildCrudApplication($appId, $title, $tables);
            $buildStats = $buildRuntime->stats($manifest);

            $this->log('Build pipeline complete: ' . json_encode($buildStats));

            // 6. Write artifacts to output directory
            $writer = new BuildWriter();

            // Project context: everything the page generators need from the
            // designer modules (menu, theme shell, page designs, gating, misc).
            $activeTheme = \PpPhp\Core\Theme\ThemeRegistry::resolvedForBuild($slug, \PpPhp\Core\Theme\ThemeRegistry::activeDefault($slug)) ?? [];
            $menu = \PpPhp\Core\Theme\MenuModel::normalize(\PpPhp\Core\Theme\ThemeRegistry::menu($slug));
            $writer->withContext([
                'slug'        => $slug,
                'title'       => $title,
                'menu'        => $menu['items'] ?? [],
                'shell'       => (array) ($activeTheme['shell'] ?? []),
                'designer'    => $designerConfig,
                'pages'       => (array) ($pagesConfig['byTable'] ?? []),
                'query'       => (array) ($queryConfig['byTable'] ?? []),
                'calendar'    => (array) $calendarConfig,
                'notifRules'  => (array) ($notifRulesConfig['rules'] ?? []),
                'relations'   => (array) ($tablesConfig['relations'] ?? []),
                'misc'        => (array) ($miscConfig['byTable'] ?? []),
                'miscProject' => (array) ($miscConfig['project'] ?? []),
                'advanced'    => (array) ($miscConfig['advanced'] ?? []),
                'languages'   => (array) ($miscConfig['languages'] ?? []),
                'labels'      => (array) ($miscConfig['labels'] ?? []),
                'email'       => (array) ($miscConfig['email'] ?? []),
                'sms'         => (array) ($miscConfig['sms'] ?? []),
                'ai'          => (array) ($miscConfig['ai'] ?? []),
                'security'    => $securityConfig,
                'mobile'      => $mobileConfig,
                'primaryKeys' => $primaryKeys,
                'primaryKeyAuto' => $primaryKeyAuto,
            ]);

            if (!is_dir($outputDir)) {
                mkdir($outputDir, 0755, true);
                $this->log("Created output directory: $outputDir");
            }

            $writer->writeManifest($manifest, $outputDir);

            // Output → "Include standalone installer": ship install.php + a sample
            // config instead of baked credentials + a dev admin (see BuildWriter).
            // `forceInstaller` lets the Finished "Deployment Package" action ship a
            // self-installing bundle without the operator flipping the Output toggle.
            $writer->standaloneInstaller = !empty($options['forceInstaller'])
                || (bool) ($outputConfig['advanced']['standaloneInstaller'] ?? false);

            // A database app built WITHOUT a local DB profile on disk can only be a
            // server-side build: the client's credentials are never transmitted to
            // the build server (BuildProtocol::DESIGN_MODULES excludes them). There
            // is nothing real to bake, and extractDbConfig() would otherwise fall
            // back to platform defaults (root / empty) — a broken connection. Ship a
            // self-installing bundle instead so the generated app collects the DB
            // credentials via install.php on first run. Local console builds (which
            // DO have config/database.php) are unaffected and keep baking creds.
            if (!$standalone
                && !is_file(\PpPhp\Core\ProjectDatabaseProfile::configPath(
                    \PpPhp\Core\ProjectLocator::dir($slug)))) {
                $writer->standaloneInstaller = true;
                $this->log('No local DB profile (server-side build) — shipping self-installing bundle (install.php).');
            }

            // 7. Generate bootstrap files (index.php, config, .htaccess)
            $dbConfig = $this->extractDbConfig($slug);
            // Virtual (SQLite) database: resolve where the project's data file
            // actually lives NOW, and make the shipped profile portable. A pool
            // database sits at an absolute console path that means nothing on a
            // live server — the built app gets `database/app.sqlite` instead and
            // a snapshot of the data is copied in below (step 7b).
            $virtualSrc = null;
            if (($dbConfig['type'] ?? '') === 'sqlite' && ($dbConfig['path'] ?? '') !== ':memory:') {
                $projectDir = \PpPhp\Core\ProjectLocator::dir($slug);
                try {
                    $virtualSrc = \PpPhp\Core\DatabaseConnection::resolveSqlitePath((string) $dbConfig['path'], $projectDir);
                } catch (\Throwable $e) {
                    $virtualSrc = null;
                }
                $isAbsolute = str_starts_with((string) $dbConfig['path'], '/')
                    || (bool) preg_match('/^[A-Za-z]:[\\\\\\/]/', (string) $dbConfig['path']);
                if ($isAbsolute) {
                    $dbConfig['path'] = 'database/app.sqlite';
                    $dbConfig['dbname'] = $dbConfig['path'];
                    $dbConfig['name'] = $dbConfig['path'];
                }
            }
            $writer->generateBootstrap($outputDir, $slug, $outputConfig, $dbConfig);

            // 7b. Ship a consistent snapshot of the virtual database so the built
            //     app opens with its schema + data intact (WAL-safe VACUUM INTO).
            if ($virtualSrc !== null && is_file($virtualSrc)) {
                try {
                    $dest = \PpPhp\Core\DatabaseConnection::resolveSqlitePath((string) $dbConfig['path'], $outputDir);
                    \PpPhp\Core\Databases\VirtualDb::backupTo($virtualSrc, $dest);
                    $this->log('Virtual DB: shipped a data snapshot ('
                        . \PpPhp\Core\Databases\VirtualDb::humanSize(\PpPhp\Core\Databases\VirtualDb::fileSize($dest)) . ').');
                } catch (\Throwable $e) {
                    $this->log('Virtual DB snapshot failed (app will start empty): ' . $e->getMessage());
                }
            }

            // 8. Copy framework assets
            $writer->copyAssets($outputDir);

            // 9. Copy core framework files (Helpers, Autoloader, etc.)
            $writer->copyCore($outputDir);

            // 10. Generate designer component CSS
            $writer->generateDesignerCss($outputDir);

            // 11. Generate PHP application pages (replaces static HTML with CRUD)
            $writer->generateApplicationPages($outputDir, $tables, $slug);

            // 12. Export Events designer hooks (PHP + JS + scheduled tasks)
            $writer->generateEvents($outputDir, $eventsConfig);

            // 12b. Email / SMS configuration (Misc module)
            $writer->generateMessagingConfig($outputDir);

            // 13. Compile the active Style theme into the output
            $writer->generateThemeCss($outputDir, $slug);

            // 13a. Database bundle (real schema + seed data) and install guide
            $writer->generateDatabaseSql($outputDir, $tables, $slug, $dbConfig);
            $writer->generateInstallGuide($outputDir, $slug, $dbConfig, $outputConfig, $eventsConfig);
            if ($writer->standaloneInstaller) {
                $writer->generateStandaloneInstaller($outputDir, $slug, $dbConfig, $outputConfig);
            }

            // (URL base is auto-detected at runtime by the generated app — no
            //  build-time URL rewriting needed for subdirectory hosting.)

            // 13b. Custom Files — copy the user's own files/folders into the app
            // root (override generated output, skip reserved system paths).
            $writer->copyCustomFiles($outputDir, $slug);

            // 13c. Native-mobile stylesheet: write assets/css/mobile.css and inject
            //      it LAST into every generated page's <head>. All rules live inside
            //      max-width media queries, so desktop output is untouched; phones get
            //      the native-app layer. Runs before minify + versioning so the new
            //      sheet is compressed and cache-busted like the others.
            $writer->injectMobileStylesheet($outputDir);

            // 13d. PWA layer: manifest + service worker + app icons, so the generated
            //      app is installable ("Add to Home Screen") and runs fullscreen.
            //      Self-contained; injects only its own <head> tags.
            $writer->generatePwaAssets($outputDir);

            // 14. Optimization passes (honour Output settings)
            $compressCss = (bool) ($options['compress'] ?? $outputConfig['optimization']['compressCss'] ?? false);
            $compressJs  = (bool) ($outputConfig['optimization']['compressJs'] ?? false);
            if ($compressCss || $compressJs) {
                $writer->minifyAssets($outputDir, $compressCss, $compressJs);
            }
            // Cache-busting: always version the mutable platform CSS/JS so a
            // redeploy never serves a stale stylesheet from the browser cache;
            // the Output opt-in widens this to every /assets/ file.
            $versionLabel = (string) ($outputConfig['advanced']['versionLabel'] ?? '1.0.0');
            $writer->applyAssetVersioning(
                $outputDir,
                $versionLabel,
                ($outputConfig['optimization']['assetVersioning'] ?? false) ? 'all' : 'platform'
            );
            if (!empty($outputConfig['advanced']['filePermissions'])) {
                $writer->applyFilePermissions($outputDir, (string) $outputConfig['advanced']['filePermissions']);
            }

            // 15. Post-build hook (Output module → Advanced)
            $postHook = trim((string) ($outputConfig['advanced']['postBuildHook'] ?? ''));
            if ($postHook !== '') {
                $this->log('Running post-build hook…');
                $hookOut = shell_exec('cd ' . escapeshellarg($outputDir) . ' && (' . $postHook . ') 2>&1');
                if (is_string($hookOut) && trim($hookOut) !== '') {
                    $this->log('post-build hook output: ' . trim($hookOut));
                }
            }

            // Structural Designer artifacts are replaced by functional PHP pages
            // during generation. Refresh checksums only after every final pass.
            $manifest = $writer->refreshManifest($manifest, $outputDir);
            $buildStats = $manifest->summary();

            // Surface the writer's detailed log in the build result
            $this->logs = array_merge($this->logs, $writer->getLogs());

            // 16. Calculate totals
            $totalFiles = $buildStats['artifacts'] ?? 0;
            $totalSize = $buildStats['bytes'] ?? 0;
            $duration = round(microtime(true) - $start, 2);
            $sizeHuman = $this->formatBytes($totalSize);

            // Count all files in output dir
            $allFiles = $this->countFiles($outputDir);

            $summary = [
                'filesGenerated' => $allFiles,
                'outputDir'      => $outputDir,
                'duration'       => $duration,
                'size'           => $sizeHuman,
                'logs'           => $this->logs,
                'manifest'       => $manifest->toArray(),
            ];

            $this->log("Build complete: $allFiles files written, $sizeHuman total in {$duration}s");

            return BuildResult::success(
                // Human message stays path-free — the resolved location is carried
                // as structured fields (outputDir for server ops; a web-relative
                // path + public URL for display) so the UI never shows the server's
                // absolute filesystem path.
                "Build successful. $allFiles files generated in {$duration}s.",
                $summary
            );
        } catch (\Throwable $e) {
            $this->log('ERROR: ' . $e->getMessage() . ' (' . $e->getFile() . ':' . $e->getLine() . ')');
            $this->log('Trace: ' . $e->getTraceAsString());

            return BuildResult::error(
                'Build failed: ' . $e->getMessage(),
                $this->logs
            );
        }
    }

    // ── Private helpers ─────────────────────────────────────────────

    /**
     * The table's primary-key column, from the live schema. Called while the
     * project DB connection is active. Falls back to `id` (present → use it,
     * else the first column) so single-row addressing always has a key.
     */
    /**
     * The table's primary key and whether it is AUTO_INCREMENT.
     * @return array{name:string,auto:bool}
     */
    private function detectPrimaryKey(string $tableName): array
    {
        try {
            $safe = preg_replace('/[^A-Za-z0-9_]/', '', $tableName);
            $cols = \PpPhp\Core\DB::FetchAll("SHOW COLUMNS FROM `{$safe}`") ?: [];
            foreach ($cols as $col) {
                if (($col['Key'] ?? '') === 'PRI') {
                    // MySQL/MariaDB report AI in Extra; other dialects (Postgres serial,
                    // SQLite rowid) surface it differently — treat a numeric single PK
                    // whose Extra mentions auto_increment/identity as auto.
                    $extra = strtolower((string) ($col['Extra'] ?? ''));
                    $auto  = str_contains($extra, 'auto_increment') || str_contains($extra, 'identity');
                    return ['name' => (string) $col['Field'], 'auto' => $auto];
                }
            }
            if ($cols !== []) {
                $names = array_map(static fn($c) => (string) $c['Field'], $cols);
                $name = in_array('id', $names, true) ? 'id' : (string) $names[0];
                // No declared PRI: assume a lone `id`-style column is auto.
                return ['name' => $name, 'auto' => ($name === 'id')];
            }
        } catch (\Throwable) {
            // schema unavailable → fall through
        }
        return ['name' => 'id', 'auto' => true];
    }

    private function extractFields(array $fieldsConfig, string $tableName): array
    {
        $byTable = $fieldsConfig['byTable'] ?? [];
        $tableFields = $byTable[$tableName] ?? [];
        $devices = $tableFields['devices'] ?? [];
        $desktopFields = $devices['desktop'] ?? [];
        $viewAs = $tableFields['viewAs'] ?? [];
        $editAs = $tableFields['editAs'] ?? [];
        $filterAs = $tableFields['filterAs'] ?? [];
        // Field-level validation stack (Field Controls modal → Validation tab):
        // validationAs[field] = [ {type,params,message}, … ]. Merged into the
        // form's validation collector at build time (component-level rules win).
        $validationAs = $tableFields['validationAs'] ?? [];
        // Optional PER-PAGE control overrides: pageAs[field][page] = {type,settings}
        // (page ∈ list/add/edit/view/search). A page override wins over the
        // category default (editAs/viewAs/filterAs) at that page's render site.
        $pageAs = $tableFields['pageAs'] ?? [];
        // Fields module → Search Settings (per field): 'filterPanel' controls
        // which fields appear in the generated list-page Filter panel.
        $searchSettings = is_array($tableFields['searchSettings'] ?? null) ? $tableFields['searchSettings'] : [];

        // The fields state stores page flags but not column types — enrich
        // from the live schema so type-driven defaults (dates, numbers…)
        // work without the user re-declaring types anywhere.
        $dbTypes = [];
        try {
            // Driver-aware (MySQL SHOW COLUMNS / SQLite PRAGMA) so the Virtual DB
            // RAD flow — create project, add a table, build — works identically.
            foreach (\PpPhp\Core\DB::Columns($tableName) as $col) {
                $dbTypes[(string) $col['Field']] = (string) $col['Type'];
            }
        } catch (\Throwable) {
            // DB unavailable (headless build) — types fall back to state values
        }

        // RAD default: a user may go Tables → Pages → Build without ever opening
        // the Fields module, leaving no field state at all. Seed the list from the
        // live schema so every column is included with default flags (enabled,
        // shown on list/add/edit/view) — the same starting point the Fields module
        // itself presents on first open. Each column gets an EMPTY config so the
        // defaults below (humanized label, type from SQL, all pages on) apply.
        // Without this the build produced pages with zero columns and empty forms.
        if ($desktopFields === [] && $dbTypes !== []) {
            foreach (array_keys($dbTypes) as $col) {
                $desktopFields[$col] = [];
            }
            $this->log("No field settings for '$tableName' — defaulting to all "
                . count($desktopFields) . ' column(s) from the database schema');
        }

        $fields = [];
        foreach ($desktopFields as $name => $config) {
            if (!($config['enabled'] ?? true)) continue;

            $field = [
                'name'     => $name,
                // Humanize the fallback label ("first_name" → "First Name") so pages
                // read well even when the user never set labels or opened the Designer.
                'label'    => $config['label'] ?? ucwords(str_replace(['_', '-'], ' ', $name)),
                'type'     => $config['type'] ?? $dbTypes[$name] ?? 'text',
                'enabled'  => true,
                'list'     => $config['list'] ?? true,
                'add'      => $config['add'] ?? true,
                'edit'     => $config['edit'] ?? true,
                'view'     => $config['view'] ?? true,
                'cards'    => $config['cards'] ?? true,
                'required' => $config['required'] ?? false,
                'viewAs'   => $viewAs[$name]['type'] ?? 'PlainText',
                'editAs'   => $editAs[$name]['type'] ?? 'TextBox',
                'filterAs' => $filterAs[$name]['type'] ?? '',
                'viewAsSettings' => $this->controlSettings($viewAs[$name] ?? null),
                'editAsSettings' => $this->controlSettings($editAs[$name] ?? null),
                'filterAsSettings' => $this->controlSettings($filterAs[$name] ?? null),
                'validations' => is_array($validationAs[$name] ?? null) ? array_values($validationAs[$name]) : [],
                'pageAs' => is_array($pageAs[$name] ?? null) ? $pageAs[$name] : [],
                'filterPanel' => (bool) (($searchSettings[$name]['filterPanel'] ?? false)),
                'searchable'  => (bool) (($searchSettings[$name]['searchable'] ?? true)),
            ];

            // Resolve the Designer type. A type set in the Fields module is already a
            // Designer type (kept as-is). Otherwise map from the live SQL column type
            // (normalizing away sizes: varchar(100), decimal(10,2)…; tinyint(1)=bool).
            // With NO schema info at all, default to a plain text INPUT — never a
            // textarea (that only fits genuine long-text columns). This is what made a
            // never-opened-Designer / DB-less build render every field as a textarea.
            $cfgType = trim((string) ($config['type'] ?? ''));
            $sqlType = trim((string) ($dbTypes[$name] ?? ''));
            if ($cfgType !== '') {
                $field['type'] = $cfgType;
            } elseif ($sqlType !== '') {
                $rawType = strtolower($sqlType);
                $rawType = str_starts_with($rawType, 'tinyint(1)') ? 'boolean' : preg_replace('/\(.*/', '', $rawType);
                $field['type'] = match ($rawType) {
                    'int', 'integer', 'bigint', 'smallint', 'tinyint', 'number' => 'number',
                    'decimal', 'float', 'double', 'currency' => 'currency',
                    'date' => 'date',
                    'datetime', 'timestamp' => 'datetime',
                    'boolean', 'bool' => 'boolean',
                    'text', 'mediumtext', 'longtext' => 'textarea',
                    'enum', 'set' => 'select',
                    'json' => 'json',
                    default => 'text',
                };
            } else {
                $field['type'] = 'text';
            }

            $fields[] = $field;
        }

        return $fields;
    }

    /** Control settings from a Fields viewAs/editAs entry ({type, settings} or flat keys). */
    private function controlSettings(mixed $entry): array
    {
        if (!is_array($entry)) {
            return [];
        }
        if (is_array($entry['settings'] ?? null)) {
            return $entry['settings'];
        }
        unset($entry['type']);
        return $entry;
    }

    private function extractDbConfig(string $slug): array
    {
        // The project's own connection config — same file DB::Init($slug) uses
        // (projects/{ownerUsername}/{slug}/config/database.php), resolved to the
        // project's real owner so an admin can build another user's project.
        $configPath = \PpPhp\Core\ProjectLocator::dir($slug) . '/config/database.php';

        try {
            if (is_file($configPath)) {
                $cfg = require $configPath;
                if (is_array($cfg)) {
                    $profile = \PpPhp\Core\DatabaseConnection::normalize($cfg);
                    $profile['name'] = $profile['type'] === 'sqlite' ? $profile['path'] : ($profile['dbname'] ?: $slug);
                    $this->log('Using ' . \PpPhp\Core\DatabaseConnection::label($profile['type']) . ' project database config: ' . $profile['name']);
                    return $profile;
                }
            }
        } catch (\Throwable $e) {
            $this->log('WARNING: could not read project database config: ' . $e->getMessage());
        }

        $this->log("WARNING: project database config not found at {$configPath}; falling back to platform defaults");

        $dbConfig = [];
        try {
            $platformConfig = dirname(__DIR__, 2) . '/config/database.php';
            if (is_file($platformConfig)) {
                $dbConfig = require $platformConfig;
            }
        } catch (\Throwable) {
        }

        return [
            'type' => 'mysql',
            'host' => $dbConfig['host'] ?? 'localhost',
            'dbname' => $slug,
            'name' => $slug,
            'user' => $dbConfig['user'] ?? 'root',
            'pass' => $dbConfig['pass'] ?? '',
            'port' => (int)($dbConfig['port'] ?? 3306),
            'charset' => 'utf8mb4',
        ];
    }

    /** Collapse '.' and '..' segments so stored/display paths are clean. */
    private function normalizePath(string $path): string
    {
        $isAbsolute = str_starts_with($path, '/');
        $out = [];
        foreach (explode('/', $path) as $seg) {
            if ($seg === '' || $seg === '.') continue;
            if ($seg === '..') {
                if ($out !== [] && end($out) !== '..') array_pop($out);
                elseif (!$isAbsolute) $out[] = '..';
                continue;
            }
            $out[] = $seg;
        }
        return ($isAbsolute ? '/' : '') . implode('/', $out);
    }

    private function countFiles(string $dir): int
    {
        if (!is_dir($dir)) return 0;
        $count = 0;
        $it = new \RecursiveIteratorIterator(
            new \RecursiveDirectoryIterator($dir, \RecursiveDirectoryIterator::SKIP_DOTS)
        );
        foreach ($it as $f) {
            if ($f->isFile()) $count++;
        }
        return $count;
    }

    private function formatBytes(int $bytes): string
    {
        if ($bytes === 0) return '0 B';
        $k = 1024;
        $sizes = ['B', 'KB', 'MB', 'GB'];
        $i = floor(log($bytes) / log($k));
        return round($bytes / pow($k, $i), 2) . ' ' . $sizes[(int)$i];
    }

    private function log(string $message): void
    {
        $this->logs[] = '[' . date('H:i:s') . '] ' . $message;
    }
}
