<?php

declare(strict_types=1);

/**
 * APO-186 Card 2 — Action API substrate primitive: contract types.
 *
 * Defines the canonical contract every API action must implement, plus the
 * value objects the dispatcher uses to thread input + caller context through
 * to the action and to wrap its result in the canonical response envelope.
 *
 * Per the Card 1 architecture log:
 *   - Each action declares URI, method, schema, required scope, side effects,
 *     audience class via MyronActionMetadata.
 *   - The dispatcher validates auth + scope + input schema before invoking
 *     execute(). The action returns MyronActionResult; the dispatcher wraps
 *     it into the canonical envelope.
 *   - MyronActionCallerContext is the resolved caller identity (human session
 *     or AI bearer token) plus the held scopes — handed to the action so the
 *     business logic can attribute the operation correctly.
 */

/**
 * Canonical metadata for one API action.
 *
 * Every action declares its full contract here. The dispatcher uses this to
 * route requests, validate input, gate by scope. The manifest endpoint
 * (Card 5) emits this verbatim into the action catalog.
 */
final class MyronActionMetadata
{
    public readonly string $requiredCapability;

    /**
     * @param array<string, mixed> $inputSchema   JSON-schema-shaped definition of accepted request body
     * @param array<string, mixed> $outputSchema  JSON-schema-shaped definition of result payload
     * @param list<string>         $sideEffects   declared side effects (e.g. "writes footer_designs.is_default")
     * @param list<string>         $preconditions human-readable preconditions
     * @param list<string>         $postconditions human-readable postconditions
     * @param list<string>         $adminAliases  legacy admin POST $action verbs this action satisfies.
     *                                            APO-186 admin POST handlers historically use suffixed verbs
     *                                            (e.g. `set-default-footer-design`) while canonical action
     *                                            metadata uses unsuffixed verbs (`set-default`); the registry
     *                                            audit (Card 7) uses this list to mark a discovered admin
     *                                            verb as "covered" by a registered action.
     * @param bool                 $snapshotInclude  APO-193 extension. When true, the action is invoked by
     *                                               the snapshot composer to compose part of the system state
     *                                               document. Only meaningful for read actions; mutations may
     *                                               declare it but the composer skips non-GET actions.
     * @param string|null          $snapshotKey      APO-193 extension. The JSON path under which the action's
     *                                               result slots into the composed snapshot (e.g. 'design.footers').
     * @param bool                 $exposeToMcp      APO-193 extension. When false, the action exists in the
     *                                               registry (drift guard active) but is excluded from MCP
     *                                               tools/list. Default true (opt-out).
     * @param bool                 $supportsProgress APO-193 extension. When true, the action may emit
     *                                               notifications/progress messages over the MCP SSE stream
     *                                               during long-running execution. Default false.
     * @param array<string, mixed> $liveContentResolution APO-193 extension. Per-component resolver behaviour
     *                                                    descriptor; surfaced in the compatibility document.
     * @param string               $requiredCapability Runtime entitlement capability required before execute().
     *                                                  Empty means the action has no tier gate beyond scope/auth.
     * @param list<string>         $mcpPreferInstead Preferred governed surfaces when mcpAltitude is set.
     */
    public function __construct(
        public readonly string  $uri,
        public readonly string  $method,
        public readonly string  $domain,
        public readonly string  $resource,
        public readonly string  $verb,
        public readonly array   $inputSchema,
        public readonly array   $outputSchema,
        public readonly ?string $requiredScope,
        public readonly string  $summary,
        public readonly array   $sideEffects = [],
        public readonly array   $preconditions = [],
        public readonly array   $postconditions = [],
        public readonly int     $audienceClass = 2,
        public readonly array   $adminAliases = [],
        public readonly bool    $snapshotInclude = false,
        public readonly ?string $snapshotKey = null,
        public readonly bool    $exposeToMcp = true,
        public readonly bool    $supportsProgress = false,
        public readonly array   $liveContentResolution = [],
        string                  $requiredCapability = '',
        public readonly ?string $mcpAltitude = null,
        public readonly array   $mcpPreferInstead = [],
    ) {
        $this->requiredCapability = $requiredCapability !== ''
            ? $requiredCapability
            : self::inferRequiredCapability($uri, $domain, $resource, $verb);
    }

    /**
     * Emit the metadata as a plain associative array suitable for the manifest
     * catalog (Card 5) and the canonical envelope.
     *
     * @return array<string, mixed>
     */
    public function toArray(): array
    {
        return [
            'uri'                   => $this->uri,
            'method'                => $this->method,
            'domain'                => $this->domain,
            'resource'              => $this->resource,
            'verb'                  => $this->verb,
            'inputSchema'           => $this->inputSchema,
            'outputSchema'          => $this->outputSchema,
            'requiredScope'         => $this->requiredScope,
            'summary'               => $this->summary,
            'sideEffects'           => $this->sideEffects,
            'preconditions'         => $this->preconditions,
            'postconditions'        => $this->postconditions,
            'audienceClass'         => $this->audienceClass,
            'adminAliases'          => $this->adminAliases,
            'snapshotInclude'       => $this->snapshotInclude,
            'snapshotKey'           => $this->snapshotKey,
            'exposeToMcp'           => $this->exposeToMcp,
            'supportsProgress'      => $this->supportsProgress,
            'liveContentResolution' => $this->liveContentResolution,
            'requiredCapability'    => $this->requiredCapability,
            'mcpAltitude'           => $this->mcpAltitude,
            'mcpPreferInstead'      => $this->mcpPreferInstead,
        ];
    }

