<?php

declare(strict_types=1);

/**
 * APO-186 Card 2 / APO-197 Card 4 — Action API dispatcher with audit binding.
 *
 * Routes incoming /api/v1/... requests to registered MyronAction instances.
 * Validates auth + scope + input schema before invoking the action; wraps
 * the action's MyronActionResult in the canonical envelope before returning.
 *
 * APO-197 audit binding: every request path (success, error, authz_denied,
 * rate_limited, uncaught) writes one append-only row to ai_audit_log via
 * MyronAuditLogRepository. The auditEventId is allocated upfront and echoed
 * back in the canonical envelope so clients can cross-reference.
 *
 * Canonical envelope:
 *   {
 *     "ok": <bool>,
 *     "result": <action-specific>,
 *     "error": {"code": "...", "message": "..."} | null,
 *     "actionId": "act_<hex>",
 *     "auditEventId": "aud_<hex>" | null  (null only for action_not_found which writes no audit row)
 *   }
 */
final class MyronActionDispatcher
{
    public function __construct(
        private readonly MyronActionRegistry $registry,
        private readonly MyronApiAuthorization $auth,
        private readonly ?MyronAuditLogRepository $auditLog = null,
        private readonly ?MyronRateLimiter $rateLimiter = null,
    ) {
    }

    /**
     * Dispatch a single API request.
     *
     * @param array<string, string|null> $headers   header name → value (case-insensitive)
     * @param array<string, mixed>       $body      decoded JSON body
     * @return array{status:int, envelope:array<string, mixed>}
     */
    public function dispatch(string $method, string $uri, array $headers, array $body): array
    {
        $start = microtime(true);
        $actionId     = $this->generateActionId();
        $auditEventId = $this->auditLog !== null ? MyronAuditEventId::generate() : null;
        $clientIp     = (string) ($headers['X-Forwarded-For'] ?? $headers['x-forwarded-for'] ?? $_SERVER['REMOTE_ADDR'] ?? '');
        $userAgent    = (string) ($headers['User-Agent'] ?? $headers['user-agent'] ?? $_SERVER['HTTP_USER_AGENT'] ?? '');

        // 1. Resolve the action by URI + method.
        $resolved = $this->registry->resolve($method, $uri);
        if ($resolved === null) {
            // Unknown URIs do not write an audit row — they didn't reach an action.
            return $this->envelope(404, false, [], ['code' => 'action_not_found', 'message' => 'No registered action matches the request URI + method.'], $actionId, null);
        }
        $action     = $resolved['action'];
        $pathParams = $resolved['pathParams'];
        $meta       = $action->metadata();
        $entitlementDecision = null;

        // 2. Authenticate.
        $caller = null;
        try {
            $caller = $this->auth->resolveCaller($headers, $meta->requiredScope === null);
        } catch (MyronApiAuthError $e) {
            $this->writeAudit(
                $auditEventId, null, $actionId, $meta,
                $body, null, 'authz_denied', $e->errorCode, $e->httpStatus,
                $this->elapsedMs($start), $clientIp, $userAgent
            );
            return $this->envelope($e->httpStatus, false, [], ['code' => $e->errorCode, 'message' => $e->getMessage()], $actionId, $auditEventId);
        }

        // 3a. Rate-limit check (only for AI bearer callers).
        if ($this->rateLimiter !== null && $caller->callerType === 'ai-bearer' && $caller->callerId !== 'dev-token') {
            $decision = $this->rateLimiter->decide($caller->callerId, $meta->requiredScope ?? '');
            if (!$decision->allowed) {
                $this->writeAudit(
                    $auditEventId, $this->callerIntegrationId($caller), $actionId, $meta,
                    $body, null, 'rate_limited', 'rate_limited', 429,
                    $this->elapsedMs($start), $clientIp, $userAgent
                );
                return $this->envelope(429, false, [], [
                    'code'    => 'rate_limited',
                    'message' => 'Rate limit exceeded. Retry after ' . $decision->retryAfterSeconds . ' seconds. (' . $decision->reason . ')',
                ], $actionId, $auditEventId);
            }
        }

        // 3. Scope check.
        if ($meta->requiredScope !== null && !$caller->holdsScope($meta->requiredScope)) {
            $this->writeAudit(
                $auditEventId, $this->callerIntegrationId($caller), $actionId, $meta,
                $body, null, 'authz_denied', 'scope_insufficient', 403,
                $this->elapsedMs($start), $clientIp, $userAgent
            );
            return $this->envelope(403, false, [], [
                'code'    => 'scope_insufficient',
                'message' => 'Caller does not hold the required scope: ' . $meta->requiredScope . '.',
            ], $actionId, $auditEventId);
        }

        // 3b. Runtime entitlement check.
        if ($meta->requiredCapability !== '') {
            $entitlementDecision = $this->evaluateActionEntitlement($meta->requiredCapability);
            if (!in_array((string) ($entitlementDecision['outcome'] ?? ''), ['allowed', 'warn'], true)) {
                $this->writeAudit(
                    $auditEventId, $this->callerIntegrationId($caller), $actionId, $meta,
                    $body, $entitlementDecision, 'authz_denied', 'entitlement_denied', 403,
                    $this->elapsedMs($start), $clientIp, $userAgent, $entitlementDecision
                );
                return $this->envelope(403, false, [], [
                    'code' => 'entitlement_denied',
                    'message' => 'This feature is not available for the current license.',
                ], $actionId, $auditEventId);
            }
        }

        // 4. Compose input (path params overlay body).
        $input = array_merge($body, $this->normalizePathParams($pathParams, $meta->inputSchema));

        // 5. Schema validation.
        $validation = $this->validateInput($input, $meta->inputSchema);
        if ($validation !== null) {
            $this->writeAudit(
                $auditEventId, $this->callerIntegrationId($caller), $actionId, $meta,
                $input, null, 'error', 'input_invalid', 400,
                $this->elapsedMs($start), $clientIp, $userAgent, $entitlementDecision
            );
            return $this->envelope(400, false, [], ['code' => 'input_invalid', 'message' => $validation], $actionId, $auditEventId);
        }

        // 6. Execute the action.
        try {
            $result = $action->execute($input, $caller);
        } catch (Throwable $e) {
            $this->writeAudit(
                $auditEventId, $this->callerIntegrationId($caller), $actionId, $meta,
                $input, ['exception' => $e->getMessage()], 'error', 'uncaught_exception', 500,
                $this->elapsedMs($start), $clientIp, $userAgent, $entitlementDecision
            );
            return $this->envelope(500, false, [], ['code' => 'uncaught_exception', 'message' => $e->getMessage()], $actionId, $auditEventId);
        }

        if (!$result->ok) {
            $this->writeAudit(
                $auditEventId, $this->callerIntegrationId($caller), $actionId, $meta,
                $input, $result->error, 'error', (string) ($result->error['code'] ?? 'unknown'), 422,
                $this->elapsedMs($start), $clientIp, $userAgent, $entitlementDecision
            );
            return $this->envelope(422, false, [], $result->error, $actionId, $auditEventId);
        }

        $this->writeAudit(
            $auditEventId, $this->callerIntegrationId($caller), $actionId, $meta,
            $input, $result->result, 'ok', null, 200,
            $this->elapsedMs($start), $clientIp, $userAgent, $entitlementDecision
        );
        return $this->envelope(200, true, $result->result, null, $actionId, $auditEventId);
    }

