<?php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Models\SocialAccount;
use App\Models\User;
use App\Support\MultipartFormDataParser;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\UploadedFile;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\Password as PasswordRule;
use Laravel\Socialite\Facades\Socialite;

/**
 * Mobile / public REST auth.
 *
 * Issues Sanctum personal access tokens for use by the MailTrixy mobile app
 * and any external API consumer. All endpoints return JSON; on success they
 * include a `token` (plain-text Bearer) and `user` payload.
 *
 * Routes (registered in routes/api.php under prefix `v1/auth`):
 *   POST   /v1/auth/register
 *   POST   /v1/auth/login
 *   POST   /v1/auth/logout                (auth:sanctum)
 *   POST   /v1/auth/forgot-password
 *   POST   /v1/auth/reset-password
 *   POST   /v1/auth/social/{provider}
 *   GET    /v1/auth/me                    (auth:sanctum)
 *
 * Throttling — login + forgot-password are rate-limited per IP+email to
 * prevent credential-stuffing and reset-flood. The /api `throttle:api`
 * middleware (60/min) gives a baseline; sensitive endpoints stack a
 * stricter custom limiter on top.
 */
class AuthController extends Controller
{
    private const TOKEN_NAME = 'mobile';

    /**
     * Allowed providers for social-login token exchange.
     * Limited to providers configured in config/services.php + SocialLoginController.
     */
    private const SOCIAL_PROVIDERS = ['google', 'microsoft', 'github', 'facebook'];

    public function register(Request $request): JsonResponse
    {
        // Mobile signup forms typically have ONE password field — no separate
        // confirmation. Web flow can still pass `password_confirmation` and
        // we'll honour it via the `confirmed` rule when present.
        $rules = [
            'name'     => ['required', 'string', 'min:2', 'max:100'],
            'email'    => ['required', 'string', 'email', 'max:255', Rule::unique(User::class)],
            'password' => ['required', 'string', PasswordRule::min(8)],
            'terms'    => ['sometimes', 'accepted'],
            'referral_code' => ['nullable', 'string', 'max:32'],
            'device_name'   => ['nullable', 'string', 'max:80'],
        ];

        // Apply the stricter password rule only when the client sends a
        // confirmation field (i.e. the web register form).
        if ($request->has('password_confirmation')) {
            $rules['password'] = ['required', 'string', 'confirmed', PasswordRule::min(10)->mixedCase()->numbers()->symbols()];
        }

        $data = Validator::make($request->all(), $rules)->validate();

        $referredBy = $data['referral_code'] ?? null;
        if ($referredBy) {
            $referrerExists = User::where('referral_code', strtoupper(trim($referredBy)))
                ->where('status', 'active')
                ->exists();
            if (!$referrerExists) {
                $referredBy = null;
            }
        }

        $user = User::create([
            'name'           => $data['name'],
            'email'          => strtolower($data['email']),
            'password'       => $data['password'],
            'referral_code'  => strtoupper(Str::random(16)),
            'referred_by'    => $referredBy,
        ]);

        try {
            $user->notify(new \App\Notifications\WelcomeNotification());
        } catch (\Throwable $e) {
            \Illuminate\Support\Facades\Log::warning('Welcome email skipped: ' . $e->getMessage());
        }

        $deviceName = $data['device_name'] ?? self::TOKEN_NAME;
        $token = $user->createToken($deviceName, ['*'])->plainTextToken;

        return response()->json([
            'message' => 'Registration successful.',
            'token'   => $token,
            'user'    => $this->transformUser($user),
        ], 201);
    }

    public function login(Request $request): JsonResponse
    {
        $data = Validator::make($request->all(), [
            'email'    => ['required', 'string', 'email'],
            'password' => ['required', 'string'],
            'device_name' => ['nullable', 'string', 'max:80'],
        ])->validate();

        $email = strtolower($data['email']);
        $key = 'mobile-login:' . sha1($email . '|' . $request->ip());

        if (RateLimiter::tooManyAttempts($key, 5)) {
            $seconds = RateLimiter::availableIn($key);
            return response()->json([
                'message' => "Too many login attempts. Try again in {$seconds} seconds.",
            ], 429);
        }

        $user = User::whereRaw('LOWER(email) = ?', [$email])->first();

        if (!$user || !$user->password || !Hash::check($data['password'], $user->password)) {
            RateLimiter::hit($key, 60);
            return response()->json([
                'message' => 'Invalid email or password.',
            ], 401);
        }

        if ($user->status !== 'active') {
            return response()->json([
                'message' => 'Your account has been suspended or deactivated.',
            ], 403);
        }

        // 2FA gate — if user has 2FA enabled, require a code (PIN or TOTP)
        if ($user->hasTwoFactorEnabled()) {
            $code = (string) $request->input('two_factor_code', '');
            $method = $user->two_factor_method ?: 'totp';

            if ($code === '') {
                return response()->json([
                    'message'             => $method === 'pin'
                        ? 'Two-factor PIN required.'
                        : 'Two-factor code required.',
                    'two_factor_required' => true,
                    'two_factor_method'   => $method,   // 'pin' | 'totp'
                ], 422);
            }

            // Verify code against the user's chosen method.
            $valid = false;
            if ($method === 'pin') {
                $valid = Hash::check($code, (string) $user->two_factor_secret);
            } else {
                $g = app(\PragmaRX\Google2FA\Google2FA::class);
                $valid = $g->verifyKey((string) $user->two_factor_secret, $code);
            }

            // Fall back to one-time recovery codes (works for either method).
            if (!$valid) {
                $codes = json_decode((string) $user->two_factor_recovery_codes, true) ?: [];
                if (in_array($code, $codes, true)) {
                    $codes = array_values(array_diff($codes, [$code]));
                    $user->forceFill(['two_factor_recovery_codes' => json_encode($codes)])->save();
                    $valid = true;
                }
            }

            if (!$valid) {
                RateLimiter::hit($key, 60);
                return response()->json([
                    'message' => $method === 'pin' ? 'Invalid PIN.' : 'Invalid two-factor code.',
                ], 422);
            }
        }

        RateLimiter::clear($key);

        $deviceName = $data['device_name'] ?? self::TOKEN_NAME;
        $token = $user->createToken($deviceName, ['*'])->plainTextToken;

        return response()->json([
            'message' => 'Login successful.',
            'token'   => $token,
            'user'    => $this->transformUser($user),
        ]);
    }

