<?php

declare(strict_types=1);

namespace Instinctor\Core;

/**
 * UserService — user lifecycle operations.
 *
 * Handles: invite, accept-invite, password reset, WP user sync.
 * All write operations route through DB abstraction — works both modes.
 *
 * WP→Standalone migration:
 *   syncFromWordPress() imports WP users into instinctor_users with:
 *     - hash_type = 'phpass'  (WP password format)
 *     - wp_user_id = WP user ID (migration reference)
 *   On first standalone login, StandaloneDriver::attempt() silently
 *   upgrades the hash to bcrypt. No forced password resets.
 *
 * @since 3.20.0
 */

if (!defined('ABSPATH')) exit;
class UserService
{
    private const INVITE_TTL = 7 * 86400;   // 7 days
    private const RESET_TTL  = 3600;         // 1 hour

    private static function table(): string
    {
        return DB::prefix() . 'instinctor_users';
    }

    // ── Invite flow ───────────────────────────────────────────────────────────

    /**
     * Create an invite for a new team member.
     * Creates the user row with status='invited', sends invite email.
     *
     * @param  string $email
     * @param  string $role        owner|admin|manager|agent
     * @param  string $invitedByName  Display name of the person sending the invite
     * @return array{ok:bool, error?:string, token?:string}
     */
    public static function invite(string $email, string $role, string $invitedByName = ''): array
    {
        $email = strtolower(trim($email));
        if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
            return ['ok' => false, 'error' => __('Invalid email address.', 'instinctor')];
        }

        $allowedRoles = ['admin', 'manager', 'agent'];
        if (!in_array($role, $allowedRoles, true)) {
            return ['ok' => false, 'error' => __('Invalid role.', 'instinctor')];
        }

        $table = self::table();

        // Check for existing user
        $existing = DB::get_row(DB::prepare(
            "SELECT id, status FROM `{$table}` WHERE email = %s LIMIT 1",
            $email
        ));

        $token   = bin2hex(random_bytes(32));
        $expires = gmdate('Y-m-d H:i:s', time() + self::INVITE_TTL);

        if ($existing) {
            if ($existing->status === 'active') {
                return ['ok' => false, 'error' => __('A user with this email already exists.', 'instinctor')];
            }
            // Re-invite: refresh token
            DB::update($table, [
                'role'           => $role,
                'status'         => 'invited',
                'invite_token'   => $token,
                'invite_expires' => $expires,
            ], ['id' => $existing->id]);
        } else {
            DB::insert($table, [
                'email'          => $email,
                'display_name'   => '',
                'password_hash'  => '',
                'hash_type'      => 'bcrypt',
                'role'           => $role,
                'status'         => 'invited',
                'invite_token'   => $token,
                'invite_expires' => $expires,
                'active'         => 0,
            ]);
        }

        // Send invite email
        $storeName = Config::get('instinctor_store_name', 'Instinctor');
        $inviteUrl = self::siteUrl('/admin/accept-invite?token=' . $token);
        $from      = $invitedByName ?: $storeName;
        $roleLabel = RoleManager::ROLE_LABELS[$role] ?? ucfirst($role);

        Mailer::send($email, "You've been invited to {$storeName}", self::inviteEmailHtml(
            $email, $from, $storeName, $roleLabel, $inviteUrl
        ), ['html' => true]);

