<?php

declare(strict_types=1);

namespace PpPhp\Core\Auth;

use PpPhp\Core\DB;
use PpPhp\Core\Security;

/**
 * Authorization for a generated app: "may THIS user do X to table Y?".
 *
 * One engine, two data sources — the only difference between the modes is where
 * each group's rights come from:
 *   • static  — compiled into config/permissions.php at build time (read-only)
 *   • dynamic — read live from the generated DB, editable inside the app
 *   • off     — no enforcement at all (the historical behaviour)
 *
 * The check logic is identical for both, so nothing downstream cares which mode
 * produced the rights map.
 *
 * MEMBERSHIP vs RIGHTS. *What* a group may do is the configured/loaded rights.
 * *Who* is in a group is always live: a user's groups are their `user_groups`
 * rows PLUS their own role string treated as an implicit group. That bridges
 * the two worlds — a project that only uses the single `role` field still gets
 * permissions, and one using real groups gets the union of all of them.
 *
 * DENY BY DEFAULT. When enforcement is on, a non-admin group with no row for a
 * table cannot touch it. Admin groups and the owner/superadmin roles bypass
 * everything, so an app can never lock its own administrators out.
 */
final class Permissions
{
    /** off | static | dynamic */
    private static string $mode = 'off';

    /**
     * group name (lowercased) => [
     *   'admin' => bool,                       // full access, ignores table map
     *   'tables' => [table => [view,add,edit,delete,export,import => bool]],
     * ]
     */
    private static array $rights = [];

    /** Data tables the app governs (lowercased) — the set the menu filter gates on. */
    private static array $governed = [];

    private static bool $loaded = false;

    /** Roles that always have full access regardless of configured rights. */
    private const ADMIN_ROLES = ['owner', 'superadmin'];

    /** The CRUD verbs a table row can grant. */
    public const ACTIONS = ['view', 'add', 'edit', 'delete', 'export', 'import'];

    /** Request-cached lowercased group names of the current user. */
    private static ?array $myGroups = null;

    // ── configuration ───────────────────────────────────────────────────
    /**
     * Install a static rights map (called from the generated _runtime.php).
     *
     * @param array{mode?:string, groups?:array<string,array>} $config
     */
    public static function configure(array $config): void
    {
        self::$mode = in_array($config['mode'] ?? 'off', ['off', 'static', 'dynamic'], true)
            ? (string) $config['mode'] : 'off';
        self::$rights = [];
        foreach ((array) ($config['groups'] ?? []) as $name => $g) {
            self::$rights[mb_strtolower((string) $name)] = self::normaliseGroup((array) $g);
        }
        self::$governed = [];
        foreach ((array) ($config['tables'] ?? []) as $t) {
            self::$governed[mb_strtolower((string) $t)] = true;
        }
        // Static/off carry their rights in the config, so they are fully loaded.
        // Dynamic loads its rights lazily from the DB on first check.
        self::$loaded = self::$mode !== 'dynamic';
        self::$myGroups = null;
    }

    /** @param array<string,mixed> $g */
    private static function normaliseGroup(array $g): array
    {
        $tables = [];
        foreach ((array) ($g['tables'] ?? []) as $t => $perms) {
            $row = [];
            foreach (self::ACTIONS as $a) {
                $row[$a] = !empty($perms[$a]);
            }
            $tables[mb_strtolower((string) $t)] = $row;
        }
        return ['admin' => !empty($g['admin']), 'tables' => $tables];
    }

    public static function mode(): string
    {
        self::ensureLoaded();
        return self::$mode;
    }

    public static function enforcing(): bool
    {
        return self::mode() !== 'off';
    }

    /**
     * Dynamic mode loads its rights from the DB the first time anything is
     * asked. Static mode is already configured by _runtime.php. Both end up in
     * the same $rights shape.
     */
    private static function ensureLoaded(): void
    {
        if (self::$loaded) {
            return;
        }
        self::$loaded = true;
        if (self::$mode === 'dynamic') {
            self::loadDynamicRights();
        }
    }

    // ── the current user's groups ───────────────────────────────────────
    /**
     * Every group the signed-in user effectively belongs to, lowercased:
     * their `user_groups` rows joined to group names, plus their role string.
     * Request-cached — the same set is consulted on every gate of a page.
     *
     * @return list<string>
     */
    public static function currentUserGroups(): array
    {
        if (self::$myGroups !== null) {
            return self::$myGroups;
        }
        $groups = [];
        $role = mb_strtolower(trim((string) Security::CurrentUserGroup()));
        if ($role !== '') {
            $groups[] = $role;   // role acts as an implicit group
        }
        $uid = (int) (Security::CurrentUserData()['id'] ?? 0);
        if ($uid > 0) {
            try {
                $rows = DB::FetchAll(
                    'SELECT g.name AS n FROM `user_groups` ug JOIN `groups` g ON g.id = ug.group_id WHERE ug.user_id = ?',
                    [$uid]
                );
                foreach ((array) $rows as $r) {
                    $n = mb_strtolower(trim((string) ($r['n'] ?? '')));
                    if ($n !== '') {
                        $groups[] = $n;
                    }
                }
            } catch (\Throwable $e) {
                // no user_groups table (single-role project) — role alone stands
            }
        }
        return self::$myGroups = array_values(array_unique($groups));
    }

