<?php

declare(strict_types=1);

namespace PpPhp\Modules\Tables\Controllers;

use PpPhp\Core\Config;
use PpPhp\Core\DB;
use PpPhp\Core\DatabaseConnection;
use PpPhp\Core\Database\Dialect\DatabaseDialectInterface;
use PpPhp\Core\Database\Dialect\DatabaseDialectRegistry;
use PpPhp\Core\Database\Schema\FieldDefinitionNormalizer;
use PpPhp\Core\Database\Schema\TableDesignerService;
use PpPhp\Core\Database\SqlScriptRunner;
use PpPhp\Core\Database\SqlDialectConverter;
use PpPhp\Core\Database\SqlScriptAnalyzer;
use PpPhp\Core\Security;
use PpPhp\Core\ProjectNav;
use PpPhp\Core\ModuleState;

class TablesController
{
    private array $project = [];
    private string $slug = '';
    private array $databaseConfig = [];
    private ?DatabaseDialectInterface $dialect = null;
    private FieldDefinitionNormalizer $fieldNormalizer;
    private ?TableDesignerService $tableDesigner = null;

    // ModuleState versioning
    private const STATE_VERSION = 2;

    public function __construct(private Config $config)
    {
        $this->fieldNormalizer = new FieldDefinitionNormalizer();
    }

    public function createTable(string $slug): void
    {
        header('Content-Type: application/json');
        $this->initProject($slug);
        Security::RequireCsrf();
        $input = $this->jsonInput();

        try {
            $tableName = trim((string)($input['table_name'] ?? ''));
            $fields = $this->designer()->create($tableName, (array)($input['fields'] ?? $input['columns'] ?? []));
            $state = $this->loadWorkspaceState($slug);
            $state['selectedTables'] = $this->appendUniqueString($state['selectedTables'] ?? [], $tableName);
            $state['tableSchemas'][$tableName] = $this->tableSchemaState($tableName, $fields, 'TABLE');
            unset($state['views'][$tableName]);
            $this->saveWorkspaceState($slug, $state);
            echo json_encode(['success' => true, 'message' => "Table '{$tableName}' created.", 'dialect' => $this->dialect()->id()]);
        } catch (\InvalidArgumentException $e) {
            $this->jsonError($e->getMessage(), 400);
        } catch (\Throwable $e) {
            $this->jsonError('Unable to create table: ' . $e->getMessage(), 500);
        }
        exit;
    }

    public function updateTable(string $slug, string $table): void
    {
        header('Content-Type: application/json');
        $this->initProject($slug);
        Security::RequireCsrf();
        $input = $this->jsonInput();

        try {
            $oldName = $this->normalizeIdentifier($table, 'Table name');
            $newName = trim((string)($input['new_table_name'] ?? ''));
            $fields = $this->designer()->update($oldName, $newName, (array)($input['fields'] ?? []));

            $state = $this->loadWorkspaceState($slug);
            if ($oldName !== $newName) {
                $state = $this->renameTableInWorkspaceState($state, $oldName, $newName);
            }
            $state['selectedTables'] = $this->appendUniqueString($state['selectedTables'] ?? [], $newName);
            $state['tableSchemas'][$newName] = $this->tableSchemaState($newName, $fields, 'TABLE');
            unset($state['views'][$newName]);
            $this->saveWorkspaceState($slug, $state);
            echo json_encode(['success' => true, 'message' => 'Table updated successfully.', 'table' => $newName]);
        } catch (\InvalidArgumentException $e) {
            $this->jsonError($e->getMessage(), 400);
        } catch (\Throwable $e) {
            $this->jsonError('Unable to update table: ' . $e->getMessage(), 500);
        }
        exit;
    }

    public function deleteTable(string $slug, string $table): void
    {
        header('Content-Type: application/json');
        $this->initProject($slug);
        Security::RequireCsrf();

        try {
            $tableName = $this->normalizeIdentifier($table, 'Table name');
            $this->designer()->drop($tableName);
            $state = $this->removeTableFromWorkspaceState($this->loadWorkspaceState($slug), $tableName);
            $this->saveWorkspaceState($slug, $state);
            echo json_encode(['success' => true, 'message' => "'{$tableName}' deleted."]);
        } catch (\InvalidArgumentException $e) {
            $this->jsonError($e->getMessage(), 400);
        } catch (\Throwable $e) {
            $this->jsonError('Unable to delete table or view: ' . $e->getMessage(), 500);
        }
        exit;
    }

    public function createCustomView(string $slug): void
    {
        $this->createViewResponse($slug, true, 'custom');
    }

    public function createSqlView(string $slug): void
    {
        $this->createViewResponse($slug, false, 'sql');
    }

    /**
     * Enable / configure the standalone Calendar (event-management) app for the
     * project. The Calendar is NOT tied to a business table — it provisions its
     * own storage — so this saves a project-level `calendar` ModuleState that the
     * build reads to generate generated/calendar/ and add the menu link.
     */
    public function createCalendar(string $slug): void
    {
        header('Content-Type: application/json');
        $this->initProject($slug);
        Security::RequireCsrf();
        $input = $this->jsonInput();

        try {
            $config = [
                'enabled'        => !isset($input['enabled']) || !empty($input['enabled']),
                'allowPersonal'  => !isset($input['allowPersonal']) || !empty($input['allowPersonal']),
                'appReminders'   => !isset($input['appReminders']) || !empty($input['appReminders']),
                'emailReminders' => !empty($input['emailReminders']),
                'defaultView'    => in_array(($input['defaultView'] ?? 'month'), ['month', 'week', 'day', 'agenda', 'year'], true) ? (string) $input['defaultView'] : 'month',
                'firstDay'       => ((int) ($input['firstDay'] ?? 0)) === 1 ? 1 : 0,
                'menuLabel'      => trim((string) ($input['menuLabel'] ?? 'Calendar')) ?: 'Calendar',
            ];
            ModuleState::save($slug, 'calendar', $config);

            echo json_encode([
                'success' => true,
                'enabled' => $config['enabled'],
                'message' => $config['enabled']
                    ? 'Calendar app enabled. Build the app to generate it (it provisions its own tables on first run).'
                    : 'Calendar app disabled.',
            ]);
        } catch (\Throwable $e) {
            $this->jsonError('Unable to save the calendar: ' . $e->getMessage(), 500);
        }
        exit;
    }

    /** Current project-level Calendar config (for the wizard to reflect state). */
    public function calendarConfig(string $slug): void
    {
        header('Content-Type: application/json');
        $this->initProject($slug);
        $c = ModuleState::load($slug, 'calendar');
        echo json_encode(['success' => true, 'config' => is_array($c) ? $c : []]);
        exit;
    }