    private function callerIntegrationId(?MyronActionCallerContext $caller): ?string
    {
        if ($caller === null) return null;
        if ($caller->callerType !== 'ai-bearer') return null;
        if ($caller->callerId === 'dev-token') return null;
        return $caller->callerId !== '' ? $caller->callerId : null;
    }

    /**
     * @param array<string, mixed>|null $input
     * @param array<string, mixed>|null $output
     */
    private function writeAudit(
        ?string $auditEventId,
        ?string $integrationId,
        string $actionId,
        MyronActionMetadata $meta,
        ?array $input,
        ?array $output,
        string $outcome,
        ?string $errorCode,
        int $httpStatus,
        int $latencyMs,
        string $clientIp,
        string $userAgent,
        ?array $entitlementDecision = null
    ): void {
        if ($this->auditLog === null || $auditEventId === null) {
            return;
        }
        $licenseContext = $this->licenseAuditContext($entitlementDecision);
        try {
            $this->auditLog->append(
                $auditEventId, $integrationId, $actionId,
                $meta->verb, $meta->domain, $meta->uri, $meta->method,
                $input, $output, $outcome, $errorCode, $httpStatus,
                $latencyMs, $clientIp, $userAgent,
                $licenseContext['plan'],
                $licenseContext['status'],
                $licenseContext['entitlementDecision']
            );
        } catch (Throwable $e) {
            // Audit failures must not break the request. Log + continue.
            error_log('[APO-197 audit] append failed: ' . $e->getMessage());
        }
    }

