<?php
/*
 * Copyright 2026 Alessandro Chiapparini <sprint_planer_web@chiapparini.org>
 * SPDX-License-Identifier: Apache-2.0
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * See the LICENSE file in the project root for the full license text.
 */

declare(strict_types=1);

namespace App\Db;

use PDO;
use RuntimeException;

/**
 * Dirt-simple forward-only migration runner.
 *
 * - Scans `migrations/` for files matching `NNN_*.sql` (3+ digit prefix).
 * - Records applied versions in `schema_version(version, filename, applied_at)`.
 * - Each file is executed in a single transaction. No down-migrations.
 *
 * R01-N22: invoked at deploy time by `bin/migrate.php` (Docker entrypoint).
 * The web request path now only CHECKS via `pendingFiles()` and refuses to
 * serve when the on-disk migration set is ahead of `schema_version` — never
 * applying SQL itself, so a request that crashes mid-DDL can no longer leave
 * a half-migrated schema behind.
 */
final class Migrator
{
    private PDO $pdo;
    private string $dir;

    public function __construct(PDO $pdo, ?string $migrationsDir = null)
    {
        $this->pdo = $pdo;
        $this->dir = $migrationsDir ?? dirname(__DIR__, 2) . '/migrations';
    }

    /** Run all pending migrations. Returns the list of files applied this call. */
    public function migrate(): array
    {
        $this->ensureVersionTable();

        $applied = $this->appliedVersions();
        $files = $this->discover();

        $ran = [];
        foreach ($files as [$version, $filename, $fullPath]) {
            if (isset($applied[$version])) {
                continue;
            }
            $this->apply($version, $filename, $fullPath);
            $ran[] = $filename;
        }
        return $ran;
    }

    /**
     * @return list<string> filenames present on disk whose version is not yet
     * recorded in `schema_version`. Empty list ⇒ schema is current.
     *
     * Cheap read-only check: opens `schema_version` (creates it if missing —
     * a brand-new database needs the table even for an empty `pendingFiles()`
     * result to make sense), reads applied versions, diffs against the file
     * scan. No DDL beyond `CREATE TABLE IF NOT EXISTS schema_version`.
     */
    public function pendingFiles(): array
    {
        $this->ensureVersionTable();
        $applied = $this->appliedVersions();
        $out = [];
        foreach ($this->discover() as [$version, $filename, $_]) {
            if (!isset($applied[$version])) {
                $out[] = $filename;
            }
        }
        return $out;
    }

    public function currentVersion(): int
    {
        $this->ensureVersionTable();
        $stmt = $this->pdo->query('SELECT COALESCE(MAX(version), 0) AS v FROM schema_version');
        $row = $stmt->fetch();
        return (int) ($row['v'] ?? 0);
    }

    private function ensureVersionTable(): void
    {
        $this->pdo->exec(<<<SQL
            CREATE TABLE IF NOT EXISTS schema_version (
                version    INTEGER PRIMARY KEY,
                filename   TEXT NOT NULL,
                applied_at TEXT NOT NULL
            )
        SQL);
    }

    /** @return array<int,true> version => true */
    private function appliedVersions(): array
    {
        $out = [];
        $stmt = $this->pdo->query('SELECT version FROM schema_version');
        foreach ($stmt as $row) {
            $out[(int) $row['version']] = true;
        }
        return $out;
    }

    /**
     * @return list<array{0:int,1:string,2:string}>  [version, filename, fullPath] sorted by version asc
     */
    private function discover(): array
    {
        if (!is_dir($this->dir)) {
            return [];
        }

        $entries = scandir($this->dir) ?: [];
        $out = [];
        foreach ($entries as $name) {
            if (!preg_match('/^(\d{3,})_[A-Za-z0-9_\-]+\.sql$/', $name, $m)) {
                continue;
            }
            $version = (int) $m[1];
            $out[] = [$version, $name, $this->dir . DIRECTORY_SEPARATOR . $name];
        }
        usort($out, static fn($a, $b) => $a[0] <=> $b[0]);
        return $out;
    }

    /**
     * R01-N29: `file_get_contents` is unbounded by design. Migration files are
     * committed code reviewed in PRs — not user-supplied — so the trust model
     * matches the rest of the source tree. If `migrations/` ever accepts
     * non-reviewed input, revisit.
     */
    private function apply(int $version, string $filename, string $fullPath): void
    {
        $sql = file_get_contents($fullPath);
        if ($sql === false) {
            throw new RuntimeException("Cannot read migration: {$fullPath}");
        }

        $this->pdo->beginTransaction();
        try {
            // PDO::exec accepts multiple statements separated by ';' on sqlite.
            $this->pdo->exec($sql);
            $stmt = $this->pdo->prepare(
                'INSERT INTO schema_version (version, filename, applied_at) VALUES (?, ?, ?)'
            );
            $stmt->execute([$version, $filename, gmdate('Y-m-d\TH:i:s\Z')]);
            $this->pdo->commit();
        } catch (\Throwable $e) {
            $this->pdo->rollBack();
            throw new RuntimeException(
                "Migration failed ({$filename}): " . $e->getMessage(),
                0,
                $e
            );
        }
    }
}