    public function previewSql(string $slug): void
    {
        header('Content-Type: application/json');
        $this->initProject($slug);
        $input = $this->jsonInput();

        try {
            $sql = $this->normalizeViewSelectSql((string)($input['sql'] ?? ''));
            $preview = $this->designer()->preview($sql, 10);
            echo json_encode(['success' => true] + $preview + ['preview' => $preview['rows']]);
        } catch (\InvalidArgumentException $e) {
            $this->jsonError($e->getMessage(), 400);
        } catch (\Throwable $e) {
            $this->jsonError('Unable to preview query: ' . $e->getMessage(), 500);
        }
        exit;
    }

    /**
     * SQL Editor — run a free-form SQL script (schema + seed) against the project
     * database and return a per-statement report. The connection + dialect are the
     * project's own (initProject switches DB::Pdo() to it), so this works for every
     * supported engine. Access is gated by initProject (login + project ownership).
     */
    public function runSqlScript(string $slug): void
    {
        header('Content-Type: application/json');
        $this->initProject($slug);
        Security::RequireCsrf();
        $input = $this->jsonInput();

        $sql = (string) ($input['sql'] ?? '');
        if (trim($sql) === '') {
            $this->jsonError('Nothing to run — the editor is empty.', 400);
        }

        // A dry run rolls everything back — only offered on engines with
        // transactional DDL (Postgres/SQLite/SQL Server); ignored elsewhere.
        $dryRun = !empty($input['dryRun']) && $this->dryRunSupported();

        try {
            $runner = new SqlScriptRunner(DB::Pdo());
            $report = $runner->execute($sql, [
                'stopOnError' => !isset($input['stopOnError']) || !empty($input['stopOnError']),
                'transaction' => !empty($input['transaction']),
                'dryRun'      => $dryRun,
                'maxRows'     => (int) ($input['maxRows'] ?? 200),
            ]);

            // A dry run saves nothing, so the table list can't have changed.
            $objectsChanged = false;
            if (!$dryRun) {
                foreach ($report['statements'] as $s) {
                    if (($s['status'] ?? '') === 'ok' && ($s['kind'] ?? '') === 'write') {
                        $objectsChanged = true;
                        break;
                    }
                }
            }

            echo json_encode([
                'success'        => true,
                'report'         => $report,
                'objectsChanged' => $objectsChanged,
                'dialect'        => $this->dialect()->id(),
            ]);
        } catch (\Throwable $e) {
            $this->jsonError('Could not run the SQL: ' . $e->getMessage(), 500);
        }
        exit;
    }

    /**
     * SQL Editor — a read-only "plan" of a script (statement classification, row
     * estimates, destructive-operation flags) shown before anything runs. Never
     * touches the database.
     */
    public function sqlPlan(string $slug): void
    {
        header('Content-Type: application/json');
        $this->initProject($slug);
        $input = $this->jsonInput();

        $sql = (string) ($input['sql'] ?? '');
        if (trim($sql) === '') {
            $this->jsonError('Nothing to preview — the editor is empty.', 400);
        }

        try {
            $plan = SqlScriptAnalyzer::analyze($sql);
            echo json_encode([
                'success'         => true,
                'statements'      => $plan['statements'],
                'summary'         => $plan['summary'],
                'dryRunSupported' => $this->dryRunSupported(),
                'dialect'         => $this->dialect()->id(),
            ]);
        } catch (\Throwable $e) {
            $this->jsonError('Could not analyze the SQL: ' . $e->getMessage(), 500);
        }
        exit;
    }

    /**
     * SQL Editor / view builders — the project's full schema (every table + view
     * with its columns and types) for the schema explorer sidebar and column-aware
     * autocomplete. Read-only; gated by initProject.
     */
    public function schema(string $slug): void
    {
        header('Content-Type: application/json');
        $this->initProject($slug);

        $limit = 400; // safety cap for very large databases
        try {
            $objects = $this->dialect()->listObjects(DB::Pdo(), $this->databaseConfig);
            $total = count($objects);
            $out = [];
            foreach (array_slice($objects, 0, $limit) as $o) {
                $name = (string) ($o['name'] ?? '');
                if ($name === '') { continue; }
                $cols = [];
                try {
                    // Columns only — the schema explorer + autocomplete never use the
                    // index / foreign-key data, so skip those extra round-trips.
                    foreach ($this->dialect()->listColumns(DB::Pdo(), $name, $this->databaseConfig) as $c) {
                        $cols[] = [
                            'name' => (string) ($c['name'] ?? $c['COLUMN_NAME'] ?? ''),
                            'type' => (string) ($c['COLUMN_TYPE'] ?? $c['type'] ?? $c['DATA_TYPE'] ?? ''),
                        ];
                    }
                } catch (\Throwable) {
                    // a describe failure on one object shouldn't sink the whole list
                }
                $out[] = [
                    'name'    => $name,
                    'type'    => strtoupper((string) ($o['type'] ?? 'TABLE')),
                    'columns' => $cols,
                ];
            }
            echo json_encode([
                'success'   => true,
                'schema'    => $out,
                'total'     => $total,
                'truncated' => $total > $limit,
            ]);
        } catch (\Throwable $e) {
            $this->jsonError('Could not load the schema: ' . $e->getMessage(), 500);
        }
        exit;
    }

    /** Engines whose DDL is transactional, so a rolled-back dry run truly undoes everything. */
    private function dryRunSupported(): bool
    {
        return in_array($this->dialect()->id(), ['pgsql', 'cockroachdb', 'sqlite', 'sqlsrv'], true);
    }

    /**
     * SQL Editor — best-effort convert a pasted script from another database engine
     * to THIS project's engine (returns converted SQL + a change log + warnings for
     * a reviewable diff; never runs it). The target is always the project's dialect.
     */
    public function convertSql(string $slug): void
    {
        header('Content-Type: application/json');
        $this->initProject($slug);
        $input = $this->jsonInput();

        $sql = (string) ($input['sql'] ?? '');
        if (trim($sql) === '') {
            $this->jsonError('Nothing to convert — the editor is empty.', 400);
        }

        $to = $this->dialect()->id();
        // Source engine: an explicit pick wins; 'auto'/empty auto-detects; anything
        // undetectable (or beyond our 8) falls back to a generic best-effort pass.
        $from = trim((string) ($input['from'] ?? ''));
        if ($from === '' || $from === 'auto') {
            $from = SqlDialectConverter::detect($sql) ?? 'generic';
        }

        try {
            $result = (new SqlDialectConverter())->convert($sql, $from, $to);
            echo json_encode(['success' => true] + $result);
        } catch (\Throwable $e) {
            $this->jsonError('Could not convert the SQL: ' . $e->getMessage(), 500);
        }
        exit;
    }

