<?php

namespace App\Http\Controllers\Api\V1;

use App\Http\Controllers\Controller;
use App\Http\Resources\UserResource;
use App\Models\User;
use App\Services\DataverseAdminService;
use Firebase\JWT\JWK;
use Firebase\JWT\JWT;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Throwable;

class MicrosoftAuthController extends Controller
{
    public function __construct(private DataverseAdminService $adminService) {}

    /**
     * GET /auth/microsoft/redirect  (web route)
     *
     * Generates a CSRF state token, stores it in cache, then redirects
     * the browser to Microsoft's OAuth2 authorization endpoint.
     */
    /**
     * Build a stateless HMAC-signed state token (no cache/session needed).
     * Format: <nonce>.<hmac>
     */
    private function makeState(): string
    {
        $nonce = Str::random(40);
        $sig   = hash_hmac('sha256', $nonce, config('app.key'));
        return $nonce . '.' . $sig;
    }

    private function verifyState(string $state): bool
    {
        $parts = explode('.', $state, 2);
        if (count($parts) !== 2 || !$parts[0] || !$parts[1]) return false;
        $expected = hash_hmac('sha256', $parts[0], config('app.key'));
        return hash_equals($expected, $parts[1]);
    }

    /**
     * Multi-tenant login only: does this email already belong to a DIFFERENT
     * Microsoft identity than the one signing in right now?
     *
     * $existing is AppUserService::resolve()'s array shape (or null). True
     * only when a prior binding actually exists AND disagrees — a brand-new
     * email, or one whose only prior binding is empty (password/single-tenant
     * accounts that have never bound an oid), is NOT a mismatch and must be
     * allowed through to bind on first use.
     *
     * Extracted to a static, dependency-free method — the exact condition
     * that closes the "nOAuth" account-takeover class in this login flow, so
     * a future refactor of handleCallback() can't accidentally loosen it
     * (e.g. dropping the !empty() guard would make every brand-new sign-up
     * fail) without a unit test catching it directly, no Laravel/JWT
     * bootstrap required.
     */
    public static function isSubjectMismatch(?array $existing, ?string $incomingOid): bool
    {
        return $existing !== null
            && !empty($existing['subject_id'])
            && $existing['subject_id'] !== $incomingOid;
    }

    /**
     * The tenant segment for the login endpoints. Multi-tenant sends everyone
     * through /organizations (any Azure AD work/school directory); single-tenant
     * pins to SYHC's own tenant GUID (the default, unchanged behavior).
     */
    private function authoritySegment(): string
    {
        return config('services.microsoft.multitenant')
            ? 'organizations'
            : config('services.microsoft.tenant_id');
    }

    public function redirect(): RedirectResponse
    {
        $state = $this->makeState();

        $authority   = $this->authoritySegment();
        $clientId    = config('services.microsoft.client_id');
        $redirectUri = config('services.microsoft.redirect_uri');

        $params = http_build_query([
            'client_id'     => $clientId,
            'response_type' => 'code',
            'redirect_uri'  => $redirectUri,
            'response_mode' => 'query',
            'scope'         => 'openid profile email offline_access',
            'state'         => $state,
        ]);

        $authUrl = "https://login.microsoftonline.com/{$authority}/oauth2/v2.0/authorize?{$params}";

        return redirect()->away($authUrl);
    }

    /**
     * GET /auth/microsoft/callback  (web route)
     *
     * Microsoft redirects here with ?code=…&state=…
     * Exchanges the code for tokens, finds/creates user, issues Sanctum token,
     * then redirects back to the Next.js frontend with the token in the URL.
     */
    public function callback(Request $request): RedirectResponse
    {
        $frontendUrl = config('services.microsoft.frontend_url');
        $errorUrl    = "{$frontendUrl}/login/?error=";

        try {
            return $this->handleCallback($request, $frontendUrl, $errorUrl);
        } catch (Throwable $e) {
            Log::error('MSAuth: unhandled callback exception', [
                'error' => $e->getMessage(),
                'trace' => $e->getTraceAsString(),
            ]);
            return redirect()->away($errorUrl . urlencode('Auth callback error: ' . $e->getMessage()));
        }
    }

