<?php

declare(strict_types=1);

namespace Instinctor\Core\Session;


if (!defined('ABSPATH')) exit;
use Instinctor\Core\DB;

/**
 * Session\StandaloneDriver — HMAC-signed stateless CSRF tokens + DB-backed auth sessions.
 *
 * CSRF tokens (token() / verify() / check()):
 *   Stateless HMAC-SHA256 tokens — no DB hit required. Format:
 *     base64url( expiry_unix_ts . '|' . HMAC-SHA256(secret, action . '|' . expiry) )
 *   10-minute validity window, same as WP nonces. Token reuse within the window is fine.
 *
 * Auth sessions (create() / destroy() / checkToken()):
 *   Token is a 32-byte random hex string. Hash stored in instinctor_sessions with
 *   user_id and expires_at. Called by LoginController and AdminRouter.
 *
 * APP_SECRET is read from INSTINCTOR_APP_SECRET constant (set by bootstrap from .env).
 * Falls back to a hash of DB credentials — functional but not ideal for production.
 *
 * @since 3.18.50
 */
final class StandaloneDriver implements DriverInterface
{
    /** CSRF token validity in seconds — matches WordPress nonce 12h window for consistent UX. */
    private const CSRF_TTL = 43200;

    /** Cached current user ID from auth cookie — resolved once per request */
    private static ?int   $cachedUserId = null;
    private static ?object $cachedUser  = null;

    private string $secret;
    private string $sessTable;

    public function __construct()
    {
        $this->secret    = defined('INSTINCTOR_APP_SECRET')
            ? INSTINCTOR_APP_SECRET
            : sha1((defined('INSTINCTOR_DB_NAME') ? INSTINCTOR_DB_NAME : '') . (defined('INSTINCTOR_DB_PASS') ? INSTINCTOR_DB_PASS : ''));

        // Option A (2026-07-05): this driver now backs customer sessions on
        // WordPress too, so resolve the ACTUAL table prefix via DB::prefix()
        // ($wpdb->prefix on WP, INSTINCTOR_DB_PREFIX on standalone). The old
        // `defined(INSTINCTOR_DB_PREFIX) ?: 'wp_'` fallback wrote to
        // wp_instinctor_sessions on WP installs whose real prefix differs.
        $prefix          = \Instinctor\Core\DB::prefix();
        $this->sessTable = $prefix . 'instinctor_sessions';
    }

    // ── DriverInterface — CSRF ────────────────────────────────────────────────

    public function token(string $action): string
    {
        $expiry = time() + self::CSRF_TTL;
        $hmac   = hash_hmac('sha256', $action . '|' . $expiry, $this->secret);
        return $this->b64url(json_encode(['e' => $expiry, 'h' => $hmac]));
    }

    public function verify(string $token, string $action): bool
    {
        $decoded = $this->b64urlDecode($token);
        if ($decoded === null) return false;

        $data = json_decode($decoded, true);
        if (!is_array($data) || empty($data['e']) || empty($data['h'])) return false;

        $expiry = (int) $data['e'];
        if ($expiry < time()) return false; // expired

        $expected = hash_hmac('sha256', $action . '|' . $expiry, $this->secret);
        return hash_equals($expected, (string) $data['h']);
    }

    public function check(string $action, string $field = 'nonce', bool $die = true): bool
    {
        // phpcs:ignore WordPress.Security.NonceVerification.Missing -- this IS the nonce verification implementation
        // phpcs:disable WordPress.Security.NonceVerification -- request authenticated via the plugin's check_ajax_referer()/verify() HMAC nonce (standalone auth lifecycle; WPCS does not recognise Session::check as a nonce function); all inputs are sanitize_*'d + wp_unslash'd.
        $token = sanitize_text_field(wp_unslash($_POST[$field] ?? $_GET[$field] ?? ''));
        // phpcs:enable WordPress.Security.NonceVerification
        $valid = $this->verify((string) $token, $action);

        if (!$valid && $die) {
            http_response_code(403);
            echo wp_json_encode(['error' => 'Invalid or expired security token.']);
            exit;
        }

        return $valid;
    }

    // ── Auth session management ───────────────────────────────────────────────

    /**
     * Create a persistent auth session in the DB. Returns the raw token.
     */
    public function create(int $userId, int $ttl = 7200): string
    {
        $raw   = bin2hex(random_bytes(32)); // 64-char hex token
        $hash  = hash('sha256', $raw);
        $expires = gmdate('Y-m-d H:i:s', time() + $ttl);

        DB::insert($this->sessTable, [
            'user_id'    => $userId,
            'token_hash' => $hash,
            'type'       => 'auth',
            'ip'         => sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'] ?? '')),
            'user_agent' => substr(sanitize_text_field(wp_unslash($_SERVER['HTTP_USER_AGENT'] ?? '')), 0, 255),
            'expires_at' => $expires,
        ]);

        // Prune expired sessions periodically (1% chance per request)
        if (random_int(0, 99) === 0) {
            $this->pruneExpired();
        }

        return $raw;
    }

    /**
     * Validate an auth token. Returns user_id on success, 0 on failure.
     */
    public function checkToken(string $token): int
    {
        $hash = hash('sha256', $token);
        $row  = DB::get_row(DB::prepare(
            "SELECT user_id FROM `{$this->sessTable}`
             WHERE token_hash = %s AND type = 'auth' AND expires_at > NOW() LIMIT 1",
            $hash
        ));

        return $row ? (int) $row->user_id : 0;
    }

    /**
     * Invalidate an auth session token.
     */
    public function destroy(string $token): void
    {
        $hash = hash('sha256', $token);
        DB::delete($this->sessTable, ['token_hash' => $hash]);

        // Reset cached user context
        self::$cachedUserId = null;
        self::$cachedUser   = null;
    }

    /**
     * Bootstrap the current user context from the auth cookie.
     * Called by AuthStandaloneDriver::check() and user() via Auth facade.
     * Returns the user ID or 0.
     */
    public static function resolveCurrentUser(self $instance): int
    {
        if (self::$cachedUserId !== null) {
            return self::$cachedUserId;
        }

        $token = isset($_COOKIE['instinctor_auth']) ? sanitize_text_field(wp_unslash($_COOKIE['instinctor_auth'])) : '';
        if (empty($token)) {
            self::$cachedUserId = 0;
            return 0;
        }

        $userId = $instance->checkToken($token);
        self::$cachedUserId = $userId;
        return $userId;
    }

    // ── Internal ──────────────────────────────────────────────────────────────

    private function pruneExpired(): void
    {
        try {
            DB::query("DELETE FROM `{$this->sessTable}` WHERE expires_at < NOW()");
        } catch (\Throwable) {
            // Non-fatal
        }
    }

    private function b64url(string $data): string
    {
        return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
    }

    private function b64urlDecode(string $data): ?string
    {
        $padded = strtr($data, '-_', '+/');
        $padded = str_pad($padded, strlen($padded) + (4 - strlen($padded) % 4) % 4, '=');
        $result = base64_decode($padded, true);
        return $result !== false ? $result : null;
    }
}