    public function syncDatabase(string $slug): void
    {
        header('Content-Type: application/json');
        $this->initProject($slug);
        Security::RequireCsrf();
        $input = $this->jsonInput();

        try {
            $tables = $this->designer()->listObjects();
            usort($tables, static fn(array $a, array $b): int => $a['name'] <=> $b['name']);
            $selected = array_map('strval', (array)($input['selectedTables'] ?? []));
            $positions = (array)($input['positions'] ?? []);
            $expanded = array_map('strval', (array)($input['expandedTables'] ?? []));
            $existing = array_column($tables, 'name');
            $validSelected = array_values(array_intersect($selected, $existing));
            $validPositions = array_values(array_filter($positions, static fn(array $position): bool => in_array((string)($position['table'] ?? ''), $validSelected, true)));
            $validExpanded = array_values(array_intersect($expanded, $validSelected));

            $state = $this->loadWorkspaceState($slug);
            $state['selectedTables'] = $validSelected;
            $state['positions'] = $validPositions;
            $state['expandedTables'] = $validExpanded;
            $state['tableListSort'] = (string)($input['tableListSort'] ?? 'name_asc');
            $state['tableListFilter'] = (string)($input['tableListFilter'] ?? '');
            foreach (array_keys((array)$state['tableSchemas']) as $name) {
                if (!in_array($name, $existing, true)) {
                    unset($state['tableSchemas'][$name], $state['views'][$name]);
                }
            }
            $this->saveWorkspaceState($slug, $state);

            echo json_encode([
                'success' => true, 'message' => 'Database synchronized.', 'tables' => $tables,
                'selectedTables' => $validSelected, 'positions' => $validPositions,
                'expandedTables' => $validExpanded, 'dialect' => $this->designer()->dialectConfig(),
            ]);
        } catch (\Throwable $e) {
            $this->jsonError('Unable to synchronize database: ' . $e->getMessage(), 500);
        }
        exit;
    }

    public function saveRelation(string $slug): void
    {
        header('Content-Type: application/json');
        $this->initProject($slug);
        Security::RequireCsrf();
        $input = $this->jsonInput();
        $relation = null;
        $foreignKeyAdded = false;

        try {
            $relation = $this->normalizeRelationPayload($input);
            $relation['id'] = 'rel_' . bin2hex(random_bytes(8));
            if ($relation['add_foreign_key']) {
                $relation['constraint_name'] = $this->relationConstraintName($relation);
                $this->designer()->addForeignKey($relation);
                $foreignKeyAdded = true;
            }
            $relations = $this->loadRelations($slug);
            $relations[] = $relation;
            try {
                $this->saveRelations($slug, $relations);
            } catch (\Throwable $e) {
                if ($foreignKeyAdded) {
                    try { $this->designer()->dropForeignKey($relation); } catch (\Throwable) {}
                }
                throw $e;
            }
            echo json_encode(['success' => true, 'message' => 'Relation created.', 'relation' => $relation]);
        } catch (\InvalidArgumentException $e) {
            $this->jsonError($e->getMessage(), 400);
        } catch (\Throwable $e) {
            $this->jsonError('Unable to create relation: ' . $e->getMessage(), 500);
        }
        exit;
    }

    public function updateRelation(string $slug, string $id): void
    {
        header('Content-Type: application/json');
        $this->initProject($slug);
        Security::RequireCsrf();
        $input = $this->jsonInput();
        $relations = $this->loadRelations($slug);
        $index = $this->relationIndex($relations, $id);
        if ($index === null) {
            $this->jsonError('Relation not found.', 404);
            exit;
        }

        $old = $relations[$index];
        try {
            $updated = $this->normalizeRelationPayload($input, $old);
            $updated['id'] = $id;
            if ($updated['add_foreign_key']) {
                $updated['constraint_name'] = (string)($old['constraint_name'] ?? $this->relationConstraintName($updated));
            }
            $foreignKeyChanged = $this->foreignKeySignature($old) !== $this->foreignKeySignature($updated);
            $oldHasForeignKey = !empty($old['add_foreign_key']);
            $newHasForeignKey = !empty($updated['add_foreign_key']);
            $oldForeignKeyDropped = false;
            $newForeignKeyAdded = false;

            if ($oldHasForeignKey && ($foreignKeyChanged || !$newHasForeignKey)) {
                $this->designer()->dropForeignKey($old);
                $oldForeignKeyDropped = true;
            }
            try {
                if ($newHasForeignKey && ($foreignKeyChanged || !$oldHasForeignKey)) {
                    $updated['constraint_name'] = $this->relationConstraintName($updated);
                    $this->designer()->addForeignKey($updated);
                    $newForeignKeyAdded = true;
                }
            } catch (\Throwable $e) {
                if ($oldHasForeignKey && ($foreignKeyChanged || !$newHasForeignKey)) {
                    try { $this->designer()->addForeignKey($old); } catch (\Throwable) {}
                }
                throw $e;
            }

            $relations[$index] = $updated;
            try {
                $this->saveRelations($slug, $relations);
            } catch (\Throwable $e) {
                if ($newForeignKeyAdded) {
                    try { $this->designer()->dropForeignKey($updated); } catch (\Throwable) {}
                }
                if ($oldForeignKeyDropped) {
                    try { $this->designer()->addForeignKey($old); } catch (\Throwable) {}
                }
                throw $e;
            }
            echo json_encode(['success' => true, 'message' => 'Relation updated.', 'relation' => $updated]);
        } catch (\InvalidArgumentException $e) {
            $this->jsonError($e->getMessage(), 400);
        } catch (\Throwable $e) {
            $this->jsonError('Unable to update relation: ' . $e->getMessage(), 500);
        }
        exit;
    }

    public function deleteRelation(string $slug, string $id): void
    {
        header('Content-Type: application/json');
        $this->initProject($slug);
        Security::RequireCsrf();
        $relations = $this->loadRelations($slug);
        $index = $this->relationIndex($relations, $id);
        if ($index === null) {
            $this->jsonError('Relation not found.', 404);
            exit;
        }

        try {
            $target = $relations[$index];
            if (!empty($target['add_foreign_key'])) {
                $this->designer()->dropForeignKey($target);
            }
            array_splice($relations, $index, 1);
            $this->saveRelations($slug, $relations);
            echo json_encode(['success' => true, 'message' => 'Relation deleted.']);
        } catch (\Throwable $e) {
            $this->jsonError('Unable to delete relation: ' . $e->getMessage(), 500);
        }
        exit;
    }

