<?php

/**
 * Schema Aggregation REST Controller
 *
 * Exposes a site-wide JSON-LD @graph of every indexable schema entity:
 *
 *   GET /wp-json/prorank-seo/v1/schema-map?page=1&per_page=100
 *
 * Response shape:
 *   {
 *     "success": true,
 *     "data": {
 *       "@context": "https://schema.org",
 *       "@graph": [ ...deduped entities... ],
 *       "meta": {
 *         "generated_at": "2026-04-19T12:34:56+00:00",
 *         "page": 1,
 *         "per_page": 100,
 *         "total_entities": 412,
 *         "has_more": true,
 *         "cached": false,
 *         "cache_version": 3
 *       }
 *     }
 *   }
 *
 * Phase 1 scope: cached, paginated, deduped. No NLWeb format yet, no admin
 * toggle, no auth gate beyond what WordPress does by default for public GETs.
 *
 * @package ProRank\SEO\Core\RestApi
 * @since   2.2.0
 */

declare(strict_types=1);

namespace ProRank\SEO\Core\RestApi;

use ProRank\SEO\Core\Schema\EntityDeduplicator;
use ProRank\SEO\Core\Schema\SchemaMapCache;
use ProRank\SEO\Core\Schema\SchemaMapGate;
use ProRank\SEO\Core\Schema\SiteSchemaCollector;
use WP_REST_Request;

if ( ! defined( 'ABSPATH' ) ) {
    exit;
}


class SchemaAggregationController extends BaseController {

    /**
     * Default items per page.
     */
    private const DEFAULT_PER_PAGE = 100;

    /**
     * Hard cap on items per page to keep payloads bounded.
     */
    private const MAX_PER_PAGE = 200;

    /**
     * Hard cap on response size (bytes) before we bail and return an error.
     */
    private const MAX_RESPONSE_BYTES = 2 * 1024 * 1024;

    /**
     * Constructor.
     */
    public function __construct() {
        parent::__construct();
        $this->rest_base = 'schema-map';
    }

    /**
     * Register REST routes.
     *
     * @return void
     */
    public function register_routes(): void {
        register_rest_route(
            $this->namespace,
            '/' . $this->rest_base,
            [
                [
                    'methods'             => \WP_REST_Server::READABLE,
                    'callback'            => [$this, 'get_schema_map'],
                    'permission_callback' => '__return_true',
                    'args'                => [
                        'page' => [
                            'description' => __('Page number (1-indexed).', 'prorank-seo'),
                            'type'        => 'integer',
                            'required'    => false,
                            'default'     => 1,
                            'minimum'     => 1,
                        ],
                        'per_page' => [
                            'description' => __('Posts per page.', 'prorank-seo'),
                            'type'        => 'integer',
                            'required'    => false,
                            'default'     => self::DEFAULT_PER_PAGE,
                            'minimum'     => 1,
                            'maximum'     => self::MAX_PER_PAGE,
                        ],
                    ],
                ],
            ]
        );
    }

    /**
     * GET /schema-map handler.
     *
     * @param WP_REST_Request $request Request object.
     * @return \WP_REST_Response|\WP_Error
     */
    public function get_schema_map(WP_REST_Request $request) {
        return $this->try_callback(function () use ($request) {
            // Respect the enabled gate. SchemaMapGate layers three checks —
            // schema module active, operator toggle option, third-party
            // filter override — so the endpoint stops serving when ANY of
            // those says no. Crucially it's evaluated here, not inside the
            // module's init_hooks(): a disabled module can't register hooks,
            // but the gate must still block the endpoint.
            if (!SchemaMapGate::is_enabled()) {
                return $this->get_error_response(
                    __('Schema aggregation is disabled on this site.', 'prorank-seo'),
                    404,
                    'schema_map_disabled'
                );
            }

            $page = max(1, (int) $request->get_param('page'));
            $per_page = (int) $request->get_param('per_page');
            if ($per_page <= 0) {
                $per_page = self::DEFAULT_PER_PAGE;
            }
            $per_page = max(1, min(self::MAX_PER_PAGE, $per_page));

            $cache = new SchemaMapCache();
            $cached = $cache->get($page, $per_page);
            if (is_array($cached)) {
                if (isset($cached['meta']) && is_array($cached['meta'])) {
                    $cached['meta']['cached'] = true;
                }
                return $this->get_success_response($cached);
            }

            $collector = new SiteSchemaCollector();
            $collection = $collector->collect($page, $per_page);

            $deduper = new EntityDeduplicator();
            $graph = $deduper->dedupe($collection['entities'] ?? []);

            $total_posts = (int) ($collection['total_posts'] ?? 0);
            $total_pages = $total_posts > 0 ? (int) ceil($total_posts / $per_page) : 1;
            $has_more = $page < $total_pages;

            // Entity counts — be explicit about what each number represents so
            // paginated consumers aren't confused by a single `total_entities`
            // field drifting per page.
            //
            // entities_on_page: the post-dedupe count shipped in THIS response.
            // total_entities_estimate: a site-wide approximation from the
            //   collector (total_posts * 2 + site-level nodes). It is an
            //   ESTIMATE because exact site-wide dedupe is too expensive to
            //   compute per request — the real deduped total can only be
            //   derived by walking every page.
            $entities_on_page = count($graph);
            $total_entities_estimate = isset($collection['total_entities'])
                ? (int) $collection['total_entities']
                : $entities_on_page;

            $payload = [
                '@context' => 'https://schema.org',
                '@graph' => $graph,
                'meta' => [
                    'generated_at' => gmdate('c'),
                    'page' => $page,
                    'per_page' => $per_page,
                    'entities_on_page' => $entities_on_page,
                    'total_entities_estimate' => $total_entities_estimate,
                    'total_posts' => $total_posts,
                    'total_pages' => $total_pages,
                    'has_more' => $has_more,
                    'cached' => false,
                    'cache_version' => $cache->version(),
                ],
            ];

            // Guard against runaway payloads. If we'd be shipping more than MAX_RESPONSE_BYTES,
            // do not cache it and return a helpful 413-style error. Caller should lower per_page.
            $encoded = wp_json_encode($payload, JSON_UNESCAPED_SLASHES);
            $size = is_string($encoded) ? strlen($encoded) : 0;
            if ($size > self::MAX_RESPONSE_BYTES) {
                return $this->get_error_response(
                    sprintf(
                        /* translators: %1$d is payload size in bytes, %2$d is the limit in bytes. */
                        __('Schema map payload too large (%1$d bytes, max %2$d). Reduce per_page or paginate.', 'prorank-seo'),
                        $size,
                        self::MAX_RESPONSE_BYTES
                    ),
                    413,
                    'payload_too_large'
                );
            }

            $cache->set($page, $per_page, $payload);

            return $this->get_success_response($payload);
        });
    }
}