        return ['ok' => true, 'token' => $token];
    }

    /**
     * Accept an invite: validate token, set password, activate user.
     *
     * @return array{ok:bool, error?:string, user_id?:int}
     */
    public static function acceptInvite(string $token, string $displayName, string $password): array
    {
        if (strlen($token) !== 64) {
            return ['ok' => false, 'error' => __('Invalid token.', 'instinctor')];
        }

        $table = self::table();
        $row   = DB::get_row(DB::prepare(
            "SELECT id, email, status, invite_expires FROM `{$table}`
             WHERE invite_token = %s LIMIT 1",
            $token
        ));

        if (!$row) {
            return ['ok' => false, 'error' => __('Invite not found or already used.', 'instinctor')];
        }

        if ($row->status !== 'invited') {
            return ['ok' => false, 'error' => __('This invite has already been used.', 'instinctor')];
        }

        if ($row->invite_expires && strtotime($row->invite_expires) < time()) {
            return ['ok' => false, 'error' => __('This invite has expired. Ask to be re-invited.', 'instinctor')];
        }

        $passwordError = self::validatePassword($password);
        if ($passwordError) {
            return ['ok' => false, 'error' => $passwordError];
        }

        $displayName = trim($displayName) ?: explode('@', $row->email)[0];

        DB::update($table, [
            'display_name'   => $displayName,
            'password_hash'  => password_hash($password, PASSWORD_BCRYPT, ['cost' => 12]),
            'hash_type'      => 'bcrypt',
            'status'         => 'active',
            'active'         => 1,
            'invite_token'   => '',
            'invite_expires' => null,
        ], ['id' => $row->id]);

        return ['ok' => true, 'user_id' => (int) $row->id];
    }

    // ── Password reset ────────────────────────────────────────────────────────

    /**
     * Initiate password reset — generate token and send email.
     * Always returns ok=true to prevent email enumeration.
     *
     * WordPress (context='frontend'): uses WP native get_password_reset_key().
     *   Tokens stored in wp_usermeta — finds WP customers in wp_users.
     * WordPress (context='admin'):    uses instinctor_users (team members only).
     * Standalone (any context):       uses instinctor_users for all users.
     */
    public static function requestPasswordReset(string $email, string $context = 'admin'): array
    {
        $email = strtolower(trim($email));
        $isWP  = !defined('INSTINCTOR_ENV') || INSTINCTOR_ENV === 'wordpress';

        // Password reset always resolves against instinctor_users now (Option A,
        // 2026-07-05) — storefront customers are the plugin's own accounts, not
        // WP users, so there is no get_user_by / wp_usermeta path.
        // ── instinctor_users path (admin team + storefront customers) ────────
        $table = self::table();
        $row   = DB::get_row(DB::prepare(
            "SELECT id, display_name FROM `{$table}`
             WHERE email = %s AND status = 'active' AND active = 1 LIMIT 1",
            $email
        ));

        if ($row) {
            $token   = bin2hex(random_bytes(32));
            $expires = gmdate('Y-m-d H:i:s', time() + self::RESET_TTL);

            DB::update($table, [
                'reset_token'   => $token,
                'reset_expires' => $expires,
            ], ['id' => $row->id]);

            $storeName = $isWP ? (get_bloginfo('name') ?: 'Instinctor') : Config::get('instinctor_store_name', 'Instinctor');
            // Admin team members reset via /admin/reset-password (standalone AdminRouter).
            // Storefront customers reset via /reset-password (standalone frontend route).
            $resetPath = ($context === 'frontend') ? '/reset-password' : '/admin/reset-password';
            $resetUrl  = self::siteUrl($resetPath . '?token=' . $token);
            $name      = $row->display_name ?: $email;

            Mailer::send($email, "Reset your {$storeName} password",
                self::resetEmailHtml($name, $storeName, $resetUrl),
                ['html' => true]
            );
        }

        return ['ok' => true];
    }
    /**
     * Complete password reset: validate token, set new password.
     */
    /**
     * Welcome a brand-new membership account: issue a set-password token and
     * email a welcome with a "set your password" button (reliable Mailer path,
     * not the gated template system). Returns true if sent.
     */
    public static function sendMembershipWelcome(string $email): bool
    {
        $email = strtolower(trim($email));
        $table = self::table();
        $row = DB::get_row(DB::prepare(
            "SELECT id, display_name FROM `{$table}` WHERE email = %s AND status = 'active' AND active = 1 LIMIT 1",
            $email
        ));
        if (!$row) return false;

        $token   = bin2hex(random_bytes(32));
        $expires = gmdate('Y-m-d H:i:s', time() + self::RESET_TTL);
        DB::update($table, ['reset_token' => $token, 'reset_expires' => $expires], ['id' => $row->id]);

        $storeName  = Config::get('instinctor_store_name', 'Instinctor');
        $setUrl     = self::siteUrl('/reset-password?token=' . $token);
        $accountUrl = self::siteUrl('/account');
        $name       = $row->display_name ?: $email;

        return Mailer::send(
            $email,
            // translators: %s is the store name.
            sprintf(__('Welcome to %s: your membership is active', 'instinctor'), $storeName),
            self::membershipWelcomeHtml($name, $storeName, $setUrl, $accountUrl),
            ['html' => true]
        );
    }

    private static function membershipWelcomeHtml(string $name, string $storeName, string $setUrl, string $accountUrl): string
    {
        $name = esc_html($name); $store = esc_html($storeName);
        return '<div style="font-family:Arial,Helvetica,sans-serif;max-width:520px;margin:0 auto;color:#1d2327;line-height:1.55">'
            // translators: %s is the store name.
            . '<h2 style="font-size:20px;margin:0 0 12px">' . sprintf(esc_html__('Welcome to %s!', 'instinctor'), $store) . '</h2>'
            // translators: %s is the member name.
            . '<p>' . sprintf(esc_html__('Hi %s, your membership is active. Set your password to log in and manage your membership any time:', 'instinctor'), '<strong>' . $name . '</strong>') . '</p>'
            . '<p style="margin:20px 0"><a href="' . esc_url($setUrl) . '" style="display:inline-block;background:#1d2327;color:#ffffff;text-decoration:none;padding:12px 24px;border-radius:6px;font-weight:600">' . esc_html__('Set your password', 'instinctor') . '</a></p>'
            // translators: %s is the account page link.
            . '<p style="font-size:13px;color:#555">' . sprintf(esc_html__('Then visit %s any time to update your card or cancel.', 'instinctor'), '<a href="' . esc_url($accountUrl) . '">' . esc_html($accountUrl) . '</a>') . '</p>'
            . '</div>';
    }

    public static function resetPassword(string $token, string $newPassword): array
    {
        if (strlen($token) !== 64) {
            return ['ok' => false, 'error' => __('Invalid token.', 'instinctor')];
        }

        $passwordError = self::validatePassword($newPassword);
        if ($passwordError) {
            return ['ok' => false, 'error' => $passwordError];
        }

        // Reset tokens live in instinctor_users now (Option A, 2026-07-05):
        // requestPasswordReset() writes them there for storefront customers and
        // admin team alike, so there is no wp_usermeta / wp_set_password path.
        // ── Token in instinctor_users ───────────────────────────────────────
        $table = self::table();
        $row   = DB::get_row(DB::prepare(
            "SELECT id, reset_expires FROM `{$table}`
             WHERE reset_token = %s AND status = 'active' LIMIT 1",
            $token
        ));

        if (!$row) {
            return ['ok' => false, 'error' => __('Invalid or expired reset link.', 'instinctor')];
        }

        if ($row->reset_expires && strtotime($row->reset_expires) < time()) {
            return ['ok' => false, 'error' => __('This reset link has expired. Please request a new one.', 'instinctor')];
        }

        DB::update($table, [
            'password_hash'  => password_hash($newPassword, PASSWORD_BCRYPT, ['cost' => 12]),
            'hash_type'      => 'bcrypt',
            'reset_token'    => '',
            'reset_expires'  => null,
        ], ['id' => $row->id]);

        return ['ok' => true];
    }

    // ── First-run / Owner setup ───────────────────────────────────────────────

    /**
     * Change the password of an authenticated user. Verifies the current
     * password against the stored hash, then writes a fresh bcrypt-12
     * hash. Routes to wp_set_password() when the user lives in WP's
     * wp_users table (WordPress mode); otherwise updates the install's
     * own instinctor_users row.
     *
     * Returns {ok: bool, error?: string}. Errors are user-safe strings —
     * never leak whether an account exists or which field failed.
     *
     * @since 2.1.1312
     */
    public static function changePassword(int $userId, string $currentPassword, string $newPassword): array
    {
        if ($userId <= 0) {
            return ['ok' => false, 'error' => __('Not signed in.', 'instinctor')];
        }
        if (strlen($newPassword) < 8) {
            return ['ok' => false, 'error' => __('New password must be at least 8 characters.', 'instinctor')];
        }
        if ($currentPassword === $newPassword) {
            return ['ok' => false, 'error' => __('New password must differ from the current one.', 'instinctor')];
        }

        // Passwords always live in instinctor_users now (Option A, 2026-07-05) —
        // no wp_check_password / wp_set_password. Customers and admin team are
        // all the plugin's own accounts; the WP site's users are never touched.
        // ── instinctor_users ─────────────────────────────────────────────
        $table = self::table();
        $row   = DB::get_row(DB::prepare(
            "SELECT id, password_hash, hash_type FROM `{$table}`
             WHERE id = %d AND status = 'active' LIMIT 1",
            $userId
        ));
        if (!$row) {
            return ['ok' => false, 'error' => __('Account not found.', 'instinctor')];
        }

        // Verify current password — phpass for WP-migrated, bcrypt otherwise.
        $verified = false;
        $hashType = $row->hash_type ?? 'bcrypt';
        if ($hashType === 'phpass' && class_exists('\\Instinctor\\Core\\Auth\\PhpassCompat')) {
            $verified = \Instinctor\Core\Auth\PhpassCompat::check($currentPassword, $row->password_hash);
        } else {
            $verified = password_verify($currentPassword, $row->password_hash);
        }
        if (!$verified) {
            return ['ok' => false, 'error' => __('Current password is incorrect.', 'instinctor')];
        }

        $newHash = password_hash($newPassword, PASSWORD_BCRYPT, ['cost' => 12]);
        DB::update($table, [
            'password_hash' => $newHash,
            'hash_type'     => 'bcrypt',
            'updated_at'    => gmdate('Y-m-d H:i:s'),
        ], ['id' => (int) $row->id]);

        return ['ok' => true];
    }

    /**
     * Create the owner account during first-run setup.
     * Fails if any admin user already exists.
     *
     * @return array{ok:bool, error?:string, user_id?:int}
     */
    public static function createOwner(string $email, string $displayName, string $password): array
    {
        $email = strtolower(trim($email));

        if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
            return ['ok' => false, 'error' => __('Invalid email address.', 'instinctor')];
        }

        $passwordError = self::validatePassword($password);
        if ($passwordError) {
            return ['ok' => false, 'error' => $passwordError];
        }

        $table = self::table();

        // Double-check no owner exists (race condition guard)
        $existing = DB::get_var(
            "SELECT COUNT(*) FROM `{$table}` WHERE role IN ('owner','admin')"
        );
        if ((int) $existing > 0) {
            return ['ok' => false, 'error' => 'Setup already completed.'];
        }

        DB::insert($table, [
            'email'         => $email,
            'display_name'  => trim($displayName) ?: explode('@', $email)[0],
            'password_hash' => password_hash($password, PASSWORD_BCRYPT, ['cost' => 12]),
            'hash_type'     => 'bcrypt',
            'role'          => 'owner',
            'status'        => 'active',
            'active'        => 1,
            // `meta` is longtext NOT NULL with no default; omitting it fails the
            // insert under MySQL strict mode (Field 'meta' doesn't have a default
            // value), which broke first-owner setup on fresh standalone installs.
            'meta'          => '',
        ]);

        $id = DB::insert_id();
        if (!$id) {
            return ['ok' => false, 'error' => 'Database error creating user.'];
        }

        return ['ok' => true, 'user_id' => $id];
    }

    // ── WP → Standalone migration ─────────────────────────────────────────────

    /**
     * Import WP users into instinctor_users.
     * Called once when switching from WP to standalone mode.
     *
     * - Preserves phpass hashes (upgraded silently on first login)
     * - Maps WP roles to Instinctor roles
     * - Skips users already in instinctor_users (matched by email)
     * - First administrator becomes owner
     *
     * Returns count of users imported.
     */
    public static function syncFromWordPress(): int
    {
        if (!function_exists('get_users')) return 0;

        $table      = self::table();
        $imported   = 0;
        $ownerSet   = (bool) DB::get_var(
            "SELECT COUNT(*) FROM `{$table}` WHERE role = 'owner'"
        );

        $wpUsers = get_users(['number' => -1]);

        foreach ($wpUsers as $wpUser) {
            $email = strtolower(trim($wpUser->user_email));
            if (!$email) continue;

            // Skip if already in our table
            $exists = DB::get_var(DB::prepare(
                "SELECT id, user_id FROM `{$table}` WHERE email = %s LIMIT 1", $email
            ));
            if ($exists) continue;

            // Map WP role to Instinctor role
            $wpRoles = (array) $wpUser->roles;
            $role = self::mapWpRole($wpRoles, $ownerSet);

            // Skip subscribers/non-Instinctor users
            if (!$role) continue;

            if ($role === 'owner') $ownerSet = true;

            // Fetch raw WP password hash from wp_users
            global $wpdb;
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.SlowDBQuery -- direct query against the plugin's own custom table; the WP options/post API does not model this data and the read is request-scoped (object caching would add complexity without benefit)
            $rawHash = $wpdb ? $wpdb->get_var($wpdb->prepare(
                "SELECT user_pass FROM {$wpdb->users} WHERE ID = %d", $wpUser->ID
            )) : '';

            DB::insert($table, [
                'email'         => $email,
                'display_name'  => $wpUser->display_name ?: $email,
                'password_hash' => $rawHash ?: '',
                'hash_type'     => 'phpass',
                'role'          => $role,
                'status'        => 'active',
                'active'        => 1,
                'wp_user_id'    => (int) $wpUser->ID,
            ]);

            $imported++;
        }

        return $imported;
    }

    /**
     * Map WP role array to an Instinctor role string.
     * Returns null for roles that have no Instinctor equivalent.
     */
    private static function mapWpRole(array $wpRoles, bool $ownerAlreadySet): ?string
    {
        if (in_array('administrator', $wpRoles, true)) {
            return $ownerAlreadySet ? 'admin' : 'owner';
        }
        if (in_array('instinctor_store_manager', $wpRoles, true) || in_array('shop_manager', $wpRoles, true) || in_array('editor', $wpRoles, true)) {
            return 'manager';
        }
        if (in_array('instinctor_chat_agent', $wpRoles, true) || in_array('author', $wpRoles, true) || in_array('contributor', $wpRoles, true)) {
            return 'agent';
        }
        // subscribers and customers: skip (they're storefront users, handled separately)
        return null;
    }

    // ── Helpers ───────────────────────────────────────────────────────────────

    private static function validatePassword(string $password): ?string
    {
        if (strlen($password) < 8) {
            return 'Password must be at least 8 characters.';
        }
        return null;
    }

    private static function siteUrl(string $path = ''): string
    {
        if (defined('APP_URL')) return rtrim(APP_URL, '/') . $path;
        if (function_exists('site_url')) return rtrim(site_url(), '/') . $path;
        return $path;
    }


    // ── Customer registration / login (storefront) ────────────────────────────

    /**
     * Register a storefront customer in both WordPress and standalone mode.
     *
     * On WordPress:  creates a WP user (role: subscriber) + instinctor_customers row.
     * On Standalone: creates an instinctor_users row (role: customer) + sets auth cookie.
     *
     * @return array{ok:bool, user_id?:int, error?:string}
     */
    public static function registerCustomer(
        string $email,
        string $password,
        string $firstName = '',
        string $lastName  = ''
    ): array {
        $email    = strtolower(trim($email));
        $firstName = sanitize_text_field($firstName);
        $lastName  = sanitize_text_field($lastName);

        if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
            return ['ok' => false, 'error' => 'Please enter a valid email address.'];
        }
        if (strlen($password) < 8) {
            return ['ok' => false, 'error' => 'Password must be at least 8 characters.'];
        }

        $displayName = trim($firstName . ' ' . $lastName) ?: $email;

        // Customer accounts live in the plugin's OWN instinctor_users table +
        // instinctor_auth session on BOTH WordPress and standalone (Option A,
        // 2026-07-05, WP.org compliance). The plugin never creates or logs in a
        // WordPress user — the WP site's own users stay entirely separate. This
        // removes the wp_create_user / wp_update_user / wp_set_auth_cookie that
        // the WP.org review flagged; the site admin's WP account is untouched.
        $usersTable = DB::prefix() . 'instinctor_users';

        $existing = DB::get_row(DB::prepare(
            "SELECT id FROM `{$usersTable}` WHERE email = %s LIMIT 1",
            $email
        ));
        if ($existing) {
            return ['ok' => false, 'error' => 'An account with this email already exists.'];
        }

        $hash   = password_hash($password, PASSWORD_BCRYPT, ['cost' => 12]);
        $result = DB::insert($usersTable, [
            'email'          => $email,
            'password_hash'  => $hash,
            'hash_type'      => 'bcrypt',
            'display_name'   => $displayName,
            'role'           => 'customer',
            'active'         => 1,
            'email_verified' => 0,
            'status'         => 'active',
            'meta'           => '{}',
        ]);

        if (!$result) {
            return ['ok' => false, 'error' => 'Failed to create account. Please try again.'];
        }

        $userId = (int) DB::insert_id();

        // Set auth cookie via StandaloneDriver
        $sessionDriver = new \Instinctor\Core\Session\StandaloneDriver();
        $token         = $sessionDriver->create($userId, 30 * 86400);
        setcookie('instinctor_auth', $token, [
            'expires'  => time() + 30 * 86400,
            'path'     => '/',
            'secure'   => isset($_SERVER['HTTPS']) && ($_SERVER['HTTPS'] ?? '') !== 'off', // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
            'httponly' => true,
            'samesite' => 'Lax',
        ]);

        // Ensure instinctor_customers row
        self::upsertCustomer($userId, $email, $firstName, $lastName);

        return ['ok' => true, 'user_id' => $userId];
    }

    /**
     * Log in a storefront customer (guest-facing, not admin).
     *
     * On WordPress:  calls wp_signon().
     * On Standalone: calls Auth::attempt() + sets instinctor_auth cookie.
     *
     * @return array{ok:bool, error?:string}
     */
    public static function loginCustomer(
        string $username,
        string $password,
        bool   $remember = false
    ): array {
        // Customer login always verifies against instinctor_users and issues an
        // instinctor_auth session, on WordPress and standalone alike (Option A,
        // 2026-07-05). No wp_signon / WP auth cookie — WP users stay separate.
        $usersTable = DB::prefix() . 'instinctor_users';
        $row = DB::get_row(DB::prepare(
            "SELECT id, email, display_name, role, password_hash, hash_type, status, active
             FROM `{$usersTable}` WHERE email = %s LIMIT 1",
            strtolower(trim($username))
        ));

        if (!$row) {
            return ['ok' => false, 'error' => 'Invalid email or password.'];
        }

        $status = $row->status ?? 'active';
        if ($status === 'suspended' || !(int)($row->active ?? 1)) {
            return ['ok' => false, 'error' => 'This account is suspended.'];
        }

        // Verify password — phpass for WP-migrated, bcrypt otherwise
        $verified  = false;
        $hashType  = $row->hash_type ?? 'bcrypt';
        if ($hashType === 'phpass') {
            $verified = class_exists('\\Instinctor\\Core\\Auth\\PhpassCompat')
                && \Instinctor\Core\Auth\PhpassCompat::check($password, $row->password_hash);
            if ($verified) {
                $newHash = password_hash($password, PASSWORD_BCRYPT, ['cost' => 12]);
                DB::update($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($usersTable, ['password_hash' => $newHash], ['id' => $row->id]);
            }
        }

        if (!$verified) {
            return ['ok' => false, 'error' => 'Invalid email or password.'];
        }

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

        $ttl           = $remember ? 30 * 86400 : 7 * 86400;
        $sessionDriver = new \Instinctor\Core\Session\StandaloneDriver();
        $token         = $sessionDriver->create((int) $row->id, $ttl);
        setcookie('instinctor_auth', $token, [
            'expires'  => $remember ? time() + $ttl : 0,
            'path'     => '/',
            'secure'   => isset($_SERVER['HTTPS']) && ($_SERVER['HTTPS'] ?? '') !== 'off', // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
            'httponly' => true,
            'samesite' => 'Lax',
        ]);

        return ['ok' => true, 'user_id' => (int) $row->id];
    }

    /**
     * Create or update an instinctor_customers row.
     * Called on both WP and standalone registration / first checkout.
     */
    public static function upsertCustomer(
        int    $userId,
        string $email,
        string $firstName = '',
        string $lastName  = ''
    ): void {
        $table = DB::prefix() . 'instinctor_customers';
        // Scoped: email is unique per owner (0058). Storefront accounts live in
        // the vendor scope ("owner_id IS NULL" here); without the scope this
        // could link the new user to some tenant's client row that happens to
        // share the address.
        $existing = DB::get_row(DB::prepare(
            "SELECT id, user_id FROM `{$table}` WHERE email = %s
               AND " . Tenant::scopeSql('owner_id') . " LIMIT 1",
            $email
        ));

        if ($existing) {
            // Only link user_id if not already linked — never overwrite an existing link
            if (empty($existing->user_id)) {
                DB::update($table, ['user_id' => $userId], ['id' => $existing->id]);
            }
        } else {
            DB::insert($table, [
                'user_id'    => $userId,
                'email'      => $email,
                'first_name' => $firstName,
                'last_name'  => $lastName,
                'owner_id'   => Tenant::currentOwnerId(),
            ]);
        }
    }

    /**
     * URL to the frontend password-reset page.
     * On WordPress: wp_lostpassword_url().
     * On Standalone: /forgot-password (frontend route, separate from /admin/forgot-password).
     */
    public static function lostPasswordUrl(): string
    {
        $isWP = !defined('INSTINCTOR_ENV') || INSTINCTOR_ENV === 'wordpress';
        if ($isWP && function_exists('wp_lostpassword_url')) {
            return wp_lostpassword_url();
        }
        return self::siteUrl('/forgot-password');
    }

    // ── Email templates ───────────────────────────────────────────────────────

    private static function inviteEmailHtml(string $email, string $from, string $store, string $role, string $url): string
    {
        return self::emailShell($store, "You've been invited", "
            <p style=\"margin:0 0 16px\">Hi,</p>
            <p style=\"margin:0 0 16px\"><strong>{$from}</strong> has invited you to join <strong>{$store}</strong> as a <strong>{$role}</strong>.</p>
            <p style=\"margin:0 0 24px\">Click the button below to set your password and access your account.</p>
            <p style=\"margin:0 0 24px;text-align:center\">
                <a href=\"{$url}\" style=\"display:inline-block;padding:10px 24px;background:#1d2327;color:#fff;text-decoration:none;border-radius:3px;font-weight:500\">Accept Invitation</a>
            </p>
            <p style=\"margin:0;font-size:12px;color:#888\">This invitation expires in 7 days. If you didn't expect this email, you can ignore it.</p>
        ");
    }

    private static function resetEmailHtml(string $name, string $store, string $url): string
    {
        return self::emailShell($store, 'Reset your password', "
            <p style=\"margin:0 0 16px\">Hi {$name},</p>
            <p style=\"margin:0 0 16px\">We received a request to reset your password for <strong>{$store}</strong>.</p>
            <p style=\"margin:0 0 24px\">Click the button below to choose a new password.</p>
            <p style=\"margin:0 0 24px;text-align:center\">
                <a href=\"{$url}\" style=\"display:inline-block;padding:10px 24px;background:#1d2327;color:#fff;text-decoration:none;border-radius:3px;font-weight:500\">Reset Password</a>
            </p>
            <p style=\"margin:0;font-size:12px;color:#888\">This link expires in 1 hour. If you didn't request this, you can ignore it.</p>
        ");
    }

    private static function emailShell(string $store, string $subject, string $body): string
    {
        return "<!DOCTYPE html><html><head><meta charset=\"UTF-8\"></head><body style=\"font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;font-size:14px;color:#1d2327;background:#f5f5f5;margin:0;padding:40px 20px\">
            <div style=\"max-width:480px;margin:0 auto;background:#fff;border:1px solid #e0e0e0;border-radius:4px;padding:32px\">
                <div style=\"font-size:16px;font-weight:600;margin-bottom:24px;padding-bottom:16px;border-bottom:1px solid #f0f0f0\">{$store}</div>
                {$body}
            </div>
        </body></html>";
    }
}