    public function logout(Request $request): JsonResponse
    {
        $token = $request->user()?->currentAccessToken();

        if ($token) {
            $token->delete();
        }

        return response()->json(['message' => 'Logged out.']);
    }

    public function forgotPassword(Request $request): JsonResponse
    {
        $data = Validator::make($request->all(), [
            'email' => ['required', 'string', 'email'],
        ])->validate();

        $email = strtolower($data['email']);
        $key = 'mobile-forgot:' . sha1($email . '|' . $request->ip());

        if (RateLimiter::tooManyAttempts($key, 3)) {
            $seconds = RateLimiter::availableIn($key);
            return response()->json([
                'message' => "Too many requests. Try again in {$seconds} seconds.",
            ], 429);
        }
        RateLimiter::hit($key, 300);

        // Always return the same response whether or not the email exists
        // (prevents account enumeration)
        try {
            Password::sendResetLink(['email' => $email]);
        } catch (\Throwable $e) {
            \Illuminate\Support\Facades\Log::warning('Password reset email failed: ' . $e->getMessage());
        }

        return response()->json([
            'message' => 'If an account with that email exists, a password reset link has been sent.',
        ]);
    }

    public function resetPassword(Request $request): JsonResponse
    {
        $data = Validator::make($request->all(), [
            'token'    => ['required', 'string'],
            'email'    => ['required', 'string', 'email'],
            'password' => ['required', 'string', 'confirmed', PasswordRule::min(10)->mixedCase()->numbers()->symbols()],
        ])->validate();

        $status = Password::reset(
            [
                'email'    => strtolower($data['email']),
                'password' => $data['password'],
                'password_confirmation' => $data['password'],
                'token'    => $data['token'],
            ],
            function (User $user, string $password) {
                $user->forceFill([
                    'password' => $password,
                    'remember_token' => Str::random(60),
                ])->save();

                // Revoke all existing tokens — force re-login on every device
                $user->tokens()->delete();
            }
        );

        if ($status !== Password::PASSWORD_RESET) {
            return response()->json([
                'message' => __($status),
            ], 422);
        }

        return response()->json([
            'message' => 'Password reset successful. Please sign in with your new password.',
        ]);
    }

    /**
     * Social login from a mobile app.
     *
     * The mobile app uses the platform's native SDK (Google Sign-In, MSAL,
     * GitHub OAuth) to obtain an access token, then POSTs that token here.
     * We exchange it for a User and issue a Sanctum token.
     */
    public function socialLogin(Request $request, string $provider): JsonResponse
    {
        if (!in_array($provider, self::SOCIAL_PROVIDERS, true)) {
            return response()->json([
                'message' => 'Unsupported provider.',
            ], 422);
        }

        $data = Validator::make($request->all(), [
            'access_token' => ['required', 'string'],
            'device_name'  => ['nullable', 'string', 'max:80'],
        ])->validate();

        // Strip ALL whitespace from the token before any further processing.
        // Tokens pasted from a wrapped terminal output or copy-paste flow
        // sometimes carry literal newlines/spaces inside the value, which
        // gets URL-encoded to %0A%20 and silently rejected by Google.
        // Doing this once up front means every downstream path sees a
        // clean token regardless of what the caller pasted.
        $token = preg_replace('/\s+/', '', $data['access_token']) ?? '';

        // Google has effectively three token shapes in 2026:
        //   1. Opaque access_token starting with "ya29." — short string,
        //      verified by Socialite hitting /userinfo with it as Bearer.
        //   2. JWT id_token (iss=accounts.google.com) — verified by
        //      Google's /tokeninfo?id_token= endpoint.
        //   3. JWT-shaped access_token (newer GIS / Workspace flows) —
        //      verified by Google's /tokeninfo?access_token= endpoint
        //      OR sometimes accepted by /userinfo too.
        //
        // Detecting shape (1) is easy (no dots). Distinguishing 2 from 3
        // requires looking at the JWT payload: id_tokens have iss=
        // accounts.google.com AND an email claim, access_tokens don't.
        // To avoid getting it wrong, we try each path in turn and only
        // return the unified 401 message when ALL of them fail. Errors
        // from individual attempts go to the log so we know which one
        // ultimately succeeded (or why everything failed).
        $attempts = [];
        $socialUser = null;
        $lastError = null;

        if ($provider === 'google' && $this->looksLikeJwt($token)) {
            try {
                $socialUser = $this->verifyGoogleIdToken($token);
                $attempts[] = 'google_id_token:ok';
            } catch (\Throwable $e) {
                $attempts[] = 'google_id_token:' . substr($e->getMessage(), 0, 80);
                $lastError = $e;
            }

            if (!$socialUser) {
                try {
                    $socialUser = $this->verifyGoogleAccessToken($token);
                    $attempts[] = 'google_access_token_jwt:ok';
                } catch (\Throwable $e) {
                    $attempts[] = 'google_access_token_jwt:' . substr($e->getMessage(), 0, 80);
                    $lastError = $e;
                }
            }
        }

        // Fallback for all non-Google providers AND for Google opaque
        // access_tokens (no JWT shape) AND for the case where both
        // Google JWT paths above failed.
        if (!$socialUser) {
            try {
                /** @var \Laravel\Socialite\Two\AbstractProvider $driver */
                $driver = Socialite::driver($provider);
                $socialUser = $driver->userFromToken($token);
                $attempts[] = 'socialite:ok';
            } catch (\Throwable $e) {
                $attempts[] = 'socialite:' . substr($e->getMessage(), 0, 80);
                $lastError = $e;
            }
        }

        if (!$socialUser) {
            \Illuminate\Support\Facades\Log::warning("Social login token verify failed for {$provider}", [
                'token_prefix' => substr($token, 0, 16) . '…',
                'token_length' => strlen($token),
                'is_jwt'       => $this->looksLikeJwt($token),
                'attempts'     => $attempts,
                'last_error'   => $lastError?->getMessage(),
            ]);
            return response()->json([
                'message' => "Could not verify {$provider} access token.",
            ], 401);
        }

        if (!$socialUser || !$socialUser->getId()) {
            return response()->json([
                'message' => "No identity returned from {$provider}.",
            ], 401);
        }

        $email = strtolower((string) $socialUser->getEmail());

        $socialAccount = SocialAccount::where('provider', $provider)
            ->where('provider_id', $socialUser->getId())
            ->first();

        if ($socialAccount) {
            $user = $socialAccount->user;

            if (!$user || $user->status !== 'active') {
                return response()->json([
                    'message' => 'Your account has been suspended or deactivated.',
                ], 403);
            }

            $socialAccount->update([
                'token'             => $data['access_token'],
                'token_expires_at'  => null,
            ]);
        } else {
            if (!$email) {
                return response()->json([
                    'message' => "No email address received from {$provider}.",
                ], 422);
            }

            $user = User::whereRaw('LOWER(email) = ?', [$email])->first();

            if (!$user) {
                $user = User::create([
                    'name'              => $socialUser->getName() ?? $socialUser->getNickname() ?? 'User',
                    'email'             => $email,
                    'email_verified_at' => null,
                    'password'          => null,
                ]);
            } elseif ($user->status !== 'active') {
                return response()->json([
                    'message' => 'Your account has been suspended or deactivated.',
                ], 403);
            }

            $user->socialAccounts()->create([
                'provider'         => $provider,
                'provider_id'      => $socialUser->getId(),
                'token'            => $data['access_token'],
                'token_expires_at' => null,
            ]);
        }

        if (!$user->hasVerifiedEmail()) {
            try {
                $user->sendEmailVerificationNotification();
            } catch (\Throwable) {
                // Non-fatal — verification email can be resent later
            }
        }

        $deviceName = $data['device_name'] ?? "social:{$provider}";
        $token = $user->createToken($deviceName, ['*'])->plainTextToken;

        return response()->json([
            'message' => 'Login successful.',
            'token'   => $token,
            'user'    => $this->transformUser($user),
            'is_new'  => $user->wasRecentlyCreated,
        ]);
    }