    /** True when the current user should bypass every check. */
    public static function isAdmin(): bool
    {
        $mine = self::currentUserGroups();
        if (array_intersect($mine, self::ADMIN_ROLES) !== []) {
            return true;
        }
        self::ensureLoaded();
        foreach ($mine as $g) {
            if (!empty(self::$rights[$g]['admin'])) {
                return true;
            }
        }
        return false;
    }

    // ── the checks ──────────────────────────────────────────────────────
    /**
     * May the current user perform $action on $table?
     *
     * With enforcement off this is always true. Admins bypass. Otherwise the
     * answer is the union across the user's groups — any one granting it wins.
     */
    public static function can(string $action, string $table): bool
    {
        self::ensureLoaded();
        if (self::$mode === 'off') {
            return true;
        }
        $action = mb_strtolower($action);
        if (!in_array($action, self::ACTIONS, true)) {
            return true;   // unknown verb — do not accidentally block it
        }
        if (self::isAdmin()) {
            return true;
        }
        $table = mb_strtolower($table);
        foreach (self::currentUserGroups() as $g) {
            if (!empty(self::$rights[$g]['tables'][$table][$action])) {
                return true;
            }
        }
        return false;
    }

    /** Map a generated page type to the table permission it requires. */
    public static function pageAction(string $pageType): string
    {
        return match (mb_strtolower($pageType)) {
            'add'                    => 'add',
            'edit'                   => 'edit',
            'delete'                 => 'delete',
            'export', 'print', 'pdf' => 'export',
            'import'                 => 'import',
            default                  => 'view',   // list, view, dashboard, …
        };
    }

    /** May the current user open a page of $pageType on $table? */
    public static function pageAllowed(string $pageType, string $table): bool
    {
        return self::can(self::pageAction($pageType), $table);
    }

    /** Does the user have ANY access to a table (used for menu filtering)? */
    public static function anyAccess(string $table): bool
    {
        self::ensureLoaded();
        if (self::$mode === 'off' || self::isAdmin()) {
            return true;
        }
        $table = mb_strtolower($table);
        foreach (self::currentUserGroups() as $g) {
            foreach (self::ACTIONS as $a) {
                if (!empty(self::$rights[$g]['tables'][$table][$a])) {
                    return true;
                }
            }
        }
        return false;
    }

    /** Is $table one the engine governs (a real data table)? */
    public static function governs(string $table): bool
    {
        return isset(self::$governed[mb_strtolower($table)]);
    }

    /**
     * Gate a generated page. Redirects a signed-in user who lacks access to a
     * friendly "no access" page rather than showing a raw 403, and stops.
     */
    public static function requirePage(string $pageType, string $table): void
    {
        if (self::pageAllowed($pageType, $table)) {
            return;
        }
        $base = defined('APP_BASE') ? APP_BASE : '';
        if (!headers_sent()) {
            $_SESSION['_flash'] = [
                'message' => 'You do not have permission to open that page.',
                'type'    => 'danger',
            ];
            header('Location: ' . $base . '/generated/auth/no-access.php');
        }
        exit;
    }

    // ── dynamic mode (Phase 2 fills this in) ────────────────────────────
    /**
     * Build the rights map from the live DB tables (dynamic mode). Kept as a
     * seam now so the engine's shape is final; Phase 2 implements the reads
     * from group_permissions / groups.
     */
    private static function loadDynamicRights(): void
    {
        try {
            $groups = DB::FetchAll('SELECT id, name, is_admin_group FROM `groups`');
            $perms  = DB::FetchAll('SELECT group_id, table_name, perm_view, perm_add, perm_edit, perm_delete, perm_export, perm_import FROM `group_permissions`');
        } catch (\Throwable $e) {
            return;   // tables absent — leave rights empty (deny-by-default)
        }
        $byId = [];
        foreach ((array) $groups as $g) {
            $byId[(int) $g['id']] = [
                'name'  => mb_strtolower((string) $g['name']),
                'admin' => !empty($g['is_admin_group']),
                'tables'=> [],
            ];
        }
        foreach ((array) $perms as $p) {
            $gid = (int) $p['group_id'];
            if (!isset($byId[$gid])) {
                continue;
            }
            $byId[$gid]['tables'][mb_strtolower((string) $p['table_name'])] = [
                'view'   => !empty($p['perm_view']),
                'add'    => !empty($p['perm_add']),
                'edit'   => !empty($p['perm_edit']),
                'delete' => !empty($p['perm_delete']),
                'export' => !empty($p['perm_export']),
                'import' => !empty($p['perm_import']),
            ];
        }
        foreach ($byId as $g) {
            self::$rights[$g['name']] = ['admin' => $g['admin'], 'tables' => $g['tables']];
        }
    }
}