    private static function inferRequiredCapability(string $uri, string $domain, string $resource, string $verb): string
    {
        $normalized = strtolower($uri . ' ' . $domain . ' ' . $resource . ' ' . $verb);

        if (str_contains($normalized, 'byok') || str_contains($normalized, 'llm-provider') || str_contains($normalized, 'ai-provider')) {
            return 'mcp.byok_ai_actions';
        }
        if ($domain === 'analytics') {
            return 'analytics.native';
        }
        if ($domain === 'administration' && str_starts_with($resource, 'mail')) {
            return 'mail.multiple_configs';
        }
        if ($domain === 'administration.mailTransports' || $resource === 'mail-transport') {
            return 'mail.multiple_configs';
        }
        if ($domain === 'administration' && str_starts_with($resource, 'application-update')) {
            return 'updates.security';
        }
        if ($domain === 'administration' && $resource === 'search-index') {
            return 'search.public';
        }
        if ($domain === 'content' && $resource === 'search') {
            return 'search.public';
        }
        if ($domain === 'design' && $resource === 'document-template') {
            return 'templates.document';
        }
        if ($domain === 'design' && $resource === 'section-variant') {
            return 'templates.section_variants';
        }
        if ($domain === 'design' && $resource === 'section-template') {
            return 'templates.custom_section';
        }
        if (str_starts_with($domain, 'devops') || str_contains($normalized, 'environment-pair') || str_contains($normalized, 'deployment-token')) {
            return 'devops.environments';
        }
        if (str_contains($normalized, 'lms') || str_contains($normalized, 'course') || str_contains($normalized, 'lesson') || str_contains($normalized, 'enrollment')) {
            return 'site_type.edu';
        }
        if (str_contains($normalized, 'funnel') || str_contains($normalized, 'quiz') || str_contains($normalized, 'vcard') || str_contains($normalized, 'personal-page') || str_contains($normalized, 'personal_page')) {
            return 'site_type.mkt';
        }
        if (str_contains($normalized, 'commerce') || str_contains($normalized, 'checkout') || str_contains($normalized, 'payment-provider')) {
            return 'commerce.full';
        }

        return '';
    }
}

/**
 * Resolved caller identity threaded through every action invocation.
 *
 * callerType:
 *   - 'human-session' — authenticated browser-session user (cookie auth)
 *   - 'ai-bearer'     — AI integration bearer token (Authorization: Bearer …)
 *   - 'system'        — internal invocation (e.g. seed scripts, migrations)
 *
 * heldScopes is the set of scope strings the caller has been granted. The
 * dispatcher checks the action's requiredScope against this set BEFORE
 * invoking execute(); business logic can also inspect for sub-scope reasoning.
 */
final class MyronActionCallerContext
{
    /**
     * @param list<string> $heldScopes
     */
    public function __construct(
        public readonly string $callerType,
        public readonly string $callerId,
        public readonly array  $heldScopes,
    ) {
    }

    public function holdsScope(string $scope): bool
    {
        return MyronApiScopes::covers($this->heldScopes, $scope);
    }
}

/**
 * The result an action returns from execute(). The dispatcher wraps this in
 * the canonical envelope before responding to the client.
 *
 * Use ::ok() for success; ::error() for declared failures (precondition,
 * validation). Uncaught exceptions are translated to 500 envelopes by the
 * dispatcher — actions should prefer ::error() for known-failure paths.
 */
final class MyronActionResult
{
    /**
     * @param array<string, mixed> $result
     * @param array{code: string, message: string}|null $error
     */
    private function __construct(
        public readonly bool   $ok,
        public readonly array  $result,
        public readonly ?array $error,
    ) {
    }

    /**
     * @param array<string, mixed> $result
     */
    public static function ok(array $result = []): self
    {
        return new self(true, $result, null);
    }

    public static function error(string $code, string $message): self
    {
        return new self(false, [], ['code' => $code, 'message' => $message]);
    }
}

/**
 * Canonical contract every API action implements.
 *
 * Two methods, no inheritance gymnastics:
 *   - metadata() returns the declarative contract; called by the dispatcher
 *     for routing + validation, and by the manifest endpoint for catalog
 *     emission.
 *   - execute() runs the business logic. Receives the validated input and
 *     resolved caller context; returns a MyronActionResult.
 */
interface MyronAction
{
    public function metadata(): MyronActionMetadata;

    /**
     * @param array<string, mixed> $input
     */
    public function execute(array $input, MyronActionCallerContext $caller): MyronActionResult;
}
