<?php

declare(strict_types=1);

namespace Instinctor\Core\Auth;


if (!defined('ABSPATH')) exit;
use Instinctor\Core\DB;
use Instinctor\Core\Session\StandaloneDriver as SessionDriver;

/**
 * Auth\StandaloneDriver — authenticates against instinctor_users table.
 *
 * Role hierarchy (highest → lowest):
 *   owner   — single seat, all caps including destructive actions
 *   admin   — full access, no destructive actions
 *   manager — chat + store operations, no Impulse/SEO/Settings
 *   agent   — chat only
 *   customer — storefront only (cannot log into admin)
 *
 * phpass migration: if hash_type = 'phpass', verifies with PhpassCompat then
 * immediately re-hashes to bcrypt. Transparent — no password reset required
 * after WP→Standalone migration.
 *
 * @since 3.18.50
 * @updated 3.20.0 — full role hierarchy, phpass compat, status checks, last_active
 */
class StandaloneDriver implements DriverInterface
{
    private string $usersTable;
    private ?object $currentUser = null;
    private bool    $resolved    = false;

    /** Roles allowed to access the admin panel */
    public const ADMIN_ROLES = ['owner', 'admin', 'site_owner', 'manager', 'agent', 'administrator'];

    /** Capability map by role */
    private const ROLE_CAPS = [
        'owner' => [
            'manage_options', 'instinctor_access', 'instinctor_owner',
            'instinctor_chat', 'instinctor_chat_settings', 'instinctor_store',
            'instinctor_impulse', 'instinctor_seo', 'instinctor_settings',
            'edit_posts', 'publish_posts', 'delete_posts', 'read', 'upload_files',
        ],
        'admin' => [
            'manage_options', 'instinctor_access',
            'instinctor_chat', 'instinctor_chat_settings', 'instinctor_store',
            'instinctor_impulse', 'instinctor_seo', 'instinctor_settings',
            'edit_posts', 'publish_posts', 'delete_posts', 'read', 'upload_files',
        ],
        // Tenant site owner (multi-tenant): manages ONLY their own site. Same
        // reach as admin MINUS manage_options (no vendor-global settings); the
        // data layer scopes everything to their owner_id via the Tenant resolver.
        'site_owner' => [
            'instinctor_access',
            'instinctor_chat', 'instinctor_chat_settings', 'instinctor_store',
            'instinctor_impulse', 'instinctor_seo', 'instinctor_settings',
            'edit_posts', 'publish_posts', 'delete_posts', 'read', 'upload_files',
        ],
        'manager' => [
            'instinctor_access', 'instinctor_chat', 'instinctor_store', 'read',
        ],
        'agent' => [
            'instinctor_access', 'instinctor_chat', 'read',
        ],
        'customer' => ['read'],
        // WP aliases for imported users
        'administrator' => [
            'manage_options', 'instinctor_access',
            'instinctor_chat', 'instinctor_chat_settings', 'instinctor_store',
            'instinctor_impulse', 'instinctor_seo', 'instinctor_settings',
            'edit_posts', 'publish_posts', 'delete_posts', 'read', 'upload_files',
        ],
        'editor' => [
            'instinctor_access', 'instinctor_chat', 'instinctor_store',
            'edit_posts', 'publish_posts', 'delete_posts', 'read',
        ],
    ];

    public function __construct()
    {
        // Option A (2026-07-05): this driver now resolves 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 read from
        // wp_instinctor_users on WP installs whose real prefix differs.
        $prefix           = \Instinctor\Core\DB::prefix();
        $this->usersTable = $prefix . 'instinctor_users';
    }

    // ── DriverInterface ───────────────────────────────────────────────────────

    public function user(): ?object
    {
        $this->resolve();
        return $this->currentUser;
    }

    public function userId(): int
    {
        $user = $this->user();
        return $user ? (int) ($user->id ?? 0) : 0;
    }

