<?php

declare(strict_types=1);

/**
 * APO-193 Card 2 — System Snapshot composer.
 *
 * The substrate primitive that lets AI clients (and the compatibility-document
 * endpoints) understand the LIVE state of the running install. Composes a
 * canonical state document by invoking every registered MyronAction with
 * snapshotInclude:true and slotting each action's result under its declared
 * snapshotKey JSON path.
 *
 * The composer is read-only. It never mutates. It runs synchronously per
 * request — fresh state, no caching. Each invocation uses an internal
 * MyronActionCallerContext with callerType 'system' so the snapshot composer
 * can read across scopes regardless of the requesting caller's held scopes.
 * (The READER of the composed snapshot, however, is still scope-gated via
 * the wrapping action.)
 *
 * Cross-reference: full-parity-mcp-2026-05-10.md §12 (compatibility documents)
 * and §2 (read action conventions).
 */
final class MyronSystemSnapshotService
{
    public function __construct(
        private readonly MyronActionRegistry $registry,
    ) {
    }

    /**
     * Compose the live install snapshot.
     *
     * @param array<string, mixed> $callerScopes Scopes held by the wrapping
     *                                           caller; entries whose required
     *                                           scope is not covered are
     *                                           skipped (defense-in-depth even
     *                                           though the system caller can
     *                                           read across scopes — keeps the
     *                                           composed document scope-safe).
     * @return array<string, mixed>
     */
    public function compose(array $callerScopes = []): array
    {
        $snapshot = [
            'version'     => '1',
            'generatedAt' => date('Y-m-d\TH:i:s\Z'),
            'install'     => [
                'siteName' => $this->resolveSiteName(),
            ],
            'manifest'    => $this->composeManifestSummary(),
            'mcpWorkflows' => [
                'blogPostAuthoring' => [
                    'resourceUri' => 'mcp://myroncms/content/blog-post-authoring',
                    'tools' => [
                        'content_page_create',
                        'content_page_update',
                        'content_page_body_set_markdown',
                        'content_page_categories_set',
                        'content_page_author_set',
                        'content_page_publish',
                    ],
                ],
            ],
        ];

        $systemCaller = new MyronActionCallerContext(
            callerType: 'system',
            callerId:   'snapshot-composer',
            heldScopes: MyronApiScopes::ALL_BASE,
        );

        foreach ($this->registry->all() as $action) {
            $meta = $action->metadata();
            if (!$meta->snapshotInclude) {
                continue;
            }
            if (strtoupper($meta->method) !== 'GET') {
                continue; // composer only invokes reads
            }
            if ($meta->snapshotKey === null || $meta->snapshotKey === '') {
                continue; // ill-declared — skip rather than crash the composer
            }

            try {
                // Provide an empty input map; reads that require path params
                // (e.g. show-by-id) cannot be composed without a target id and
                // should NOT declare snapshotInclude:true. Snapshot-composed
                // reads are list/index endpoints.
                $result = $action->execute([], $systemCaller);
                if (!$result->ok) {
                    $this->slotInto($snapshot, $meta->snapshotKey, [
                        'error' => $result->error,
                    ]);
                    continue;
                }
                $this->slotInto($snapshot, $meta->snapshotKey, $result->result);
            } catch (Throwable $e) {
                // Defensive: a single read failure must not crash the composer.
                $this->slotInto($snapshot, $meta->snapshotKey, [
                    'error' => [
                        'code'    => 'snapshot_read_failed',
                        'message' => $e->getMessage(),
                    ],
                ]);
            }
        }

        return $snapshot;
    }

    /**
     * Slot a sub-document into the composed snapshot at a dotted key path.
     *
     * @param array<string, mixed> $target  passed by reference
     * @param array<string, mixed> $value
     */
    private function slotInto(array &$target, string $key, array $value): void
    {
        $segments = array_filter(explode('.', $key), static fn ($s) => $s !== '');
        if ($segments === []) {
            return;
        }
        $cursor = &$target;
        foreach ($segments as $segment) {
            if (!isset($cursor[$segment]) || !is_array($cursor[$segment])) {
                $cursor[$segment] = [];
            }
            $cursor = &$cursor[$segment];
        }
        $cursor = $value;
    }

    /**
     * Summarise the registered action catalog for inclusion in the snapshot's
     * compatibility document. Returns just URIs + summaries + exposeToMcp flags
     * — the full manifest is available via GET /api/v1/manifest.
     *
     * @return array<string, mixed>
     */
    private function composeManifestSummary(): array
    {
        $entries = [];
        $exposedCount = 0;
        foreach ($this->registry->all() as $action) {
            $meta = $action->metadata();
            $entries[] = [
                'uri'           => $meta->uri,
                'method'        => $meta->method,
                'domain'        => $meta->domain,
                'requiredScope' => $meta->requiredScope,
                'exposeToMcp'   => $meta->exposeToMcp,
            ];
            if ($meta->exposeToMcp) {
                $exposedCount += 1;
            }
        }
        // Deterministic ordering: alphabetical by URI.
        usort($entries, static fn (array $a, array $b): int => strcmp((string) $a['uri'], (string) $b['uri']));
        return [
            'totalActions'       => count($entries),
            'mcpExposedActions'  => $exposedCount,
            'entries'            => $entries,
        ];
    }

    /**
     * Best-effort site name resolution. Defers to the canonical
     * myronSiteSettingsGet() when present; otherwise returns null.
     */
    private function resolveSiteName(): ?string
    {
        if (function_exists('myronSiteSettingsGet')) {
            $settings = myronSiteSettingsGet();
            if (is_array($settings) && isset($settings['site_name'])) {
                return (string) $settings['site_name'];
            }
        }
        return null;
    }
}