    private function handleCallback(Request $request, string $frontendUrl, string $errorUrl): RedirectResponse
    {
        // Validate CSRF state (HMAC-signed, no server-side storage needed)
        $state = $request->query('state', '');
        if (!$state || !$this->verifyState($state)) {
            Log::error('MSAuth: invalid state', ['state_length' => strlen($state), 'state_preview' => substr($state, 0, 20)]);
            return redirect()->away($errorUrl . urlencode('Invalid state. Please try again.'));
        }

        // Check for Azure errors
        if ($request->has('error')) {
            $msg = $request->query('error_description', $request->query('error'));
            Log::error('MSAuth: Azure returned error', ['error' => $msg]);
            return redirect()->away($errorUrl . urlencode($msg));
        }

        $code = $request->query('code', '');
        if (!$code) {
            Log::error('MSAuth: no code received');
            return redirect()->away($errorUrl . urlencode('No authorization code received.'));
        }

        // Exchange code for tokens
        try {
            $tokens = $this->exchangeCode($code);
        } catch (Throwable $e) {
            Log::error('MSAuth: token exchange failed', ['error' => $e->getMessage()]);
            return redirect()->away($errorUrl . urlencode('Token exchange failed: ' . $e->getMessage()));
        }

        // Decode ID token
        try {
            $claims = $this->decodeIdToken($tokens['id_token'] ?? '');
        } catch (Throwable $e) {
            Log::error('MSAuth: ID token decode failed', ['error' => $e->getMessage()]);
            return redirect()->away($errorUrl . urlencode($e->getMessage()));
        }

        $multitenant = (bool) config('services.microsoft.multitenant');

        // Find or create user.
        //
        // Single-tenant (default): preferred_username, falling back to email —
        // unchanged from before, safe because the only tenant that can ever
        // reach this code is SYHC's own trusted directory.
        //
        // Multi-tenant: preferred_username ONLY. The `email` claim is a
        // free-text profile attribute (Entra's `mail`) that a FOREIGN tenant's
        // own admin can set to any string — including someone else's real
        // address — with no domain-ownership check. Trusting it as an account
        // key is the "nOAuth" account-takeover class: a hostile tenant sets a
        // user's mail to "victim@realcompany.com", that user signs in, and an
        // app that does firstOrCreate(['email' => claims.email]) logs the
        // attacker in AS the victim. preferred_username (the UPN) is bound to
        // a domain Microsoft has verified belongs to that token's own tenant,
        // so it can't be forged to land on another tenant's user.
        $email = $multitenant
            ? strtolower((string) ($claims['preferred_username'] ?? ''))
            : strtolower($claims['preferred_username'] ?? $claims['email'] ?? '');
        if (!$email) {
            return redirect()->away($errorUrl . urlencode('Could not determine user email.'));
        }

        // oid is Microsoft's immutable per-user identifier — a GUID, globally
        // unique in practice, so equality alone (no tenant needed alongside it)
        // is enough to tell "same Microsoft account" from "different one that
        // happens to share an email". Unchanged from before this fix; only the
        // mismatch check below is new.
        $oid = $claims['oid'] ?? $claims['sub'] ?? null;

        // Refuse BEFORE creating any local session: if this email is already
        // bound to a different Microsoft identity, this login must not be
        // allowed to silently take over that account. Only enforced once a
        // prior binding actually exists — a brand-new email (or one created
        // via password/single-tenant, which has no subject_id yet) still
        // proceeds normally and binds on first use. Comparing on bare oid
        // (not a tid+oid composite) means this needs no transition handling
        // for accounts bound before multi-tenant existed — they compare
        // exactly the same way as one bound today.
        if ($multitenant) {
            $existing = app(\App\Services\Identity\AppUserService::class)->resolve($email);
            if (self::isSubjectMismatch($existing, $oid)) {
                Log::warning('MSAuth: multi-tenant subject mismatch — refusing login', ['email' => $email]);
                return redirect()->away($errorUrl . urlencode(
                    'This account is already linked to a different Microsoft identity. Contact your administrator.'
                ));
            }
        }

        $user = User::firstOrCreate(
            ['email' => $email],
            [
                'name'      => $claims['name'] ?? $email,
                'password'  => bcrypt(Str::random(32)),
                'role'      => 'user',
                'is_active' => true,
            ]
        );

        if (!$user->is_active) {
            return redirect()->away($errorUrl . urlencode('Your account has been deactivated.'));
        }

        // Issue Sanctum token
        $user->tokens()->where('name', 'api')->delete();
        $token = $user->createToken('api')->plainTextToken;

        // Phase 1: upsert the provider-agnostic app-user (no-op unless enabled).
        try {
            $appRef = app(\App\Services\Identity\AppUserService::class)->syncFromLogin(
                $user->email,
                $claims['name'] ?? null,
                'microsoft',
                $oid,
            );
            if ($appRef !== null && empty($appRef['is_active'])) {
                return redirect()->away($errorUrl . urlencode('Your account has been deactivated.'));
            }
        } catch (\Throwable $e) {
            Log::warning('MSAuth: app-user sync failed', ['email' => $email, 'error' => $e->getMessage()]);
        }

        $roles = $this->adminService->resolveRoles($user->email);
        $user->is_admin       = $roles['is_admin'];
        $user->is_developer   = $roles['is_developer'];
        $user->is_super_admin = $roles['is_super_admin'];
        $user->owned_credit_account_ids = $this->adminService->creditAccountsOwnedBy($user->email);

        Log::info('MSAuth: login success', ['email' => $email]);

        $userJson = base64_encode(json_encode((new UserResource($user))->resolve()));

        return redirect()->away(
            "{$frontendUrl}/auth/callback/?token=" . urlencode($token) . "&user=" . urlencode($userJson)
        );
    }

