<?php
declare(strict_types=1);

/**
 * Versioned migrations.
 *
 * Each migration lives in /migrations/NNN_short_description.php and returns
 * a callable: function (PDO $pdo): void { ... }
 *
 * On every boot the migrator checks the applied_migrations table for ids it
 * has not yet seen and runs them in lexical order. Each migration runs in a
 * transaction; on failure the transaction rolls back, the migration is NOT
 * marked applied, and the boot continues so the rest of the app can still
 * report the error to an admin.
 *
 * The Database constructor still keeps its legacy idempotent CREATE/ALTER
 * block for changes made before this system existed; new schema work goes
 * through versioned migration files.
 */
class Migrator
{
    public static function dir(): string
    {
        return BASE_PATH . '/migrations';
    }

    public static function run(): array
    {
        $log = [];
        $dir = self::dir();
        if (!is_dir($dir)) return $log;

        $db  = Database::getInstance();
        $pdo = $db->getPdo();

        $pdo->exec("
            CREATE TABLE IF NOT EXISTS applied_migrations (
                id          TEXT PRIMARY KEY,
                applied_at  DATETIME DEFAULT CURRENT_TIMESTAMP
            )
        ");

        $applied = [];
        foreach ($pdo->query('SELECT id FROM applied_migrations') as $row) {
            $applied[$row['id']] = true;
        }

        $files = glob($dir . '/*.php') ?: [];
        sort($files, SORT_NATURAL);

        $hadFailure = false;
        $failures   = [];

        foreach ($files as $file) {
            $id = basename($file, '.php');
            if (isset($applied[$id])) continue;

            $migration = require $file;
            if (!is_callable($migration)) {
                $log[] = ['id' => $id, 'ok' => false, 'error' => 'migration file did not return a callable'];
                $hadFailure = true;
                $failures[] = ['id' => $id, 'error' => 'file did not return a callable'];
                continue;
            }

            try {
                $pdo->beginTransaction();
                $migration($pdo);
                $stmt = $pdo->prepare('INSERT INTO applied_migrations (id) VALUES (?)');
                $stmt->execute([$id]);
                $pdo->commit();
                $log[] = ['id' => $id, 'ok' => true];
            } catch (\Throwable $e) {
                if ($pdo->inTransaction()) $pdo->rollBack();
                error_log('Migrator failed for ' . $id . ': ' . $e->getMessage());
                $log[] = ['id' => $id, 'ok' => false, 'error' => $e->getMessage()];
                $hadFailure = true;
                $failures[] = ['id' => $id, 'error' => $e->getMessage()];
            }
        }

        // Persist failure state so the Health Check can surface it to admins.
        // Cleared on a clean run so transient failures self-heal once fixed.
        try {
            if ($hadFailure) {
                $pdo->prepare("INSERT INTO settings (key,value) VALUES ('migration_last_error',?)
                               ON CONFLICT(key) DO UPDATE SET value = excluded.value")
                    ->execute([json_encode([
                        'at'       => date(DATE_ATOM),
                        'failures' => $failures,
                    ])]);
            } else {
                $pdo->exec("DELETE FROM settings WHERE key='migration_last_error'");
            }
        } catch (\Throwable) { /* settings table may not exist on a brand-new install */ }

        return $log;
    }

    /**
     * Record every existing migration file as already applied WITHOUT executing
     * its body. Used during fresh-install bootstrap right after Database::schema()
     * has created the full target schema. Effect: the migrator on the next boot
     * sees no pending work and skips the historical CREATE/ALTER chain that the
     * declarative schema already produced. Safe to call repeatedly: idempotent
     * on the applied_migrations table via INSERT OR IGNORE.
     */
    public static function markAllApplied(): int
    {
        $dir = self::dir();
        if (!is_dir($dir)) return 0;
        $pdo = Database::getInstance()->getPdo();
        $pdo->exec("CREATE TABLE IF NOT EXISTS applied_migrations (id TEXT PRIMARY KEY, applied_at DATETIME DEFAULT CURRENT_TIMESTAMP)");
        $stmt = $pdo->prepare('INSERT OR IGNORE INTO applied_migrations (id) VALUES (?)');
        $count = 0;
        foreach (glob($dir . '/*.php') ?: [] as $file) {
            $stmt->execute([basename($file, '.php')]);
            $count++;
        }
        return $count;
    }

    /** Number of unapplied migration files. 0 means everything is in sync. */
    public static function pendingCount(): int
    {
        $dir = self::dir();
        if (!is_dir($dir)) return 0;
        try {
            $pdo = Database::getInstance()->getPdo();
            $pdo->exec("CREATE TABLE IF NOT EXISTS applied_migrations (id TEXT PRIMARY KEY, applied_at DATETIME DEFAULT CURRENT_TIMESTAMP)");
            $applied = [];
            foreach ($pdo->query('SELECT id FROM applied_migrations') as $row) $applied[$row['id']] = true;
            $files = glob($dir . '/*.php') ?: [];
            $pending = 0;
            foreach ($files as $f) {
                if (!isset($applied[basename($f, '.php')])) $pending++;
            }
            return $pending;
        } catch (\Throwable) { return 0; }
    }

    /** Decoded last failure record or null when the latest run was clean. */
    public static function lastError(): ?array
    {
        try {
            $row = Database::getInstance()->fetch("SELECT value FROM settings WHERE key='migration_last_error'");
            if (!$row) return null;
            $data = json_decode($row['value'], true);
            return is_array($data) ? $data : null;
        } catch (\Throwable) { return null; }
    }
}