    /**
     * GET /api/v1/auth/me
     *
     * Returns everything the mobile profile screen needs in ONE request:
     *   - user fields (name, email, phone, timezone, role, avatar)
     *   - active workspace + plan badge
     *   - stats (conversations / response rate / avg reply time)
     *   - workspaces list (with role + member count + plan + is_current)
     *   - security (2FA enabled, active session count, password set)
     *
     * Pass `?light=1` to skip stats/workspaces/security blocks (faster
     * polling for header avatar refresh, etc.).
     */
    public function me(Request $request): JsonResponse
    {
        $user = $request->user();
        $light = $request->boolean('light');

        $payload = [
            'user' => $this->transformUser($user),
        ];

        if (!$light) {
            $payload['stats']      = $this->computeUserStats($user);
            $payload['workspaces'] = $this->listUserWorkspaces($user);
            $payload['security']   = $this->describeSecurityState($user);
        }

        return response()->json($payload);
    }

    /**
     * Backward-compatible alias for clients that fetch GET /auth/profile
     * instead of GET /auth/me.
     */
    public function profile(Request $request): JsonResponse
    {
        return $this->me($request);
    }

    /**
     * Shape a User for client consumption — never leak password/2FA secrets.
     */
    private function transformUser(User $user): array
    {
        $user->loadMissing(['activeWorkspace', 'currentPlan']);

        $activeWs = $user->activeWorkspace;
        $plan     = $user->currentPlan;
        $avatarUrl = $user->avatar_path ? asset('storage/' . $user->avatar_path) : null;

        return [
            'id'                  => $user->id,
            'uuid'                => $user->uuid,
            'name'                => $user->name,
            'first_name'          => $user->first_name ?? null,
            'last_name'           => $user->last_name ?? null,
            'initials'            => $user->initials ?? strtoupper(substr((string) $user->name, 0, 2)),
            'email'               => $user->email,
            'phone'               => $user->phone ?? null,
            'profile_image'       => $avatarUrl,
            'timezone'            => $user->timezone ?? 'UTC',
            'locale'              => $user->locale ?? 'en',
            'language'            => $user->language ?? $user->locale ?? 'en',
            'is_admin'            => (bool) $user->is_admin,
            'admin_role'          => $user->admin_role ?? null,
            'email_verified'      => $user->hasVerifiedEmail(),
            'two_factor_enabled'  => method_exists($user, 'hasTwoFactorEnabled') ? $user->hasTwoFactorEnabled() : false,
            'has_workspace'       => method_exists($user, 'hasWorkspace') ? $user->hasWorkspace() : (bool) $user->active_workspace_id,
            'workspace_role'      => $activeWs ? $user->workspaceRole($activeWs->id) : null,
            'active_workspace'    => $activeWs ? [
                'id'    => $activeWs->id,
                'name'  => $activeWs->name,
                'slug'  => $activeWs->slug ?? null,
            ] : null,
            'plan' => $plan ? [
                'id'           => $plan->id,
                'slug'         => $plan->slug,
                'name'         => $plan->name,
                'is_popular'   => (bool) $plan->is_popular,
                'monthly_price'=> (float) ($plan->monthly_price ?? 0),
            ] : null,
            'created_at'          => $user->created_at?->toIso8601String(),
        ];
    }

