<?php

declare(strict_types=1);

/**
 * APO-186 Card 2 — Action API substrate primitive: registry.
 *
 * Holds the list of all registered MyronAction instances. The dispatcher
 * looks up actions by URI pattern + HTTP method; the manifest endpoint
 * (Card 5) reads the catalog enumeration; the registry audit (Card 7)
 * cross-references admin-route POST verbs against this registry.
 *
 * URI patterns may include path placeholders in the form `{name}`. The
 * dispatcher matches an incoming request URI against each registered
 * action's pattern; if the pattern contains placeholders, the matched
 * values become part of the action's input. E.g. the pattern
 * `/api/v1/design/footers/{variantId}/default` matches the request URI
 * `/api/v1/design/footers/footer.editorial/default` with
 * `variantId = "footer.editorial"`.
 */
final class MyronActionRegistry
{
    /** @var list<MyronAction> */
    private array $actions = [];

    public function register(MyronAction $action): void
    {
        $this->actions[] = $action;
    }

    public function registerExtension(string $vendor, MyronAction $action): void
    {
        if (!preg_match('/^[a-z][a-z0-9_]*$/', $vendor)) {
            throw new InvalidArgumentException('Extension vendor must be snake_case.');
        }

        $meta = $action->metadata();
        $expectedUriPrefix = '/api/v1/x/' . $vendor . '/';
        if (!str_starts_with($meta->uri, $expectedUriPrefix)) {
            throw new InvalidArgumentException('Extension action URI must start with ' . $expectedUriPrefix . '.');
        }

        $expectedDomainPrefix = 'x.' . $vendor . '.';
        if (!str_starts_with($meta->domain, $expectedDomainPrefix)) {
            throw new InvalidArgumentException('Extension action domain must start with ' . $expectedDomainPrefix . '.');
        }

        if ($meta->inputSchema === []) {
            throw new InvalidArgumentException('Extension action inputSchema must be non-empty.');
        }
        if ($meta->outputSchema === []) {
            throw new InvalidArgumentException('Extension action outputSchema must be non-empty.');
        }

        $this->register($action);
    }

    /**
     * @return list<MyronAction>
     */
    public function all(): array
    {
        return $this->actions;
    }

    /**
     * Resolve an incoming request URI + method to an action plus the path
     * placeholder values. Returns null when no registered action matches.
     *
     * @return array{action: MyronAction, pathParams: array<string, string>}|null
     */
    public function resolve(string $method, string $uri): ?array
    {
        $method = strtoupper(trim($method));
        $uri    = $this->stripQueryString(trim($uri));

        foreach ($this->actions as $action) {
            $meta = $action->metadata();
            if (strtoupper($meta->method) !== $method) {
                continue;
            }
            $params = $this->matchPattern($meta->uri, $uri);
            if ($params !== null) {
                return ['action' => $action, 'pathParams' => $params];
            }
        }

        return null;
    }

    /**
     * Match a URI against a pattern containing `{name}` placeholders. Returns
     * a map of placeholder → matched value on success; null on no match.
     *
     * @return array<string, string>|null
     */
    private function matchPattern(string $pattern, string $uri): ?array
    {
        $patternRegex = preg_replace_callback(
            '/\\{([a-zA-Z_][a-zA-Z0-9_]*)\\}/',
            static fn (array $m): string => '(?P<' . $m[1] . '>[^/]+)',
            preg_quote($pattern, '#')
        );
        // preg_quote on the pattern escapes our literal `{`, `}` — the callback
        // works because preg_replace_callback runs BEFORE preg_quote. Reverse:
        // run preg_quote first, then translate the escaped placeholders. Safer
        // approach: build the regex without preg_quote (the segments between
        // placeholders are literal-safe paths and don't contain regex metachars
        // in our URI conventions).
        $patternRegex = $this->compilePattern($pattern);
        if (preg_match($patternRegex, $uri, $matches) !== 1) {
            return null;
        }
        $params = [];
        foreach ($matches as $key => $value) {
            if (is_string($key)) {
                $params[$key] = rawurldecode((string) $value);
            }
        }
        return $params;
    }

    private function compilePattern(string $pattern): string
    {
        // Split on placeholders; quote literal segments; substitute named groups.
        $regex = preg_replace_callback(
            '/\\{([a-zA-Z_][a-zA-Z0-9_]*)\\}|([^{}]+)/',
            static function (array $m): string {
                if (isset($m[1]) && $m[1] !== '') {
                    return '(?P<' . $m[1] . '>[^/]+)';
                }
                return preg_quote($m[2], '#');
            },
            $pattern
        );
        return '#^' . $regex . '$#';
    }

    private function stripQueryString(string $uri): string
    {
        $idx = strpos($uri, '?');
        return $idx === false ? $uri : substr($uri, 0, $idx);
    }
}
