<?php

declare(strict_types=1);

namespace PpPhp\Core\Auth;

/**
 * Single source of truth for the authentication users-table name and its column
 * mapping in a GENERATED application.
 *
 * The build writes config/auth.php from the Security module's Database
 * authentication settings ("Use existing table" → a custom table name + a
 * logical→physical column map, or "Create new table" → the standard schema).
 * Every auth code path (login, register, password reset, 2FA, session checks,
 * the admin area) resolves the table and columns through here instead of
 * hardcoding `users` / `username` / `password` / …
 *
 * When config/auth.php is ABSENT — the PPPHP console itself, and every app built
 * before this feature — load() falls back to the historical defaults (table
 * `users`, identity columns named after their logical field). That keeps all
 * pre-existing behaviour byte-for-byte identical, so this class is safe to route
 * through unconditionally.
 */
final class UserSchema
{
    /** @var array{table:string,columns:array<string,string>}|null */
    private static ?array $cfg = null;

    /** Logical field → historical default physical column name. */
    private const DEFAULTS = [
        'id'          => 'id',
        'username'    => 'username',
        'email'       => 'email',
        'password'    => 'password',
        'fullname'    => 'fullname',
        'role'        => 'role',
        'status'      => 'status',
        'totp_secret' => 'totp_secret',
    ];

    /** Fields the runtime cannot work without — always resolved, even if unmapped. */
    private const REQUIRED = ['id', 'username', 'email', 'password'];

    /** @return array{table:string,columns:array<string,string>} */
    private static function load(): array
    {
        if (self::$cfg !== null) {
            return self::$cfg;
        }

        $table   = 'users';
        $columns = self::DEFAULTS;

        $file = null;
        if (defined('BASE_PATH') && is_file(BASE_PATH . '/config/auth.php')) {
            $file = BASE_PATH . '/config/auth.php';
        } elseif (is_file(dirname(__DIR__, 2) . '/config/auth.php')) {
            $file = dirname(__DIR__, 2) . '/config/auth.php';
        }

        if ($file !== null) {
            $loaded = @require $file;
            if (is_array($loaded)) {
                if (!empty($loaded['table'])) {
                    $table = (string) $loaded['table'];
                }
                if (!empty($loaded['columns']) && is_array($loaded['columns'])) {
                    // Keep only real string mappings. Unmapped OPTIONAL fields
                    // (fullname / role / status / totp_secret) stay absent, so
                    // the runtime never SELECTs a column that isn't there.
                    $columns = [];
                    foreach ($loaded['columns'] as $logical => $physical) {
                        if (is_string($physical) && $physical !== '') {
                            $columns[(string) $logical] = $physical;
                        }
                    }
                    // Required identity fields always resolve to *something*.
                    foreach (self::REQUIRED as $r) {
                        if (empty($columns[$r])) {
                            $columns[$r] = self::DEFAULTS[$r];
                        }
                    }
                }
            }
        }

        $table = self::ident($table, 'users');
        foreach ($columns as $logical => $physical) {
            $columns[$logical] = self::ident((string) $physical, self::DEFAULTS[$logical] ?? $logical);
        }

        return self::$cfg = ['table' => $table, 'columns' => $columns];
    }

    /** Strip everything but [A-Za-z0-9_] so a name can never break out of an identifier. */
    private static function ident(string $value, string $fallback): string
    {
        $clean = preg_replace('/[^A-Za-z0-9_]/', '', $value) ?? '';
        return $clean !== '' ? $clean : $fallback;
    }

    /** Reset the cache (tests only). */
    public static function reset(): void
    {
        self::$cfg = null;
    }

    /** Physical auth table name. */
    public static function table(): string
    {
        return self::load()['table'];
    }

    /** Backtick-quoted auth table name. */
    public static function qTable(): string
    {
        return '`' . self::table() . '`';
    }

    /** True if a logical field maps to a real column. */
    public static function has(string $logical): bool
    {
        return isset(self::load()['columns'][$logical]);
    }

    /** Physical column for a logical field, or null when unmapped. */
    public static function col(string $logical): ?string
    {
        return self::load()['columns'][$logical] ?? null;
    }

    /** Backtick-quoted physical column, or null when unmapped. */
    public static function qCol(string $logical): ?string
    {
        $c = self::col($logical);
        return $c !== null ? '`' . $c . '`' : null;
    }

    /**
     * Remap an associative array keyed by LOGICAL field names to the physical
     * column names, for DB::Insert / DB::Update on the auth table. Keys that
     * are not logical auth fields pass through unchanged; logical keys with no
     * mapped column are dropped (the table doesn't have them).
     *
     * @param array<string,mixed> $row
     * @return array<string,mixed>
     */
    public static function remap(array $row): array
    {
        $cols = self::load()['columns'];
        $out = [];
        foreach ($row as $key => $value) {
            if (array_key_exists($key, self::DEFAULTS)) {
                if (isset($cols[$key])) {
                    $out[$cols[$key]] = $value;
                }
                // logical field with no column → skip (not present on the table)
            } else {
                $out[$key] = $value;   // non-identity column, pass through
            }
        }
        return $out;
    }

    /**
     * A SELECT column list that exposes every mapped field under its LOGICAL
     * name, so downstream code can keep reading $row['password'] / $row['email']
     * regardless of the real column names:  `*, `pass_hash` AS `password`, …`.
     * When physical == logical (the default schema) it collapses to just `*`.
     */
    public static function aliasedColumns(): string
    {
        $parts = ['*'];
        foreach (self::load()['columns'] as $logical => $physical) {
            if ($physical !== $logical) {
                $parts[] = "`{$physical}` AS `{$logical}`";
            }
        }
        return implode(', ', $parts);
    }
}
