<?php

declare(strict_types=1);

/**
 * APO-186 Card 4 / APO-197 Card 3 — Bearer-token authorization gate.
 *
 * APO-186 Foundation shipped a config-defined dev token loaded from
 * MYRON_AI_DEV_BEARER_TOKEN + MYRON_AI_DEV_BEARER_SCOPES. APO-197 upgrades
 * this to the production posture: bearer tokens are looked up against the
 * `ai_integrations` table — tokenPrefix is matched for O(1) candidate
 * lookup, then bcrypt-verified against the stored hash. Revoked tokens
 * return 401 immediately. The dev token continues to work in DEV mode as
 * a fallback (logged as a deprecation), but production installs reject it.
 *
 * Authentication flow:
 *   1. Read `Authorization: Bearer <token>` header.
 *   2. If empty/missing AND the action is public → anonymous caller.
 *      Empty/missing on private action → 401 auth_required.
 *   3. If token starts with `myr_ai_` → look up by prefix + bcrypt verify
 *      against ai_integrations. On match: integrationId + scopes returned.
 *   4. If token matches MYRON_AI_DEV_BEARER_TOKEN AND dev mode → return
 *      dev-token caller (with deprecation log). Production: rejected.
 *   5. Otherwise → 401 auth_invalid.
 *
 * Successful AI-bearer auth bumps the integration's request count + last-used.
 */

require_once dirname(__DIR__, 2) . '/modules/identity/AiIntegration.php';

final class MyronApiAuthorization
{
    /**
     * @param list<string> $devTokenScopes
     * @param list<MyronApiAuthMethod|callable(array<string, string|null>, bool): ?MyronActionCallerContext> $authMethods
     */
    public function __construct(
        private readonly string $devToken,
        private readonly array  $devTokenScopes,
        private readonly ?MyronAiIntegrationRepository $integrationRepository = null,
        private readonly bool   $isProductionMode = false,
        private readonly array  $authMethods = [],
    ) {
    }

    /**
     * Resolve the caller from the request headers.
     *
     * @param array<string, string|null> $headers
     */
    public function resolveCaller(array $headers, bool $publicEndpoint): MyronActionCallerContext
    {
        $bearer = $this->extractBearer($headers);

        if ($bearer === null || $bearer === '') {
            $sessionCaller = $this->resolveSessionCaller();
            if ($sessionCaller instanceof MyronActionCallerContext) {
                return $sessionCaller;
            }

            if ($publicEndpoint) {
                return new MyronActionCallerContext(
                    callerType: 'anonymous',
                    callerId:   'anonymous',
                    heldScopes: [],
                );
            }
            throw new MyronApiAuthError(
                'Authorization required: missing or empty Authorization: Bearer <token> header.',
                httpStatus: 401,
                errorCode:  'auth_required',
            );
        }

        foreach ($this->authMethods as $method) {
            if ($method instanceof MyronApiAuthMethod) {
                $caller = $method->resolveCaller($headers, $publicEndpoint);
            } elseif (is_callable($method)) {
                $caller = $method($headers, $publicEndpoint);
            } else {
                continue;
            }

            if ($caller instanceof MyronActionCallerContext) {
                return $caller;
            }
        }

        // Production path: lookup against ai_integrations
        if (str_starts_with($bearer, MyronAiTokenCodec::TOKEN_PREFIX_LITERAL) && $this->integrationRepository !== null) {
            $prefix = MyronAiTokenCodec::prefixFromToken($bearer);
            if ($prefix === null) {
                throw new MyronApiAuthError(
                    'Authorization rejected: malformed AI integration token.',
                    httpStatus: 401,
                    errorCode:  'auth_invalid',
                );
            }
            $candidates = $this->integrationRepository->findByPrefix($prefix);
            foreach ($candidates as $integration) {
                if ($integration->isRevoked()) {
                    continue;
                }
                $hash = $this->integrationRepository->getTokenHash($integration->id);
                if ($hash === null) {
                    continue;
                }
                if (MyronAiTokenCodec::verify($bearer, $hash)) {
                    $this->integrationRepository->recordUse($integration->id);
                    return new MyronActionCallerContext(
                        callerType: 'ai-bearer',
                        callerId:   $integration->id,
                        heldScopes: $integration->scopes,
                    );
                }
            }
            throw new MyronApiAuthError(
                'Authorization rejected: bearer token did not match any active AI integration.',
                httpStatus: 401,
                errorCode:  'auth_invalid',
            );
        }

        // Dev-token fallback (allowed only in non-production mode)
        if ($this->devToken !== '' && hash_equals($this->devToken, $bearer)) {
            if ($this->isProductionMode) {
                throw new MyronApiAuthError(
                    'Authorization rejected: MYRON_AI_DEV_BEARER_TOKEN is not accepted in production. Create an AI integration via the Permission UI.',
                    httpStatus: 401,
                    errorCode:  'auth_invalid',
                );
            }
            error_log('[APO-197] Deprecation: MYRON_AI_DEV_BEARER_TOKEN accepted in non-production mode. Migrate to ai_integrations.');
            return new MyronActionCallerContext(
                callerType: 'ai-bearer',
                callerId:   'dev-token',
                heldScopes: $this->devTokenScopes,
            );
        }

        throw new MyronApiAuthError(
            'Authorization rejected: bearer token did not match any active AI integration.',
            httpStatus: 401,
            errorCode:  'auth_invalid',
        );
    }

    /**
     * @param array<string, string|null> $headers
     */
    private function extractBearer(array $headers): ?string
    {
        $headerCandidates = [
            'Authorization',
            'HTTP_AUTHORIZATION',
            'REDIRECT_HTTP_AUTHORIZATION',
        ];

        // Case-insensitive lookup across normal headers and the common
        // Apache/PHP CGI variables used after rewrite forwarding.
        foreach ($headers as $name => $value) {
            $matchesCandidate = false;
            foreach ($headerCandidates as $candidate) {
                if (strcasecmp((string) $name, $candidate) === 0) {
                    $matchesCandidate = true;
                    break;
                }
            }
            if (!$matchesCandidate) {
                continue;
            }
            $raw = trim((string) $value);
            if (stripos($raw, 'bearer ') === 0) {
                return trim(substr($raw, 7));
            }
        }
        return null;
    }

    private function resolveSessionCaller(): ?MyronActionCallerContext
    {
        if (!function_exists('myronCurrentUser') || !function_exists('myronUserGrantedScopes')) {
            return null;
        }

        $user = myronCurrentUser();
        if (!is_array($user)) {
            return null;
        }

        $userId = trim((string) ($user['id'] ?? ''));
        if ($userId === '') {
            return null;
        }

        return new MyronActionCallerContext(
            callerType: 'human-session',
            callerId: $userId,
            heldScopes: myronUserGrantedScopes($user),
        );
    }

}

/**
 * Auth-layer exception with a structured HTTP status + error code so the
 * dispatcher can translate into the canonical envelope cleanly.
 */
final class MyronApiAuthError extends RuntimeException
{
    public function __construct(
        string $message,
        public readonly int $httpStatus,
        public readonly string $errorCode,
    ) {
        parent::__construct($message);
    }
}