    /**
     * @return array{outcome:string, reason:string, capability:string, plan:string, status:string}
     */
    private function evaluateActionEntitlement(string $capability): array
    {
        if (!function_exists('myronEntitlementGate')) {
            return [
                'outcome' => 'blocked',
                'reason' => 'entitlement_gate_unavailable',
                'capability' => $capability,
                'plan' => '',
                'status' => '',
            ];
        }

        try {
            return myronEntitlementGate()->evaluate($capability);
        } catch (Throwable) {
            return [
                'outcome' => 'blocked',
                'reason' => 'entitlement_evaluation_failed',
                'capability' => $capability,
                'plan' => '',
                'status' => '',
            ];
        }
    }

    /**
     * @param array<string, mixed>|null $entitlementDecision
     * @return array{plan:string, status:string, entitlementDecision:string}
     */
    private function licenseAuditContext(?array $entitlementDecision): array
    {
        $plan = is_string($entitlementDecision['plan'] ?? null) ? $entitlementDecision['plan'] : '';
        $status = is_string($entitlementDecision['status'] ?? null) ? $entitlementDecision['status'] : '';
        $decision = is_string($entitlementDecision['outcome'] ?? null) ? $entitlementDecision['outcome'] : '';

        if (($plan === '' || $status === '') && function_exists('myronLicenseRepository')) {
            try {
                $snapshot = myronLicenseRepository()->currentSnapshot();
                $plan = $plan !== '' ? $plan : $snapshot->plan;
                $status = $status !== '' ? $status : $snapshot->status;
            } catch (Throwable) {
                // License context is best-effort audit enrichment.
            }
        }

        return [
            'plan' => $plan,
            'status' => $status,
            'entitlementDecision' => $decision,
        ];
    }

    private function elapsedMs(float $start): int
    {
        return (int) ((microtime(true) - $start) * 1000);
    }

    /**
     * @param array<string, mixed> $input
     * @param array<string, mixed> $schema
     */
    private function validateInput(array $input, array $schema): ?string
    {
        $type = (string) ($schema['type'] ?? 'object');
        if ($type !== 'object') {
            return null;
        }
        $required = is_array($schema['required'] ?? null) ? $schema['required'] : [];
        foreach ($required as $key) {
            $key = (string) $key;
            if (!array_key_exists($key, $input)) {
                return 'Missing required field: ' . $key . '.';
            }
        }
        $properties = is_array($schema['properties'] ?? null) ? $schema['properties'] : [];
        foreach ($input as $key => $value) {
            $propSchema = $properties[$key] ?? null;
            if (!is_array($propSchema)) continue;
            $expected = (string) ($propSchema['type'] ?? '');
            if ($expected !== '' && !$this->valueMatchesType($value, $expected)) {
                return 'Field "' . $key . '" expected type ' . $expected . '.';
            }
        }
        return null;
    }

    /**
     * @param array<string, string> $pathParams
     * @param array<string, mixed> $schema
     * @return array<string, mixed>
     */
    private function normalizePathParams(array $pathParams, array $schema): array
    {
        $properties = is_array($schema['properties'] ?? null) ? $schema['properties'] : [];
        $normalized = [];
        foreach ($pathParams as $key => $value) {
            $propSchema = $properties[$key] ?? null;
            $expected = is_array($propSchema) ? (string) ($propSchema['type'] ?? '') : '';
            if ($expected === 'integer' && preg_match('/^-?\d+$/', $value) === 1) {
                $normalized[$key] = (int) $value;
                continue;
            }
            $normalized[$key] = $value;
        }
        return $normalized;
    }

    private function valueMatchesType(mixed $value, string $expected): bool
    {
        return match ($expected) {
            'string'  => is_string($value),
            'integer' => is_int($value),
            'number'  => is_int($value) || is_float($value),
            'boolean' => is_bool($value),
            'array'   => is_array($value) && array_is_list($value),
            'object'  => is_array($value) && !array_is_list($value),
            'null'    => $value === null,
            default   => true,
        };
    }

    /**
     * @param array<string, mixed> $result
     * @param array{code:string,message:string}|null $error
     * @return array{status:int, envelope:array<string, mixed>}
     */
    private function envelope(int $status, bool $ok, array $result, ?array $error, string $actionId, ?string $auditEventId): array
    {
        return [
            'status'   => $status,
            'envelope' => [
                'ok'           => $ok,
                'result'       => $result,
                'error'        => $error,
                'actionId'     => $actionId,
                'auditEventId' => $auditEventId,
            ],
        ];
    }

    private function generateActionId(): string
    {
        return 'act_' . bin2hex(random_bytes(8));
    }
}