    /**
     * Profile screen stat tiles — Conversations / Response rate / Avg reply.
     * Scoped to the user's active workspace; degrades gracefully if no
     * workspace is set yet (returns zeros).
     */
    private function computeUserStats(User $user): array
    {
        $workspaceId = $user->active_workspace_id;
        if (!$workspaceId) {
            return [
                'conversations'        => 0,
                'response_rate'        => 0,
                'avg_reply_minutes'    => 0,
            ];
        }

        // All conversations in this workspace (per the profile screen
        // mockup the user wants total volume, not just ones assigned to them).
        $base = \App\Models\Conversation::where('workspace_id', $workspaceId);

        $total = (int) $base->count();
        $replied = (int) (clone $base)->whereNotNull('first_response_at')->count();

        $avgFirstResponseSeconds = (int) (clone $base)
            ->whereNotNull('first_response_at')
            ->selectRaw('AVG(TIMESTAMPDIFF(SECOND, created_at, first_response_at)) as avg_s')
            ->value('avg_s');

        return [
            'conversations'     => $total,
            'response_rate'     => $total > 0 ? (int) round(($replied / $total) * 100) : 0,
            'avg_reply_minutes' => (int) round(($avgFirstResponseSeconds ?? 0) / 60),
        ];
    }

    /**
     * All workspaces this user belongs to, with the bits the profile screen
     * displays per row: role, member count, plan name, current marker.
     */
    private function listUserWorkspaces(User $user): array
    {
        $rows = $user->workspaces()
            ->with(['subscription.plan'])
            ->withCount('members')
            ->get();

        $activeId = $user->active_workspace_id;

        return $rows->map(function ($ws) use ($activeId) {
            $plan = $ws->subscription?->plan;
            return [
                'id'           => $ws->id,
                'name'         => $ws->name,
                'slug'         => $ws->slug ?? null,
                'role'         => ucfirst($ws->pivot->role ?? 'member'),
                'member_count' => (int) ($ws->members_count ?? 0),
                'plan_name'    => $plan?->name ?? 'Free',
                'is_current'   => (int) $ws->id === (int) $activeId,
            ];
        })->values()->all();
    }

    /**
     * Security card — what the mobile profile screen flags in green/red.
     */
    private function describeSecurityState(User $user): array
    {
        $sessions = 0;
        try {
            // Best-effort: many MailTrixy installs track per-user sessions in
            // a `user_sessions` table (see migrations). If it doesn't exist
            // we just return 0 and the mobile UI hides the "N devices" chip.
            if (\Illuminate\Support\Facades\Schema::hasTable('user_sessions')) {
                $sessions = (int) \Illuminate\Support\Facades\DB::table('user_sessions')
                    ->where('user_id', $user->id)
                    ->count();
            }
        } catch (\Throwable) {
            $sessions = 0;
        }

        return [
            'has_password'           => !empty($user->password),
            'two_factor_enabled'     => method_exists($user, 'hasTwoFactorEnabled') ? $user->hasTwoFactorEnabled() : false,
            'active_sessions_count'  => $sessions,
            'email_verified'         => $user->hasVerifiedEmail(),
        ];
    }

    // ════════════════════════════════════════════════════════════════════
    // FORGOT PASSWORD — OTP FLOW (mobile-first)
    // ════════════════════════════════════════════════════════════════════
    //
    // 3-step flow:
    //   1. POST /forgot-password/send-otp     → emails a 6-digit code
    //   2. POST /forgot-password/verify-otp   → returns a short-lived reset_token
    //   3. POST /forgot-password/reset        → password change using reset_token
    //
    // OTP is stored in cache (no DB migration needed) keyed by email + a
    // hash of the IP. TTL 10 min. Reset_token TTL 5 min.

    private const OTP_TTL_MINUTES = 10;
    private const RESET_TOKEN_TTL_MINUTES = 5;

    public function forgotPasswordSendOtp(Request $request): JsonResponse
    {
        $data = Validator::make($request->all(), [
            'email' => ['required', 'string', 'email'],
        ])->validate();

        $email = strtolower($data['email']);
        $rateKey = 'mobile-otp-send:' . sha1($email . '|' . $request->ip());

        if (RateLimiter::tooManyAttempts($rateKey, 3)) {
            $secs = RateLimiter::availableIn($rateKey);
            return response()->json(['message' => "Please wait {$secs} seconds before requesting another code."], 429);
        }
        RateLimiter::hit($rateKey, 300); // 5 min window

        // Always respond identically regardless of whether the user exists
        // (prevents account enumeration). Only actually send if they do.
        $user = User::whereRaw('LOWER(email) = ?', [$email])->first();

        if ($user && $user->status === 'active') {
            // Refuse to "succeed" silently when the install isn't actually
            // configured to send mail. Otherwise the API returns 200 but the
            // OTP gets written to storage/logs/laravel-*.log instead of an
            // inbox — which is exactly what makes "OTP not received" tickets.
            $mailer = strtolower((string) config('mail.default'));
            $smtpHost = (string) config('mail.mailers.smtp.host');
            if ($mailer === 'log' || $mailer === 'array' || ($mailer === 'smtp' && $smtpHost === '')) {
                \Illuminate\Support\Facades\Log::warning("OTP not sent — mail driver is '{$mailer}' (SMTP not configured).");
                return response()->json([
                    'message' => 'Email is not configured on this server. Ask the admin to set up SMTP under Admin → Settings → Email.',
                ], 503);
            }

            // Generate 6-digit OTP
            $otp = str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT);

            // Store hashed OTP in cache so we don't keep the plaintext anywhere
            \Illuminate\Support\Facades\Cache::put(
                'otp:reset:' . sha1($email),
                ['hash' => hash('sha256', $otp), 'attempts' => 0],
                now()->addMinutes(self::OTP_TTL_MINUTES)
            );

            try {
                $user->notify(new \App\Notifications\PasswordResetOtpNotification($otp, self::OTP_TTL_MINUTES));
            } catch (\Throwable $e) {
                // Real send failure — burn the cached OTP and surface a
                // friendly error rather than pretending it worked.
                \Illuminate\Support\Facades\Cache::forget('otp:reset:' . sha1($email));
                \Illuminate\Support\Facades\Log::error('OTP email send failed', [
                    'email' => $email,
                    'error' => $e->getMessage(),
                ]);
                return response()->json([
                    'message' => 'Could not send the verification email. Please try again or contact support.',
                ], 502);
            }
        }