    public function check(): bool
    {
        return $this->userId() > 0;
    }

    public function can(string $capability, mixed ...$args): bool
    {
        $user = $this->user();
        if (!$user) return false;
        $caps = self::ROLE_CAPS[$user->role ?? 'customer'] ?? self::ROLE_CAPS['customer'];
        return in_array($capability, $caps, true);
    }

    public function isAdmin(): bool
    {
        return $this->can('manage_options');
    }

    public function login(string $email, string $password, bool $remember = false): bool
    {
        $user = $this->attempt($email, $password);
        if (!$user) return false;

        $sessionDriver = new SessionDriver();
        $ttl   = $remember ? 30 * 86400 : 7 * 86400;
        $token = $sessionDriver->create($user['id'], $ttl);

        setcookie('instinctor_auth', $token, [
            'expires'  => $remember ? time() + $ttl : 0,
            'path'     => '/',
            'secure'   => isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off',
            'httponly' => true,
            'samesite' => 'Lax',
        ]);

        $this->currentUser = (object) $user;
        $this->resolved    = true;
        return true;
    }

    /**
     * Log in a user by ID (no password check). Used by SSO, where the token has
     * already been verified upstream. Mirrors the session/cookie setup in login().
     *
     * @since 2.0.778
     */
    public function loginAs(int $userId, bool $remember = false): bool
    {
        if ($userId <= 0) return false;
        // Verify user exists
        $t = \Instinctor\Core\DB::prefix() . 'instinctor_users';
        $user = \Instinctor\Core\DB::get_row(\Instinctor\Core\DB::prepare(
            "SELECT id, email, display_name, role FROM {$t} WHERE id = %d LIMIT 1",
            $userId
        ));
        if (!$user) return false;

        $sessionDriver = new SessionDriver();
        $ttl   = $remember ? 30 * 86400 : 7 * 86400;
        $token = $sessionDriver->create((int) $user->id, $ttl);

        setcookie('instinctor_auth', $token, [
            'expires'  => $remember ? time() + $ttl : 0,
            'path'     => '/',
            'secure'   => isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off',
            'httponly' => true,
            'samesite' => 'Lax',
        ]);

        $this->currentUser = $user;
        $this->resolved    = true;
        return true;
    }

    public function logout(): void
    {
        $token = isset($_COOKIE['instinctor_auth']) ? sanitize_text_field(wp_unslash($_COOKIE['instinctor_auth'])) : '';
        if ($token) {
            (new SessionDriver())->destroy($token);
        }
        setcookie('instinctor_auth', '', [
            'expires'  => time() - 3600,
            'path'     => '/',
            'secure'   => isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off',
            'httponly' => true,
            'samesite' => 'Lax',
        ]);
        $this->currentUser = null;
        $this->resolved    = false;
    }

    // ── Extended API ─────────────────────────────────────────────────────────