    public function getRelationCount(string $slug, string $id): void
    {
        header('Content-Type: application/json');
        $this->initProject($slug);
        $relations = $this->loadRelations($slug);
        $index = $this->relationIndex($relations, $id);
        if ($index === null) {
            $this->jsonError('Relation not found.', 404);
            exit;
        }
        try {
            echo json_encode(['success' => true, 'count' => $this->designer()->countRows((string)$relations[$index]['detail_table'])]);
        } catch (\Throwable $e) {
            $this->jsonError('Unable to count detail records: ' . $e->getMessage(), 500, ['count' => 0]);
        }
        exit;
    }

    // -------------------------------------------------------
    //  Main SPA page
    // -------------------------------------------------------
    public function index(string $slug): string
    {
        $this->initProject($slug);
        $project = $this->project;

        // Load full workspace state (with version upgrade and defaults)
        $workspace = $this->loadWorkspaceState($slug);

        $savedTables     = $workspace['selectedTables'] ?? [];
        $savedPositions  = $workspace['positions'] ?? [];
        $expandedTables  = $workspace['expandedTables'] ?? [];
        $tableListSort   = $workspace['tableListSort'] ?? 'name_asc';
        $tableListFilter = $workspace['tableListFilter'] ?? '';

        // Fetch all tables/views through the active engine adapter.
        $tables = $this->dialect()->listObjects(DB::Pdo(), $this->databaseConfig);
        usort($tables, fn($a, $b) => $a['name'] <=> $b['name']);
        $databaseDialect = $this->dialect()->uiConfig();

        $navItems  = ProjectNav::items('/tables');
        $baseUrl   = $this->config->get('base_url');
        $assetsUrl = $this->config->get('assets_url');
        $user      = $_SESSION['user'];
        $pageTitle = 'Tables';

        $tablesJson          = json_encode($tables);
        $savedTablesJson     = json_encode($savedTables);
        $savedPositionsJson  = json_encode($savedPositions);
        $expandedTablesJson  = json_encode($expandedTables);

        ob_start();
        include __DIR__ . '/../Views/index.php';
        return ob_get_clean();
    }

    // -------------------------------------------------------
    //  AJAX: Fetch table details
    // -------------------------------------------------------
    public function tableDetails(string $slug, string $table): void
    {
        header('Content-Type: application/json');
        $this->initProject($slug);

        try {
            $table = $this->normalizeIdentifier($table, 'Table name');
            $details = $this->dialect()->describeTable(DB::Pdo(), $table, $this->databaseConfig);
            echo json_encode(['success' => true] + $details + ['dialect' => $this->dialect()->uiConfig()]);
        } catch (\InvalidArgumentException $e) {
            http_response_code(400);
            echo json_encode(['success' => false, 'message' => $e->getMessage()]);
        } catch (\Throwable $e) {
            http_response_code(500);
            echo json_encode(['success' => false, 'message' => 'Unable to inspect table: ' . $e->getMessage()]);
        }
        exit;
    }

    public function allTables(string $slug): void
    {
        header('Content-Type: application/json');
        $this->initProject($slug);

        $workspace = $this->loadWorkspaceState($slug);
        $selected = array_values(array_filter(array_map('strval', $workspace['selectedTables'] ?? [])));

        try {
            $existingNames = array_column($this->dialect()->listObjects(DB::Pdo(), $this->databaseConfig), 'name');
            if (empty($selected)) {
                $selected = $existingNames;
            } else {
                $selected = array_values(array_intersect($selected, $existingNames));
            }

            $columns = [];
            foreach ($selected as $tableName) {
                $safeTable = $this->normalizeIdentifier($tableName, 'Table name');
                // Columns only — this endpoint returns just column metadata, so
                // skip the index / foreign-key queries describeTable would run.
                $tableColumns = $this->dialect()->listColumns(DB::Pdo(), $safeTable, $this->databaseConfig);
                $columns[] = [
                    'table' => $safeTable,
                    'columns' => $tableColumns,
                ];
            }

            echo json_encode(['success' => true, 'columns' => $columns]);
        } catch (\Throwable $e) {
            http_response_code(500);
            echo json_encode(['success' => false, 'message' => 'Error: ' . $e->getMessage(), 'columns' => []]);
        }
        exit;
    }

    // -------------------------------------------------------
    //  Save / Sync – now properly persists full workspace state
    // -------------------------------------------------------
    public function saveChanges(string $slug): void
    {
        header('Content-Type: application/json');
        $this->initProject($slug);
        Security::RequireCsrf();

        $input = json_decode(file_get_contents('php://input'), true) ?? $_POST;
        $state = $this->loadWorkspaceState($slug);

        if (array_key_exists('selectedTables', $input)) {
            $state['selectedTables'] = $input['selectedTables'];
        }
        if (array_key_exists('positions', $input)) {
            $state['positions'] = $input['positions'];
        }
        if (array_key_exists('expandedTables', $input)) {
            $state['expandedTables'] = $input['expandedTables'];
        }
        if (array_key_exists('tableListSort', $input)) {
            $state['tableListSort'] = $input['tableListSort'];
        }
        if (array_key_exists('tableListFilter', $input)) {
            $state['tableListFilter'] = $input['tableListFilter'];
        }

        $this->saveWorkspaceState($slug, $state);
        echo json_encode(['success' => true, 'message' => 'Workspace state saved.']);
        exit;
    }

    // -------------------------------------------------------
    //  Relations (Master‑Detail) – now stored in tables.json via ModuleState
    // -------------------------------------------------------

    private function loadRelations(string $slug): array
    {
        $state = $this->loadWorkspaceState($slug);
        return $state['relations'] ?? [];
    }

    private function saveRelations(string $slug, array $relations): void
    {
        $state = $this->loadWorkspaceState($slug);
        $state['relations'] = $relations;
        $this->saveWorkspaceState($slug, $state);
    }

    public function listRelations(string $slug): void
    {
        header('Content-Type: application/json');
        try {
            $this->initProject($slug);
        } catch (\Exception $e) {
            http_response_code(500);
            echo json_encode(['success' => false, 'message' => 'Project init failed: ' . $e->getMessage()]);
            exit;
        }

        $relations = $this->loadRelations($slug);
        echo json_encode(['success' => true, 'relations' => $relations]);
        exit;
    }

