<?php

declare(strict_types=1);

namespace PpPhp\Core\Auth;

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

/**
 * Row-Level Security enforcement for a generated app.
 *
 * Rules are authored in the console (Security → Advanced → Row-Level Security) and
 * stored per (table, group) in `row_security_rules`. The generated app shares that
 * database, so rules are read LIVE here — changing them in the console takes effect
 * with no rebuild (like dynamic permissions).
 *
 * Model (matches the console Tips):
 *  - RLS filters rows AFTER table permission has already allowed the table.
 *  - Admins and the owner/superadmin bypass RLS entirely (never lock admins out).
 *  - A user's groups combine with OR (union): they see rows allowed by ANY group.
 *  - A group with NO rule on a table grants full access — so if any of the user's
 *    groups is unrestricted, they see everything. A table with no rules = all rows.
 *  - `owner_column`  → `col = <current user id>`.
 *  - `custom_condition` → `field <op> <value>` (value safely quoted).
 *
 * `clause()` returns a self-contained, safely-quoted SQL condition (no `WHERE`
 * keyword) or '' when the current user is unrestricted — callers just AND it in.
 */
final class RowSecurity
{
    private const OPERATORS = ['=', '!=', '>', '<', '>=', '<=', 'LIKE'];

    private static ?bool $bypass = null;
    private static ?array $groupIds = null;
    /** @var array<string,array<int,array<string,mixed>>> */
    private static array $rulesCache = [];

    /** Admins / owner see every row. */
    public static function bypass(): bool
    {
        if (self::$bypass !== null) return self::$bypass;
        try { self::$bypass = Permissions::isAdmin(); }
        catch (\Throwable $e) { self::$bypass = false; }
        return self::$bypass;
    }

    /** The bare SQL condition for the current user on $table, or '' if unrestricted. */
    public static function clause(string $table): string
    {
        if ($table === '' || self::bypass()) return '';
        $rules = self::rulesFor($table);
        if ($rules === []) return '';               // no rule anywhere on this table
        $groups = self::userGroupIds();
        if ($groups === []) return '';              // user in no group → not restricted
        $conds = [];
        foreach ($groups as $gid) {
            if (!isset($rules[$gid])) return '';    // an unrestricted group → full access
            $c = self::one($rules[$gid]);
            if ($c === '') return '';               // malformed rule → fail open (never lock out)
            $conds[] = $c;
        }
        return $conds === [] ? '' : '(' . implode(' OR ', $conds) . ')';
    }

    /** Convenience: ' AND (clause)' ready to append to an existing WHERE, or ''. */
    public static function andSql(string $table): string
    {
        $c = self::clause($table);
        return $c === '' ? '' : ' AND ' . $c;
    }

    /** Merge the RLS condition into an existing WHERE string (handles empty). */
    public static function mergeWhere(string $where, string $table): string
    {
        $c = self::clause($table);
        if ($c === '') return $where;
        $w = trim($where);
        return $w === '' ? 'WHERE ' . $c : $where . ' AND ' . $c;
    }

    /** True when the current user may see/act on a specific record (for write guards). */
    public static function canAccess(string $table, int $id): bool
    {
        $c = self::clause($table);
        if ($c === '') return true;
        try {
            $row = DB::FetchAll("SELECT 1 FROM `$table` WHERE id = ? AND $c LIMIT 1", [$id]);
            return !empty($row);
        } catch (\Throwable $e) {
            return true; // fail open — RLS must never break a legitimate action
        }
    }

    // ── internals ───────────────────────────────────────────────────────
    private static function one(array $rule): string
    {
        $type = (string) ($rule['rule_type'] ?? '');
        if ($type === 'owner_column') {
            $col = (string) ($rule['owner_column'] ?? '');
            if (!preg_match('/^[A-Za-z0-9_]+$/', $col)) return '';
            return '`' . $col . '` = ' . self::userId();
        }
        if ($type === 'custom_condition') {
            $cond = json_decode((string) ($rule['condition_json'] ?? ''), true);
            if (!is_array($cond)) return '';
            $field = (string) ($cond['field'] ?? '');
            $op    = (string) ($cond['operator'] ?? '=');
            $val   = $cond['value'] ?? '';
            if (!preg_match('/^[A-Za-z0-9_]+$/', $field)) return '';
            if (!in_array($op, self::OPERATORS, true)) return '';
            return '`' . $field . '` ' . $op . ' ' . self::quote($val);
        }
        return '';
    }

    private static function quote(mixed $val): string
    {
        if (is_int($val) || is_float($val)) return (string) $val;
        $s = (string) $val;
        if (preg_match('/^-?\d+(\.\d+)?$/', $s)) return $s;   // numeric literal
        try { return DB::Pdo()->quote($s); }
        catch (\Throwable $e) { return "'" . str_replace("'", "''", $s) . "'"; }
    }

    private static function userId(): int
    {
        return (int) (Security::CurrentUserData()['id'] ?? 0);
    }

    /** @return int[] the current user's group ids */
    private static function userGroupIds(): array
    {
        if (self::$groupIds !== null) return self::$groupIds;
        $uid = self::userId();
        if ($uid <= 0) return self::$groupIds = [];
        try {
            $rows = DB::FetchAll('SELECT group_id FROM `user_groups` WHERE user_id = ?', [$uid]);
            self::$groupIds = array_values(array_unique(array_map(static fn($r) => (int) $r['group_id'], is_array($rows) ? $rows : [])));
        } catch (\Throwable $e) {
            self::$groupIds = [];
        }
        return self::$groupIds;
    }

    /** @return array<int,array<string,mixed>> group_id => rule row */
    private static function rulesFor(string $table): array
    {
        if (isset(self::$rulesCache[$table])) return self::$rulesCache[$table];
        $map = [];
        try {
            $rows = DB::FetchAll(
                'SELECT group_id, rule_type, owner_column, condition_json FROM `row_security_rules` WHERE table_name = ?',
                [$table]
            );
            foreach ((array) $rows as $r) { $map[(int) $r['group_id']] = $r; }
        } catch (\Throwable $e) {
            $map = []; // table missing on this deployment → no RLS (fail open)
        }
        return self::$rulesCache[$table] = $map;
    }
}