    /**
     * Verify credentials and return user data on success.
     * Handles bcrypt and phpass (WP-migrated) hashes transparently.
     *
     * @return array{id:int,email:string,display_name:string,role:string}|null
     */
    public function attempt(string $email, string $password): ?array
    {
        $row = DB::get_row(DB::prepare(
            "SELECT id, email, password_hash, hash_type, display_name, role, status, active
             FROM `{$this->usersTable}` WHERE email = %s LIMIT 1",
            strtolower(trim($email))
        ));

        if (!$row) return null;

        // Status gate — invited users must accept before they can log in
        $status = $row->status ?? 'active';
        if ($status === 'invited' || $status === 'suspended') return null;
        if (!(int) ($row->active ?? 1)) return null;

        // Customers never access admin
        $role = $row->role ?? 'customer';
        if ($role === 'customer') return null;

        // Verify password — phpass for WP-migrated users, bcrypt otherwise
        $hashType = $row->hash_type ?? 'bcrypt';
        $verified = false;

        if ($hashType === 'phpass') {
            $verified = PhpassCompat::check($password, $row->password_hash);
            if ($verified) {
                // Silently upgrade to bcrypt — no password reset needed
                $newHash = password_hash($password, PASSWORD_BCRYPT, ['cost' => 12]);
                DB::update($this->usersTable,
                    ['password_hash' => $newHash, 'hash_type' => 'bcrypt'],
                    ['id' => $row->id]
                );
            }
        } else {
            $verified = password_verify($password, $row->password_hash);
            if ($verified && password_needs_rehash($row->password_hash, PASSWORD_BCRYPT, ['cost' => 12])) {
                $newHash = password_hash($password, PASSWORD_BCRYPT, ['cost' => 12]);
                DB::update($this->usersTable, ['password_hash' => $newHash], ['id' => $row->id]);
            }
        }

        if (!$verified) return null;

        // Stamp last_active
        DB::update($this->usersTable, ['last_active' => gmdate('Y-m-d H:i:s')], ['id' => $row->id]);

        return [
            'id'           => (int) $row->id,
            'email'        => $row->email,
            'display_name' => $row->display_name,
            'role'         => $role,
        ];
    }

    /**
     * Validate auth cookie token and bootstrap user context.
     */
    public function checkToken(string $token): bool
    {
        $userId = (new SessionDriver())->checkToken($token);
        if (!$userId) return false;

        $row = DB::get_row(DB::prepare(
            "SELECT id, email, display_name, role, status, active
             FROM `{$this->usersTable}` WHERE id = %d LIMIT 1",
            $userId
        ));

        if (!$row) return false;

        $status = $row->status ?? 'active';
        if ($status === 'suspended' || !(int) ($row->active ?? 1)) return false;

        $this->currentUser = $row;
        $this->resolved    = true;
        return true;
    }

    /**
     * Whether any admin-role user exists. False = first-run setup required.
     */
    public function hasAnyAdminUser(): bool
    {
        $roles = implode("','", array_map('addslashes', self::ADMIN_ROLES));
        return (int) DB::get_var(
            "SELECT COUNT(*) FROM `{$this->usersTable}`
             WHERE role IN ('{$roles}') AND status IN ('active','invited')"
        ) > 0;
    }

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

    private function resolve(): void
    {
        if ($this->resolved) return;
        $this->resolved = true;
        // Check cookie first
        $token = isset($_COOKIE['instinctor_auth']) ? sanitize_text_field(wp_unslash($_COOKIE['instinctor_auth'])) : '';
        // Fall back to X-WP-Nonce header (used by REST API calls from Impulse admin)
        // OR the _wpnonce query-param (used by jQuery $.get calls that don't set
        // custom headers). Without the query-param fallback, GET requests built
        // with $.get() returned 403 because the HMAC token landed in the URL only,
        // and resolve() couldn't see it.
        if (empty($token)) {
            // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce is a hash, sanitization would corrupt it; unslash not needed for server headers
            $nonce = sanitize_text_field( $_SERVER['HTTP_X_WP_NONCE'] ?? '' );
            if (empty($nonce)) {
                // phpcs:ignore WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- this IS the nonce verification; sanitize_text_field handles it
                $nonce = sanitize_text_field( $_GET['_wpnonce'] ?? '' );
            }
            if (!empty($nonce)) {
                // A wp_rest CSRF nonce confirms CSRF protection but does NOT identify a
                // user. Only a real session token (or the auth cookie checked above)
                // authenticates. This previously resolved to "the most recent active
                // session", so ANY guest request carrying the shared wp_rest nonce was
                // treated as the most-recently-logged-in user (a privilege/attribution
                // bug). Treat the value as a session token instead: a plain CSRF nonce
                // is not a valid token, so checkToken() below leaves the request a guest.
                $token = $nonce;
            }
        }
        if (!empty($token)) $this->checkToken($token);
    }
}