    /**
     * Exchange an authorization code for tokens via the Microsoft token endpoint.
     */
    private function exchangeCode(string $code): array
    {
        $authority = $this->authoritySegment();
        $response = Http::asForm()->post(
            "https://login.microsoftonline.com/{$authority}/oauth2/v2.0/token",
            [
                'grant_type'    => 'authorization_code',
                'client_id'     => config('services.microsoft.client_id'),
                'client_secret' => config('services.microsoft.client_secret'),
                'redirect_uri'  => config('services.microsoft.redirect_uri'),
                'code'          => $code,
                'scope'         => 'openid profile email offline_access',
            ]
        );

        if ($response->failed() || empty($response->json('access_token'))) {
            $err = $response->json('error_description') ?? 'Unknown error';
            throw new \RuntimeException($err);
        }

        return $response->json();
    }

    /**
     * Verify the Microsoft ID token signature against the tenant JWKS and
     * validate iss + aud claims. exp/nbf are validated by firebase/php-jwt.
     *
     * JWKS is cached for 1 hour; on signature failure the cache is busted
     * once and re-fetched to handle key rotation without blocking logins.
     */
    private function decodeIdToken(string $jwt): array
    {
        $authority = $this->authoritySegment();
        $clientId  = config('services.microsoft.client_id');
        $cacheKey  = "ms_jwks_{$authority}";

        $fetchJwks = function () use ($authority): array {
            $res = Http::timeout(10)->get(
                "https://login.microsoftonline.com/{$authority}/discovery/v2.0/keys"
            );
            if ($res->failed()) {
                throw new \RuntimeException("Failed to fetch Microsoft signing keys (HTTP {$res->status()}).");
            }
            return $res->json();
        };

        // Fetch JWKS — gracefully degrade if the cache layer is unavailable
        try {
            $jwks = cache()->remember($cacheKey, 3600, $fetchJwks);
        } catch (\Throwable) {
            $jwks = $fetchJwks();
        }

        try {
            $claims = JWT::decode($jwt, JWK::parseKeySet($jwks, 'RS256'));
        } catch (\Firebase\JWT\SignatureInvalidException | \UnexpectedValueException $e) {
            // Key may have rotated — bust cache and retry once
            try { cache()->forget($cacheKey); } catch (\Throwable) {}
            try {
                $jwks = cache()->remember($cacheKey, 3600, $fetchJwks);
            } catch (\Throwable) {
                $jwks = $fetchJwks();
            }
            try {
                $claims = JWT::decode($jwt, JWK::parseKeySet($jwks, 'RS256'));
            } catch (\Throwable $e2) {
                throw new \RuntimeException('ID token signature invalid: ' . $e2->getMessage());
            }
        } catch (\Throwable $e) {
            throw new \RuntimeException('ID token decode failed: ' . $e->getMessage());
        }

        // firebase/php-jwt validates exp/nbf automatically; validate iss + aud ourselves.
        if (config('services.microsoft.multitenant')) {
            // Multi-tenant: each tenant issues under its own GUID, so the issuer
            // is validated PER-TENANT and must match the token's own `tid` claim.
            // Binding iss to tid stops a token from one tenant claiming another's
            // issuer. /organizations excludes personal MSA, so a tid is always
            // present.
            $tid = $claims->tid ?? '';
            if (!$tid) {
                throw new \RuntimeException('ID token missing tenant id (tid).');
            }
            $expectedIss = "https://login.microsoftonline.com/{$tid}/v2.0";
            if (($claims->iss ?? '') !== $expectedIss) {
                throw new \RuntimeException(
                    "ID token issuer mismatch (got: {$claims->iss}, expected: {$expectedIss})."
                );
            }
            // Optional partner-tenant allowlist. Empty = accept any tenant (safe:
            // a new external user lands unassigned with zero access until an admin
            // assigns them).
            $allowed = config('services.microsoft.allowed_tenants', []);
            if (!empty($allowed) && !in_array($tid, $allowed, true)) {
                throw new \RuntimeException("Tenant {$tid} is not permitted to sign in.");
            }
        } else {
            $tenantId    = config('services.microsoft.tenant_id');
            $expectedIss = "https://login.microsoftonline.com/{$tenantId}/v2.0";
            if (($claims->iss ?? '') !== $expectedIss) {
                throw new \RuntimeException(
                    "ID token issuer mismatch (got: {$claims->iss}, expected: {$expectedIss})."
                );
            }
        }

        $aud = $claims->aud ?? '';
        $audList = is_array($aud) ? $aud : [$aud];
        if (!in_array($clientId, $audList, true)) {
            throw new \RuntimeException(
                'ID token audience mismatch (got: ' . implode(',', $audList) . ", expected: {$clientId})."
            );
        }

        return (array) $claims;
    }
}