    // -------------------------------------------------------
    //  Save workspace state (the explicit Save button)
    // -------------------------------------------------------
    public function saveState(string $slug): void
    {
        header('Content-Type: application/json');
        $this->initProject($slug);
        \PpPhp\Core\Security::RequireCsrf(); // beacon carries the token via the keepalive-fetch shim

        $input = json_decode(file_get_contents('php://input'), true) ?? $_POST;

        // Load current state
        $state = $this->loadWorkspaceState($slug);

        // Only update fields that were explicitly sent
        if (array_key_exists('selectedTables', $input)) {
            $state['selectedTables'] = $input['selectedTables'];
        }
        if (array_key_exists('positions', $input)) {
            $state['positions'] = $input['positions'];
        }
        if (array_key_exists('expandedTables', $input)) {
            $state['expandedTables'] = $input['expandedTables'];
        }
        if (array_key_exists('tableListSort', $input)) {
            $state['tableListSort'] = $input['tableListSort'];
        }
        if (array_key_exists('tableListFilter', $input)) {
            $state['tableListFilter'] = $input['tableListFilter'];
        }

        $this->saveWorkspaceState($slug, $state);

        echo json_encode(['success' => true, 'message' => 'State saved.']);
        exit;
    }

    // -------------------------------------------------------
    //  Private helpers – with versioning & validation
    // -------------------------------------------------------

    private function createViewResponse(string $slug, bool $replace, string $source): never
    {
        header('Content-Type: application/json');
        $this->initProject($slug);
        Security::RequireCsrf();
        $input = $this->jsonInput();
        try {
            $viewName = $this->normalizeIdentifier((string)($input['view_name'] ?? ''), 'View name');
            $sql = $this->normalizeViewSelectSql((string)($input['sql'] ?? ''));
            $this->designer()->createView($viewName, $sql, $replace);
            $this->persistViewState($slug, $viewName, $sql, $source);
            echo json_encode(['success' => true, 'message' => "View '{$viewName}' " . ($replace ? 'created or updated.' : 'created.')]);
        } catch (\InvalidArgumentException $e) {
            $this->jsonError($e->getMessage(), 400);
        } catch (\Throwable $e) {
            $this->jsonError('Unable to create view: ' . $e->getMessage(), 500);
        }
        exit;
    }

    private function normalizeRelationPayload(array $input, array $existing = []): array
    {
        $masterTable = $this->normalizeIdentifier((string)($input['master_table'] ?? $existing['master_table'] ?? ''), 'Master table');
        $detailTable = $this->normalizeIdentifier((string)($input['detail_table'] ?? $existing['detail_table'] ?? ''), 'Detail table');
        $masterField = $this->normalizeIdentifier((string)($input['master_field'] ?? $existing['master_field'] ?? ''), 'Master field');
        $detailField = $this->normalizeIdentifier((string)($input['detail_field'] ?? $existing['detail_field'] ?? ''), 'Detail field');
        $masterPages = is_array($input['master_pages'] ?? null)
            ? $this->normalizeRelationPages($input['master_pages'])
            : $this->normalizeRelationPages((array)($existing['master_pages'] ?? $existing['pages'] ?? []));
        $detailPages = is_array($input['detail_pages'] ?? null)
            ? $this->normalizeRelationPages($input['detail_pages'])
            : $this->normalizeRelationPages((array)($existing['detail_pages'] ?? []));
        $legacyPages = is_array($input['pages'] ?? null) ? $input['pages'] : (array)($existing['pages'] ?? []);
        if ($masterPages === [] && $legacyPages !== []) {
            $masterPages = $this->normalizeRelationPages($legacyPages);
        }
        $displayOption = (string)($input['display_option'] ?? $existing['display_option'] ?? 'inline');
        if (!in_array($displayOption, ['inline', 'popup', 'icon'], true)) {
            $displayOption = 'inline';
        }
        $linkMode = (string)($input['details_link_mode'] ?? $existing['details_link_mode'] ?? 'single');
        if (!in_array($linkMode, ['single', 'individual'], true)) {
            $linkMode = 'single';
        }
        $badgeColor = (string)($input['count_badge_color'] ?? $existing['count_badge_color'] ?? '#0d6efd');
        if (!preg_match('/^#[0-9a-f]{6}$/i', $badgeColor)) {
            $badgeColor = '#0d6efd';
        }

        // Optional child-row filter (WHERE) — supports SQL variables at run time.
        // Hardened: no statement chaining, and a leading WHERE/AND is stripped.
        $where = trim((string)($input['where'] ?? $existing['where'] ?? ''));
        $where = str_replace(["\0", ';'], '', $where);
        $where = preg_replace('/^(where|and)\s+/i', '', $where) ?? $where;

        return [
            'where' => $where,
            'master_table' => $masterTable,
            'detail_table' => $detailTable,
            'master_field' => $masterField,
            'detail_field' => $detailField,
            'display_option' => $displayOption,
            'add_foreign_key' => array_key_exists('add_foreign_key', $input) ? !empty($input['add_foreign_key']) : !empty($existing['add_foreign_key']),
            'on_delete_action' => $this->normalizeReferentialAction((string)($input['on_delete_action'] ?? $existing['on_delete_action'] ?? 'CASCADE')),
            'on_update_action' => $this->normalizeReferentialAction((string)($input['on_update_action'] ?? $existing['on_update_action'] ?? 'CASCADE')),
            'show_details_links' => array_key_exists('show_details_links', $input) ? !empty($input['show_details_links']) : ($existing['show_details_links'] ?? true),
            'details_link_mode' => $linkMode,
            'hide_details_no_records' => array_key_exists('hide_details_no_records', $input) ? !empty($input['hide_details_no_records']) : !empty($existing['hide_details_no_records']),
            'show_proceed_link' => array_key_exists('show_proceed_link', $input) ? !empty($input['show_proceed_link']) : ($existing['show_proceed_link'] ?? true),
            'hide_if_empty' => array_key_exists('hide_if_empty', $input) ? !empty($input['hide_if_empty']) : !empty($existing['hide_if_empty']),
            'show_master_on_detail' => $detailPages !== [] || (array_key_exists('show_master_on_detail', $input) ? !empty($input['show_master_on_detail']) : !empty($existing['show_master_on_detail'])),
            'display_child_count' => array_key_exists('display_child_count', $input) ? !empty($input['display_child_count']) : !empty($existing['display_child_count']),
            'count_badge_color' => $badgeColor,
            'master_pages' => $masterPages,
            'detail_pages' => $detailPages,
            'display_order' => max(0, (int)($input['display_order'] ?? $existing['display_order'] ?? 10)),
            'pages' => $legacyPages,
            'constraint_name' => (string)($existing['constraint_name'] ?? ''),
        ];
    }