        return response()->json([
            'message'    => 'If an account with that email exists, a verification code has been sent.',
            'expires_in' => self::OTP_TTL_MINUTES * 60,
        ]);
    }

    public function forgotPasswordVerifyOtp(Request $request): JsonResponse
    {
        $data = Validator::make($request->all(), [
            'email' => ['required', 'string', 'email'],
            'otp'   => ['required', 'string', 'size:6'],
        ])->validate();

        $email = strtolower($data['email']);
        $cacheKey = 'otp:reset:' . sha1($email);
        $stored = \Illuminate\Support\Facades\Cache::get($cacheKey);

        if (!$stored) {
            return response()->json(['message' => 'Code expired or not requested. Please request a new one.'], 422);
        }

        // 5 attempts per code, then it's invalidated
        if (($stored['attempts'] ?? 0) >= 5) {
            \Illuminate\Support\Facades\Cache::forget($cacheKey);
            return response()->json(['message' => 'Too many incorrect attempts. Please request a new code.'], 429);
        }

        if (hash('sha256', $data['otp']) !== $stored['hash']) {
            $stored['attempts'] = ($stored['attempts'] ?? 0) + 1;
            \Illuminate\Support\Facades\Cache::put($cacheKey, $stored, now()->addMinutes(self::OTP_TTL_MINUTES));
            return response()->json(['message' => 'Incorrect code.', 'attempts_left' => 5 - $stored['attempts']], 422);
        }

        // OTP correct — burn it, issue a short-lived reset token
        \Illuminate\Support\Facades\Cache::forget($cacheKey);

        $resetToken = Str::random(48);
        \Illuminate\Support\Facades\Cache::put(
            'otp:reset-token:' . $resetToken,
            ['email' => $email],
            now()->addMinutes(self::RESET_TOKEN_TTL_MINUTES)
        );

        return response()->json([
            'message'     => 'Code verified.',
            'reset_token' => $resetToken,
            'expires_in'  => self::RESET_TOKEN_TTL_MINUTES * 60,
        ]);
    }

    public function forgotPasswordResetWithOtp(Request $request): JsonResponse
    {
        $data = Validator::make($request->all(), [
            'email'       => ['required', 'string', 'email'],
            'reset_token' => ['required', 'string'],
            'password'    => ['required', 'string', PasswordRule::min(8)],
        ])->validate();

        $tokenKey = 'otp:reset-token:' . $data['reset_token'];
        $payload  = \Illuminate\Support\Facades\Cache::get($tokenKey);

        if (!$payload || strtolower($data['email']) !== ($payload['email'] ?? '')) {
            return response()->json(['message' => 'Invalid or expired reset token.'], 422);
        }

        $user = User::whereRaw('LOWER(email) = ?', [strtolower($data['email'])])->first();
        if (!$user) {
            return response()->json(['message' => 'Invalid or expired reset token.'], 422);
        }

        $user->forceFill([
            'password'       => $data['password'],
            'remember_token' => Str::random(60),
        ])->save();

        // Burn the token + revoke ALL existing tokens (force re-login)
        \Illuminate\Support\Facades\Cache::forget($tokenKey);
        $user->tokens()->delete();

        return response()->json([
            'message' => 'Password updated. Please sign in with your new password.',
        ]);
    }

    // ════════════════════════════════════════════════════════════════════
    // CHANGE PASSWORD — for logged-in user (knows current password)
    // ════════════════════════════════════════════════════════════════════
    public function changePassword(Request $request): JsonResponse
    {
        $data = Validator::make($request->all(), [
            'current_password' => ['required', 'string'],
            'password'         => ['required', 'string', PasswordRule::min(8), 'different:current_password'],
        ])->validate();

        $user = $request->user();

        if (!Hash::check($data['current_password'], $user->password ?? '')) {
            return response()->json([
                'message' => 'Current password is incorrect.',
                'errors'  => ['current_password' => ['The current password you entered is incorrect.']],
            ], 422);
        }

        $user->forceFill([
            'password'       => $data['password'],
            'remember_token' => Str::random(60),
        ])->save();

        // Revoke ALL other tokens — keep the current one alive so the
        // mobile app stays signed in on this device.
        $currentTokenId = $request->user()?->currentAccessToken()?->id;
        $user->tokens()->when($currentTokenId, fn ($q) => $q->where('id', '!=', $currentTokenId))->delete();

        return response()->json([
            'message' => 'Password updated. Other devices have been signed out.',
        ]);
    }

    // ════════════════════════════════════════════════════════════════════
    // EDIT PROFILE — name / phone / timezone / locale
    // ════════════════════════════════════════════════════════════════════
    public function updateProfile(Request $request): JsonResponse
    {
        $user = $request->user();
        $payload = $this->normalizedProfilePayload($request);

        $data = Validator::make($payload, [
            'name'       => ['sometimes', 'string', 'min:2', 'max:100'],
            'first_name' => ['sometimes', 'nullable', 'string', 'min:1', 'max:50'],
            'last_name'  => ['sometimes', 'nullable', 'string', 'max:50'],
            'email'      => ['sometimes', 'string', 'email', 'max:255', Rule::unique(User::class, 'email')->ignore($user->id)],
            'phone'      => ['sometimes', 'nullable', 'string', 'max:30'],
            'timezone'   => ['sometimes', 'string', 'max:50'],
            'locale'     => ['sometimes', 'string', 'max:10'],
            'language'   => ['sometimes', 'string', 'max:10'],
            'profile_image' => ['sometimes', 'image', 'mimes:jpg,jpeg,png,webp', 'max:4096'],
        ])->validate();

        $updates = [];

        if (array_key_exists('name', $data)) {
            $name = trim((string) $data['name']);
            if ($name !== '') {
                $updates['name'] = $name;
            }
        } elseif (array_key_exists('first_name', $data) || array_key_exists('last_name', $data)) {
            $firstName = array_key_exists('first_name', $data)
                ? trim((string) ($data['first_name'] ?? ''))
                : trim((string) ($user->first_name ?? ''));
            $lastName = array_key_exists('last_name', $data)
                ? trim((string) ($data['last_name'] ?? ''))
                : trim((string) ($user->last_name ?? ''));

            $fullName = trim(implode(' ', array_filter([$firstName, $lastName], fn ($v) => $v !== '')));
            if ($fullName !== '') {
                $updates['name'] = $fullName;
            }
        }

        foreach (['phone', 'timezone', 'locale', 'language'] as $field) {
            if (array_key_exists($field, $data)) {
                $updates[$field] = $data[$field];
            }
        }

        if (array_key_exists('email', $data)) {
            $email = strtolower(trim((string) $data['email']));
            if ($email !== '' && $email !== strtolower((string) $user->email)) {
                $updates['email'] = $email;
                $updates['email_verified_at'] = null;
            }
        }

        // Resolve the uploaded image from either the native request (works for
        // POST multipart) or the manually-parsed payload (needed for PUT
        // multipart — PHP doesn't parse multipart bodies on PUT requests).
        $uploadedImage = $request->file('profile_image');
        if (!$uploadedImage instanceof UploadedFile && ($payload['profile_image'] ?? null) instanceof UploadedFile) {
            $uploadedImage = $payload['profile_image'];
        }
        if ($uploadedImage instanceof UploadedFile) {
            $updates['avatar_path'] = $this->replaceAvatar($user, $uploadedImage);
        }

        if ($updates === []) {
            return response()->json([
                'message' => 'No supported profile fields were provided.',
                'errors'  => ['profile' => ['Send at least one of: name, first_name, last_name, email, phone, timezone, locale, language, profile_image.']],
            ], 422);
        }

        $user->forceFill($updates)->save();

        return response()->json([
            'message' => 'Profile updated.',
            'user'    => $this->transformUser($user->fresh()),
        ]);
    }

    private function replaceAvatar(User $user, UploadedFile $file): string
    {
        if ($user->avatar_path) {
            try {
                \Illuminate\Support\Facades\Storage::disk('public')->delete($user->avatar_path);
            } catch (\Throwable) {
            }
        }

        $path = $file->store('avatars/' . $user->id, 'public');

        if (str_starts_with(basename($file->getPathname()), 'mtx_profile_')) {
            @unlink($file->getPathname());
        }

        return $path;
    }

    /**
     * @return array<string, mixed>
     */
    private function normalizedProfilePayload(Request $request): array
    {
        $payload = $request->all();

        foreach ($request->allFiles() as $key => $file) {
            $payload[$key] = $file;
        }

        foreach (MultipartFormDataParser::fromRequest($request) as $key => $value) {
            $payload[$key] = $value;
        }

        return $payload;
    }

    // ════════════════════════════════════════════════════════════════════
    // TWO-FACTOR AUTHENTICATION
    // ════════════════════════════════════════════════════════════════════
    //
    // Setup flow:
    //   1. POST /2fa/setup    → generates secret + QR URL + setup_token (cached)
    //                           secret is NOT yet saved to user — must confirm first
    //   2. POST /2fa/confirm  → user submits first valid code from authenticator app
    //                           on success: saves encrypted secret + activates 2FA + returns recovery codes
    //   3. POST /2fa/disable  → requires current password (and optionally a code)
    //
    // Login with 2FA: existing /login already accepts `two_factor_code`.
    // If user has 2FA enabled but doesn't supply code, response is:
    //     { "message": "Two-factor code required.", "two_factor_required": true }
    // Mobile then re-POSTs to /login with the code added.

    public function twoFactorSetup(Request $request): JsonResponse
    {
        $user = $request->user();

        if ($user->hasTwoFactorEnabled()) {
            return response()->json([
                'message' => 'Two-factor authentication is already enabled. Disable it first to re-enroll.',
            ], 422);
        }

        /** @var \PragmaRX\Google2FA\Google2FA $g */
        $g = app(\PragmaRX\Google2FA\Google2FA::class);
        $secret = $g->generateSecretKey(32);

        // Same de-leak fix as the web TwoFactorController — the 2FA
        // QR shown by the mobile API previously branded every
        // authenticator-app entry "MailTrixy" for resellers.
        $appName = \App\Helpers\BrandingHelper::name();
        $qrCodeUrl = $g->getQRCodeUrl($appName, $user->email, $secret);

        // Cache the unconfirmed secret with a setup_token so the user must
        // come back with the same token + a valid code to activate.
        $setupToken = Str::random(48);
        \Illuminate\Support\Facades\Cache::put(
            '2fa:setup:' . $setupToken,
            ['user_id' => $user->id, 'secret' => $secret],
            now()->addMinutes(15)
        );

        return response()->json([
            'message'      => 'Scan the QR code with your authenticator app, then call /2fa/confirm with the 6-digit code.',
            'setup_token'  => $setupToken,
            'secret'       => $secret,            // for manual entry
            'otpauth_url'  => $qrCodeUrl,         // raw URL (mobile can render its own QR)
            'qr_image_url' => 'https://api.qrserver.com/v1/create-qr-code/?size=250x250&data=' . urlencode($qrCodeUrl),
            'expires_in'   => 15 * 60,
        ]);
    }

    public function twoFactorConfirm(Request $request): JsonResponse
    {
        $data = Validator::make($request->all(), [
            'setup_token' => ['required', 'string'],
            'code'        => ['required', 'string', 'size:6'],
        ])->validate();

        $cached = \Illuminate\Support\Facades\Cache::get('2fa:setup:' . $data['setup_token']);
        if (!$cached || $cached['user_id'] !== $request->user()->id) {
            return response()->json(['message' => 'Setup token is invalid or expired. Start over with /2fa/setup.'], 422);
        }

        /** @var \PragmaRX\Google2FA\Google2FA $g */
        $g = app(\PragmaRX\Google2FA\Google2FA::class);
        if (!$g->verifyKey($cached['secret'], (string) $data['code'])) {
            return response()->json(['message' => 'Invalid code. Make sure your phone clock is in sync.'], 422);
        }

        // Generate one-time recovery codes (stored hashed)
        $recoveryCodes = collect(range(1, 10))->map(fn () => Str::random(10))->all();

        $user = $request->user();
        $user->forceFill([
            'two_factor_enabled'        => true,
            'two_factor_secret'         => $cached['secret'],            // encrypted via cast on User
            'two_factor_recovery_codes' => json_encode($recoveryCodes),  // encrypted via cast
            'two_factor_confirmed_at'   => now(),
            'two_factor_method'         => 'totp',
        ])->save();

        \Illuminate\Support\Facades\Cache::forget('2fa:setup:' . $data['setup_token']);

        return response()->json([
            'message'        => 'Two-factor authentication enabled.',
            'recovery_codes' => $recoveryCodes,
            'user'           => $this->transformUser($user->fresh()),
        ]);
    }

    public function twoFactorDisable(Request $request): JsonResponse
    {
        $data = Validator::make($request->all(), [
            'password' => ['required', 'string'],
        ])->validate();

        $user = $request->user();

        if (!Hash::check($data['password'], $user->password ?? '')) {
            return response()->json([
                'message' => 'Incorrect password.',
                'errors'  => ['password' => ['Password verification failed.']],
            ], 422);
        }

        $user->forceFill([
            'two_factor_enabled'        => false,
            'two_factor_secret'         => null,
            'two_factor_recovery_codes' => null,
            'two_factor_confirmed_at'   => null,
            'two_factor_method'         => null,
        ])->save();

        return response()->json([
            'message' => 'Two-factor authentication disabled.',
            'user'    => $this->transformUser($user->fresh()),
        ]);
    }

    // ════════════════════════════════════════════════════════════════════
    // 2FA — PIN-BASED (mobile-friendly alternative to TOTP)
    // ════════════════════════════════════════════════════════════════════
    //
    // For users who don't want to install Google Authenticator. Stores a
    // bcrypt-hashed 4–6 digit PIN in `two_factor_secret` (encrypted column,
    // so the cast wraps the bcrypt hash for at-rest defence-in-depth).
    //
    // Login flow then accepts the PIN via the same `two_factor_code` field.

    public function twoFactorSetupPin(Request $request): JsonResponse
    {
        $data = Validator::make($request->all(), [
            'password' => ['required', 'string'],
            'pin'      => ['required', 'string', 'digits_between:4,6'],
        ])->validate();

        $user = $request->user();

        if (!Hash::check($data['password'], $user->password ?? '')) {
            return response()->json([
                'message' => 'Incorrect password.',
                'errors'  => ['password' => ['Password verification failed.']],
            ], 422);
        }

        if ($user->hasTwoFactorEnabled()) {
            return response()->json([
                'message' => 'Two-factor authentication is already enabled. Disable it first or use the change-pin endpoint.',
            ], 422);
        }

        $recoveryCodes = collect(range(1, 10))->map(fn () => Str::random(10))->all();

        $user->forceFill([
            'two_factor_enabled'        => true,
            'two_factor_secret'         => Hash::make($data['pin']),
            'two_factor_recovery_codes' => json_encode($recoveryCodes),
            'two_factor_confirmed_at'   => now(),
            'two_factor_method'         => 'pin',
        ])->save();

        return response()->json([
            'message'        => 'PIN set. Two-factor authentication is now active.',
            'recovery_codes' => $recoveryCodes,
            'user'           => $this->transformUser($user->fresh()),
        ]);
    }

    public function twoFactorChangePin(Request $request): JsonResponse
    {
        $data = Validator::make($request->all(), [
            'current_pin' => ['required', 'string'],
            'pin'         => ['required', 'string', 'digits_between:4,6', 'different:current_pin'],
        ])->validate();

        $user = $request->user();

        if (!$user->hasTwoFactorEnabled() || ($user->two_factor_method ?? '') !== 'pin') {
            return response()->json(['message' => 'PIN-based 2FA is not enabled. Use /2fa/pin/setup first.'], 422);
        }

        if (!Hash::check($data['current_pin'], (string) $user->two_factor_secret)) {
            return response()->json([
                'message' => 'Current PIN is incorrect.',
                'errors'  => ['current_pin' => ['Incorrect.']],
            ], 422);
        }

        $user->forceFill([
            'two_factor_secret' => Hash::make($data['pin']),
        ])->save();

        return response()->json(['message' => 'PIN updated.']);
    }

    public function twoFactorRegenerateRecoveryCodes(Request $request): JsonResponse
    {
        $data = Validator::make($request->all(), [
            'password' => ['required', 'string'],
        ])->validate();

        $user = $request->user();

        if (!Hash::check($data['password'], $user->password ?? '')) {
            return response()->json([
                'message' => 'Incorrect password.',
                'errors'  => ['password' => ['Password verification failed.']],
            ], 422);
        }

        if (!$user->hasTwoFactorEnabled()) {
            return response()->json(['message' => 'Two-factor authentication is not enabled.'], 422);
        }

        $recoveryCodes = collect(range(1, 10))->map(fn () => Str::random(10))->all();

        $user->forceFill([
            'two_factor_recovery_codes' => json_encode($recoveryCodes),
        ])->save();

        return response()->json([
            'message'        => 'New recovery codes generated. Old ones are no longer valid.',
            'recovery_codes' => $recoveryCodes,
        ]);
    }

    /**
     * Quick shape check — is this string a JSON Web Token (three base64url
     * segments separated by dots)? Used by the social-login endpoint to
     * decide whether the caller sent an opaque OAuth2 access_token or a
     * JWT id_token, since the two need different validation paths.
     */
    private function looksLikeJwt(string $token): bool
    {
        $parts = explode('.', $token);
        if (count($parts) !== 3) {
            return false;
        }
        // Header should base64url-decode to JSON with an "alg" claim.
        $headerJson = base64_decode(strtr($parts[0], '-_', '+/'), true);
        if ($headerJson === false) {
            return false;
        }
        $header = json_decode($headerJson, true);
        return is_array($header) && isset($header['alg']);
    }

    /**
     * Verify a Google id_token against Google's tokeninfo endpoint and
     * return a Socialite-compatible user object so the rest of the
     * socialLogin() flow can stay unchanged.
     *
     * tokeninfo validates: signature, expiry, issuer, audience. If
     * anything's off, Google returns 4xx with an error JSON and we
     * throw — caller catches and returns the unified 401 message.
     *
     * Reference: https://developers.google.com/identity/sign-in/web/backend-auth
     */
    private function verifyGoogleIdToken(string $idToken): \Laravel\Socialite\Two\User
    {
        $response = \Illuminate\Support\Facades\Http::timeout(10)
            ->get('https://oauth2.googleapis.com/tokeninfo', ['id_token' => $idToken]);

        if (!$response->ok()) {
            throw new \RuntimeException('Google tokeninfo rejected the id_token: ' . $response->status());
        }

        $payload = $response->json();
        if (!is_array($payload) || empty($payload['sub']) || empty($payload['email'])) {
            throw new \RuntimeException('Google tokeninfo returned an incomplete payload.');
        }

        // Audience check — make sure the token was issued for OUR Meta
        // app client, not someone else's. Skip when the admin hasn't
        // configured a Google client yet (env empty), since the only
        // path that gets here is admin testing in that case.
        $expectedAud = (string) (config('services.google.client_id') ?? '');
        if ($expectedAud !== '' && (string) ($payload['aud'] ?? '') !== $expectedAud) {
            throw new \RuntimeException('Google id_token audience does not match this app.');
        }

        // Build a Socialite user object so the rest of socialLogin()
        // doesn't need to branch on which verification path ran.
        $user = new \Laravel\Socialite\Two\User();
        $user->id        = (string) $payload['sub'];
        $user->email     = (string) $payload['email'];
        $user->name      = (string) ($payload['name'] ?? '');
        $user->nickname  = (string) ($payload['given_name'] ?? '');
        $user->avatar    = (string) ($payload['picture'] ?? '');
        $user->token     = $idToken;
        $user->user      = $payload;
        return $user;
    }

    /**
     * Verify a Google access_token (opaque OR JWT-shaped) by hitting the
     * `?access_token=` mode of Google's tokeninfo endpoint. This is the
     * fallback path for tokens that look like a JWT but were issued as
     * access_tokens — increasingly common with newer Google Identity
     * Services (GIS) flows and Workspace tenants.
     *
     * The tokeninfo response for an access_token does NOT include email
     * directly (only `azp`, `aud`, `sub`, `scope`, `exp`). So we use the
     * token AGAIN as a Bearer header against /userinfo to retrieve the
     * email + profile fields needed to find-or-create the user.
     */
    private function verifyGoogleAccessToken(string $accessToken): \Laravel\Socialite\Two\User
    {
        $tokenInfo = \Illuminate\Support\Facades\Http::timeout(10)
            ->get('https://oauth2.googleapis.com/tokeninfo', ['access_token' => $accessToken]);

        if (!$tokenInfo->ok()) {
            throw new \RuntimeException('Google tokeninfo rejected the access_token: ' . $tokenInfo->status());
        }

        $payload = $tokenInfo->json();
        if (!is_array($payload) || empty($payload['sub'])) {
            throw new \RuntimeException('Google tokeninfo returned an incomplete payload.');
        }

        // Audience guard — only enforced when the admin has set the client
        // ID. Same logic as the id_token path; keeps tests/playground
        // tokens working on a fresh install with no client_id configured.
        $expectedAud = (string) (config('services.google.client_id') ?? '');
        if ($expectedAud !== '' && (string) ($payload['aud'] ?? '') !== $expectedAud) {
            throw new \RuntimeException('Google access_token audience does not match this app.');
        }

        // tokeninfo doesn't give us email/name for access_tokens — go
        // fetch the profile from /userinfo using the same token.
        $userInfo = \Illuminate\Support\Facades\Http::timeout(10)
            ->withToken($accessToken)
            ->get('https://www.googleapis.com/oauth2/v3/userinfo');

        if (!$userInfo->ok()) {
            throw new \RuntimeException('Google userinfo rejected the access_token: ' . $userInfo->status());
        }

        $profile = $userInfo->json();
        if (!is_array($profile) || empty($profile['sub']) || empty($profile['email'])) {
            throw new \RuntimeException('Google userinfo returned an incomplete profile.');
        }

        $user = new \Laravel\Socialite\Two\User();
        $user->id        = (string) $profile['sub'];
        $user->email     = (string) $profile['email'];
        $user->name      = (string) ($profile['name'] ?? '');
        $user->nickname  = (string) ($profile['given_name'] ?? '');
        $user->avatar    = (string) ($profile['picture'] ?? '');
        $user->token     = $accessToken;
        $user->user      = $profile;
        return $user;
    }
}