    private function relationConstraintName(array $relation): string
    {
        $base = preg_replace('/[^A-Za-z0-9_]/', '_', 'fk_' . $relation['detail_table'] . '_' . $relation['detail_field']) ?: 'fk_relation';
        return substr($base, 0, 54) . '_' . substr(sha1($base), 0, 8);
    }

    private function foreignKeySignature(array $relation): string
    {
        return implode('|', [
            !empty($relation['add_foreign_key']) ? '1' : '0',
            (string)($relation['master_table'] ?? ''), (string)($relation['master_field'] ?? ''),
            (string)($relation['detail_table'] ?? ''), (string)($relation['detail_field'] ?? ''),
            $this->normalizeReferentialAction((string)($relation['on_delete_action'] ?? 'CASCADE')),
            $this->normalizeReferentialAction((string)($relation['on_update_action'] ?? 'CASCADE')),
        ]);
    }

    private function relationIndex(array $relations, string $id): ?int
    {
        foreach ($relations as $index => $relation) {
            if ((string)($relation['id'] ?? '') === $id) {
                return (int)$index;
            }
        }
        return null;
    }

    private function jsonInput(): array
    {
        $decoded = json_decode((string)file_get_contents('php://input'), true);
        return is_array($decoded) ? $decoded : $_POST;
    }

    private function jsonError(string $message, int $status, array $extra = []): never
    {
        http_response_code($status);
        echo json_encode(['success' => false, 'message' => $message] + $extra);
        exit;
    }

    private function designer(): TableDesignerService
    {
        if ($this->tableDesigner === null) {
            throw new \LogicException('The table designer has not been initialized.');
        }
        return $this->tableDesigner;
    }

    private function dialect(): DatabaseDialectInterface
    {
        if ($this->dialect === null) {
            throw new \LogicException('The project database dialect has not been initialized.');
        }
        return $this->dialect;
    }

    private function normalizeRelationPages(array $pages): array
    {
        $allowed = ['list', 'add', 'edit', 'view', 'print'];
        return array_values(array_intersect($allowed, array_map(static fn($page) => strtolower((string)$page), $pages)));
    }

    private function normalizeReferentialAction(string $action): string
    {
        $action = strtoupper(trim($action));
        $allowed = $this->dialect !== null
            ? (array)($this->dialect->uiConfig()['referential_actions'] ?? [])
            : ['CASCADE', 'RESTRICT', 'NO ACTION', 'SET NULL', 'SET DEFAULT'];
        return in_array($action, $allowed, true) ? $action : 'CASCADE';
    }

    private function appendUniqueString(array $values, string $value): array
    {
        $values = array_values(array_filter(array_map('strval', $values), static fn($item) => $item !== ''));
        if (!in_array($value, $values, true)) {
            $values[] = $value;
        }
        return $values;
    }

    private function normalizeIdentifier(string $value, string $label): string
    {
        $value = trim($value);
        if (!preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $value)) {
            throw new \InvalidArgumentException($label . ' must contain letters, numbers, and underscores only, and must start with a letter or underscore.');
        }
        return $value;
    }

    private function tableSchemaState(string $tableName, array $fields, string $type = 'TABLE'): array
    {
        return [
            'name' => $tableName,
            'type' => strtoupper($type) === 'VIEW' ? 'VIEW' : 'TABLE',
            'fields' => array_values(array_map(function (array $field): array {
                return [
                    'name' => (string)($field['name'] ?? ''),
                    'type' => strtoupper((string)($field['type'] ?? 'VARCHAR')),
                    'size' => (int)($field['size'] ?? 0),
                    'scale' => (int)($field['scale'] ?? 0),
                    'nullable' => !empty($field['nullable']),
                    'default_mode' => strtoupper((string)($field['default_mode'] ?? 'NONE')),
                    'default' => $field['default'] ?? '',
                    'default_expression' => $field['default_expression'] ?? null,
                    'primary_key' => !empty($field['primary_key']),
                    'auto_increment' => !empty($field['auto_increment']),
                    'enum_values' => (string)($field['enum_values'] ?? ''),
                ];
            }, $fields)),
            'updated_at' => date('c'),
        ];
    }

    private function persistViewState(string $slug, string $viewName, string $sql, string $source): void
    {
        $state = $this->loadWorkspaceState($slug);
        $state['selectedTables'] = $this->appendUniqueString($state['selectedTables'] ?? [], $viewName);
        $state['views'][$viewName] = [
            'name' => $viewName,
            'type' => 'VIEW',
            'source' => in_array($source, ['custom', 'sql'], true) ? $source : 'sql',
            'sql' => $sql,
            'updated_at' => date('c'),
        ];
        $state['tableSchemas'][$viewName] = [
            'name' => $viewName,
            'type' => 'VIEW',
            'fields' => $this->schemaFieldsFromDatabase($viewName),
            'updated_at' => date('c'),
        ];
        $this->saveWorkspaceState($slug, $state);
    }

    private function schemaFieldsFromDatabase(string $tableName): array
    {
        try {
            $columns = $this->designer()->describe($tableName)['columns'];
        } catch (\Throwable $e) {
            return [];
        }

        return array_values(array_map(static function (array $col): array {
            return [
                'name' => (string)($col['COLUMN_NAME'] ?? ''),
                'type' => strtoupper((string)($col['type'] ?? $col['DATA_TYPE'] ?? 'VARCHAR')),
                'size' => (int)($col['size'] ?? 0),
                'scale' => (int)($col['scale'] ?? 0),
                'nullable' => !empty($col['nullable']),
                'default_mode' => strtoupper((string)($col['default_mode'] ?? 'NONE')),
                'default' => $col['default'] ?? '',
                'default_expression' => $col['default_expression'] ?? null,
                'primary_key' => !empty($col['primary_key']),
                'auto_increment' => !empty($col['auto_increment']),
                'enum_values' => (string)($col['enum_values'] ?? ''),
            ];
        }, $columns));
    }

    private function renameTableInWorkspaceState(array $state, string $oldName, string $newName): array
    {
        $state['selectedTables'] = array_values(array_map(static fn($name) => $name === $oldName ? $newName : $name, $state['selectedTables'] ?? []));

        foreach ($state['positions'] ?? [] as &$pos) {
            if (($pos['table'] ?? '') === $oldName) {
                $pos['table'] = $newName;
            }
        }
        unset($pos);

        $state['expandedTables'] = array_values(array_map(static fn($name) => $name === $oldName ? $newName : $name, $state['expandedTables'] ?? []));

        foreach ($state['relations'] ?? [] as &$rel) {
            if (($rel['master_table'] ?? '') === $oldName) {
                $rel['master_table'] = $newName;
            }
            if (($rel['detail_table'] ?? '') === $oldName) {
                $rel['detail_table'] = $newName;
            }
        }
        unset($rel);

        if (isset($state['tableSchemas'][$oldName])) {
            $state['tableSchemas'][$newName] = $state['tableSchemas'][$oldName];
            $state['tableSchemas'][$newName]['name'] = $newName;
            $state['tableSchemas'][$newName]['updated_at'] = date('c');
            unset($state['tableSchemas'][$oldName]);
        }

        if (isset($state['views'][$oldName])) {
            $state['views'][$newName] = $state['views'][$oldName];
            $state['views'][$newName]['name'] = $newName;
            $state['views'][$newName]['updated_at'] = date('c');
            unset($state['views'][$oldName]);
        }

        return $state;
    }

    private function removeTableFromWorkspaceState(array $state, string $tableName): array
    {
        $state['selectedTables'] = array_values(array_diff($state['selectedTables'] ?? [], [$tableName]));
        $state['positions'] = array_values(array_filter($state['positions'] ?? [], static fn($p) => ($p['table'] ?? '') !== $tableName));
        $state['expandedTables'] = array_values(array_diff($state['expandedTables'] ?? [], [$tableName]));
        $state['relations'] = array_values(array_filter($state['relations'] ?? [], static function ($rel) use ($tableName): bool {
            return ($rel['master_table'] ?? '') !== $tableName && ($rel['detail_table'] ?? '') !== $tableName;
        }));
        unset($state['tableSchemas'][$tableName], $state['views'][$tableName]);
        return $state;
    }

    private function columnDefaultSql(string $type, string $nullableSql, bool $autoIncrement, array $field): string
    {
        if ($autoIncrement) {
            return '';
        }

        $type = strtoupper($type);
        $mode = strtoupper(trim((string)($field['default_mode'] ?? '')));
        $value = $field['default'] ?? null;
        $expression = $field['default_expression'] ?? null;

        if ($mode === '') {
            $mode = ($value !== null && $value !== '') ? 'VALUE' : 'NONE';
        }

        if (!in_array($mode, ['NONE', 'NULL', 'VALUE', 'CURRENT_TIMESTAMP', 'EXPRESSION'], true)) {
            throw new \InvalidArgumentException('Unsupported default mode.');
        }

        if ($mode === 'NONE') {
            return '';
        }

        if ($mode === 'NULL') {
            if ($nullableSql !== 'NULL') {
                throw new \InvalidArgumentException('DEFAULT NULL can only be used on nullable fields.');
            }
            return ' DEFAULT NULL';
        }

        if ($mode === 'CURRENT_TIMESTAMP') {
            if (!in_array($type, ['DATETIME', 'TIMESTAMP'], true)) {
                throw new \InvalidArgumentException('CURRENT_TIMESTAMP can only be used with DATETIME or TIMESTAMP fields.');
            }
            return ' DEFAULT CURRENT_TIMESTAMP';
        }

        $typesRequiringExpression = ['TEXT', 'TINYTEXT', 'MEDIUMTEXT', 'LONGTEXT', 'JSON', 'BLOB', 'TINYBLOB', 'MEDIUMBLOB', 'LONGBLOB'];

        if ($mode === 'EXPRESSION') {
            $expr = trim((string)($expression ?? $value ?? ''));
            if ($expr === '') {
                throw new \InvalidArgumentException('Expression default cannot be empty.');
            }
            if (str_contains($expr, ';') || str_contains($expr, '--') || str_contains($expr, '/*') || preg_match('/\bSELECT\b/i', $expr) || str_contains($expr, '@')) {
                throw new \InvalidArgumentException('Expression default contains unsupported SQL.');
            }
            return ' DEFAULT (' . $expr . ')';
        }

        if (in_array($type, $typesRequiringExpression, true)) {
            throw new \InvalidArgumentException("$type defaults must use Expression mode, not Value mode.");
        }

        if ($value === null) {
            return '';
        }

        if ($type === 'BOOLEAN') {
            $bool = strtolower(trim((string)$value));
            if (in_array($bool, ['1', 'true', 'yes'], true)) {
                return ' DEFAULT 1';
            }
            if (in_array($bool, ['0', 'false', 'no'], true)) {
                return ' DEFAULT 0';
            }
            throw new \InvalidArgumentException('Boolean literal defaults must be true/false or 1/0.');
        }

        if (in_array($type, ['INT', 'BIGINT', 'TINYINT', 'SMALLINT', 'MEDIUMINT', 'YEAR'], true)) {
            if (!preg_match('/^-?\d+$/', (string)$value)) {
                throw new \InvalidArgumentException("$type literal defaults must be whole numbers.");
            }
            return ' DEFAULT ' . (int)$value;
        }

        if (in_array($type, ['FLOAT', 'DOUBLE', 'DECIMAL'], true)) {
            if (!is_numeric($value)) {
                throw new \InvalidArgumentException("$type literal defaults must be numeric.");
            }
            return ' DEFAULT ' . trim((string)$value);
        }

        return " DEFAULT '" . addslashes((string)$value) . "'";
    }

    /**
     * Load workspace state with version upgrade and default values.
     * Merges existing saved data with a valid schema.
     */
    private function loadWorkspaceState(string $slug): array
    {
        $raw = ModuleState::load($slug, 'tables');
        $version = $raw['_state_version'] ?? 0;

        // Upgrade from older schema versions
        if ($version < self::STATE_VERSION) {
            $raw = $this->upgradeWorkspaceState($raw, $version);
            $raw['_state_version'] = self::STATE_VERSION;
            // Immediately save upgraded state
            ModuleState::save($slug, 'tables', $raw);
        }

        // Ensure all expected keys exist with default values
        $defaults = [
            'selectedTables'   => [],
            'positions'        => [],
            'relations'        => [],
            'tableSchemas'     => [],
            'views'            => [],
            'expandedTables'   => [],
            'tableListSort'    => 'name_asc',
            'tableListFilter'  => '',
            '_state_version'   => self::STATE_VERSION,
            '_last_updated'    => $raw['_last_updated'] ?? time(),
        ];

        return array_merge($defaults, $raw);
    }

    private function normalizeViewSelectSql(string $sql): string
    {
        $sql = trim($sql);
        if ($sql === '') {
            throw new \InvalidArgumentException('SQL query is required.');
        }

        $sql = rtrim($sql);
        if (str_ends_with($sql, ';')) {
            $sql = rtrim(substr($sql, 0, -1));
        }

        if (str_contains($sql, ';')) {
            throw new \InvalidArgumentException('Only one SELECT statement is allowed.');
        }

        if (!preg_match('/^\s*(SELECT|WITH)\b/i', $sql)) {
            throw new \InvalidArgumentException('Only SELECT statements can be used to create a view.');
        }

        if (preg_match('/\b(INTO|FOR\s+UPDATE|LOCK\s+IN\s+SHARE\s+MODE)\b/i', $sql)) {
            throw new \InvalidArgumentException('The SELECT statement contains an option that is not allowed for views.');
        }
        if (preg_match('/\b(INSERT|UPDATE|DELETE|MERGE|DROP|ALTER|CREATE|TRUNCATE|EXEC|EXECUTE|CALL|COPY|VACUUM|PRAGMA|ATTACH|DETACH)\b/i', $sql)) {
            throw new \InvalidArgumentException('Views may not contain data-changing or administrative statements.');
        }

        return $sql;
    }

    private function previewSelectSql(string $sql): string
    {
        return 'SELECT * FROM (' . $sql . ') AS pp_preview LIMIT 10';
    }

    /**
     * Upgrade from old state versions to current schema.
     * @param array $oldState
     * @param int $oldVersion
     * @return array
     */
    private function upgradeWorkspaceState(array $oldState, int $oldVersion): array
    {
        if ($oldVersion < 2) {
            if (!isset($oldState['tableSchemas']) || !is_array($oldState['tableSchemas'])) {
                $oldState['tableSchemas'] = [];
            }
            if (!isset($oldState['views']) || !is_array($oldState['views'])) {
                $oldState['views'] = [];
            }
        }
        return $oldState;
    }

    /**
     * Save workspace state with validation and optimistic locking (last-updated check).
     * Throws on conflict if client sends outdated timestamp.
     *
     * @param string $slug
     * @param array $state
     * @param int|null $clientTimestamp Optional timestamp from the client for conflict detection.
     * @throws \RuntimeException
     */
    private function saveWorkspaceState(string $slug, array $state, ?int $clientTimestamp = null): void
    {
        // Validate critical structures
        if (!is_array($state['selectedTables'] ?? null)) $state['selectedTables'] = [];
        if (!is_array($state['relations'] ?? null)) $state['relations'] = [];
        if (!is_array($state['expandedTables'] ?? null)) $state['expandedTables'] = [];
        if (!is_array($state['tableSchemas'] ?? null)) $state['tableSchemas'] = [];
        if (!is_array($state['views'] ?? null)) $state['views'] = [];
        $state['selectedTables'] = array_values(array_unique(array_filter(array_map('strval', $state['selectedTables']), static fn($name) => $name !== '')));
        $state['expandedTables'] = array_values(array_unique(array_filter(array_map('strval', $state['expandedTables']), static fn($name) => $name !== '')));

        // Filter and normalize positions
        if (!is_array($state['positions'] ?? null)) {
            $state['positions'] = [];
        }
        $validPositions = [];
        foreach ($state['positions'] as $pos) {
            // Normalize left/top to x/y if present
            if (isset($pos['left']) && !isset($pos['x'])) {
                $pos['x'] = $pos['left'];
            }
            if (isset($pos['top']) && !isset($pos['y'])) {
                $pos['y'] = $pos['top'];
            }
            // Now validate required fields
            if (!isset($pos['table'], $pos['x'], $pos['y'])) {
                error_log("ModuleState: Skipping invalid position entry: " . json_encode($pos));
                continue;
            }
            if (!is_numeric($pos['x']) || !is_numeric($pos['y'])) {
                error_log("ModuleState: Position x/y not numeric: " . json_encode($pos));
                continue;
            }
            $validPositions[] = $pos;
        }
        $state['positions'] = $validPositions;

        // Sort and filter strings
        $allowedSorts = ['name_asc', 'name_desc', 'type_asc', 'type_desc'];
        if (!in_array($state['tableListSort'] ?? '', $allowedSorts, true)) {
            $state['tableListSort'] = 'name_asc';
        }
        $state['tableListFilter'] = substr((string)($state['tableListFilter'] ?? ''), 0, 100);

        // Optimistic locking (optional)
        if ($clientTimestamp !== null) {
            $current = ModuleState::load($slug, 'tables');
            $lastSaved = $current['_last_updated'] ?? 0;
            if ($lastSaved > $clientTimestamp) {
                throw new \RuntimeException('State was updated by another user. Please refresh and try again.', 409);
            }
        }

        $state['_last_updated'] = time();
        ModuleState::save($slug, 'tables', $state);
    }

    private function initProject(string $slug): void
    {
        if (!Security::IsLoggedIn()) {
            redirect('/login');
        }

        $this->slug = $slug;

        // Central DB for project metadata
        DB::Init();
        $project = DB::Select('projects', ['slug' => $slug, 'created_by' => Security::CurrentUserData()['id']]);
        if (empty($project) && Security::IsAdmin()) {
            $project = DB::Select('projects', ['slug' => $slug]);
        }
        if (empty($project)) {
            http_response_code(404);
            echo 'Project not found.';
            exit;
        }
        $this->project = $project[0];

        // Standalone (database-free) projects lock the DB-only modules.
        if (\PpPhp\Core\ProjectDatabaseProfile::isStandaloneSlug($slug)) {
            $_SESSION['project_flash'] = 'Tables isn’t available for standalone apps — this project has no database.';
            redirect('/project/' . rawurlencode($slug) . '/workspace');
        }

        // Switch to project database
        DB::Init($slug);
        DB::SetConnection($slug);

        $configPath = \PpPhp\Core\ProjectLocator::dir($this->slug) . '/config/database.php';
        $this->databaseConfig = DatabaseConnection::normalize((array)require $configPath);
        $this->dialect = DatabaseDialectRegistry::forConfig($this->databaseConfig);
        $this->tableDesigner = new TableDesignerService(DB::Pdo(), $this->dialect, $this->databaseConfig, $this->fieldNormalizer);
    }

    private function getProjectDbName(): string
    {
        return (string)($this->databaseConfig['dbname'] ?? '');
    }
}
