<?php

/**
 * Ask REST Controller
 *
 * NLWeb-compatible `/ask` endpoint in list mode.
 *
 *   POST /wp-json/prorank-seo/v1/ask
 *
 * Accepts the NLWeb protocol parameters (only `query` is required) and
 * returns a spec-shaped envelope:
 *
 *   {
 *     "query_id": "...",
 *     "results": [
 *       {
 *         "url": "...",
 *         "name": "...",
 *         "site": "...",
 *         "score": 0.91,
 *         "description": "...",
 *         "schema_object": { ... }
 *       }
 *     ]
 *   }
 *
 * Scope:
 *   - `mode=list` — public under the shared SchemaMapGate.
 *   - `mode=summarize` — BYOK via the existing AI Tools provider config.
 *     Requires an authenticated WordPress user (application password)
 *     holding the `edit_posts` capability, so a bare subscriber/customer
 *     account can't burn the owner's provider credits. Advertised in the
 *     route `mode` enum only when the premium LLM helper is available on
 *     the install (free does not ship it).
 *   - `mode=generate` — same auth + tier policy as summarize. Returns a
 *     grounded RAG-style `answer` plus a `citations` array projected
 *     from the ranked results, alongside the normal result envelope.
 *   - `streaming=true` is silently served as a single JSON body;
 *     `streaming=false` matches expectation exactly. Spec allows either.
 *   - `prev` and `decontextualized_query` are accepted but ignored (no
 *     conversation context in this release).
 *   - Gate is shared with /schema-map via SchemaMapGate. Module off → 404.
 *
 * Implementation note: all pipeline logic (gate check, candidate gathering,
 * premium filter enrichment, dedupe, ranker, result projection) lives in
 * AskQueryService so /mcp can call the same backend without forking. This
 * controller's only job is to map the raw REST request onto the service and
 * project the result onto the NLWeb wire envelope.
 *
 * @package ProRank\SEO\Core\RestApi
 * @since   2.3.0
 */

declare(strict_types=1);

namespace ProRank\SEO\Core\RestApi;

use ProRank\SEO\Core\Schema\AskQueryException;
use ProRank\SEO\Core\Schema\AskQueryService;
use ProRank\SEO\Core\Schema\PostSearchEngine;
use WP_REST_Request;
use WP_REST_Response;

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


class AskController extends BaseController {

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

    /**
     * Register routes.
     *
     * The `mode` enum is computed dynamically: `summarize` is only
     * advertised when the premium LLM helper (`AI\AskSummarizer`) is
     * present on the install. On free that class is absent, so free
     * sites never publicly claim summarize support and WP's REST arg
     * validator rejects `mode=summarize` with a clean 400 long before
     * the request reaches the service.
     *
     * @return void
     */
    public function register_routes(): void {
        $supported_modes = $this->supported_modes();
        register_rest_route(
            $this->namespace,
            '/' . $this->rest_base,
            [
                [
                    'methods'             => \WP_REST_Server::CREATABLE,
                    'callback'            => [ $this, 'handle_ask' ],
                    'permission_callback' => '__return_true',
                    'args'                => [
                        'query' => [
                            'description' => __( 'Natural-language query.', 'prorank-seo' ),
                            'type'        => 'string',
                            'required'    => true,
                        ],
                        'mode' => [
                            'description' => count( $supported_modes ) > 1
                                ? __( 'Query mode. "list" is public; "summarize" and "generate" are BYOK and require a WordPress application password with edit_posts capability.', 'prorank-seo' )
                                : __( 'Query mode. Only "list" is available on this install.', 'prorank-seo' ),
                            'type'        => 'string',
                            'required'    => false,
                            'enum'        => $supported_modes,
                            'default'     => 'list',
                        ],
                        'limit' => [
                            'description' => __( 'Maximum results.', 'prorank-seo' ),
                            'type'        => 'integer',
                            'required'    => false,
                            'minimum'     => 1,
                            'maximum'     => PostSearchEngine::MAX_LIMIT,
                            'default'     => AskQueryService::DEFAULT_LIMIT,
                        ],
                        'query_id' => [
                            'description' => __( 'Optional caller-supplied query ID.', 'prorank-seo' ),
                            'type'        => 'string',
                            'required'    => false,
                        ],
                        'site' => [
                            'description' => __( 'Optional backend subset token. Ignored on single-site WordPress installs.', 'prorank-seo' ),
                            'type'        => 'string',
                            'required'    => false,
                        ],
                        'prev' => [
                            'description' => __( 'Previous queries in the conversation. Ignored in list mode.', 'prorank-seo' ),
                            'type'        => 'string',
                            'required'    => false,
                        ],
                        'decontextualized_query' => [
                            'description' => __( 'Pre-decontextualized query. Ignored in list mode.', 'prorank-seo' ),
                            'type'        => 'string',
                            'required'    => false,
                        ],
                        'streaming' => [
                            'description' => __( 'Streaming support. This implementation always returns a single JSON body.', 'prorank-seo' ),
                            'type'        => 'boolean',
                            'required'    => false,
                        ],
                    ],
                ],
            ]
        );
    }

    /**
     * POST /ask handler.
     *
     * Unlike the admin REST endpoints in this plugin, `/ask` returns a RAW
     * top-level JSON object — no `{success, data: ...}` wrapper. That's
     * what the NLWeb REST contract calls for: the response body IS the
     * NLWeb envelope. BaseController's wrapping helpers are deliberately
     * bypassed here so we stay wire-compatible.
     *
     * @param WP_REST_Request $request Request.
     * @return WP_REST_Response
     */
    public function handle_ask( WP_REST_Request $request ) {
        try {
            $raw_query = (string) $request->get_param( 'query' );
            $mode      = (string) ( $request->get_param( 'mode' ) ?? 'list' );
            $limit     = (int) $request->get_param( 'limit' );
            $query_id  = (string) ( $request->get_param( 'query_id' ) ?? '' );

            // WP resolves Basic Auth + application passwords during the
            // request lifecycle, so is_user_logged_in() is true for both
            // cookie sessions and app-password callers. For LLM-backed
            // modes we additionally require a content-edit capability so a
            // subscriber / customer / member account on a public site
            // cannot burn the owner's BYOK provider credits.
            $is_authenticated = (
                function_exists( 'is_user_logged_in' ) && is_user_logged_in()
                && function_exists( 'current_user_can' ) && current_user_can( 'edit_posts' )
            );

            $service = new AskQueryService();
            $result  = $service->execute(
                $raw_query,
                $mode,
                $limit,
                $query_id === '' ? null : $query_id,
                $is_authenticated
            );

            // Raw NLWeb envelope — query_id + results at the top level.
            // Additional fields (query, mode, site, total) are informational
            // and don't conflict with the spec; the spec says responses
            // "may contain zero or more result attributes" alongside
            // query_id and results.
            return new WP_REST_Response(
                array_merge( $result, [ 'total' => count( $result['results'] ) ] ),
                200
            );
        } catch ( AskQueryException $e ) {
            return $this->ask_error(
                $e->get_error_code(),
                $e->getMessage(),
                $e->get_http_status(),
                $e->get_extra()
            );
        } catch ( \Throwable $e ) {
            prorank_log( '[ProRank SEO] /ask error: ' . $e->getMessage() );
            return $this->ask_error(
                'internal_error',
                __( 'Internal server error.', 'prorank-seo' ),
                500
            );
        }
    }

    /**
     * Build an error response with a raw top-level NLWeb-friendly body.
     *
     * The spec is silent on error shape; we use `{code, message, ...extra}`
     * at the top level with the appropriate HTTP status so callers can
     * surface meaningful errors without having to unwrap a proprietary
     * envelope.
     *
     * @param string $code    Machine code, e.g. `unsupported_mode`.
     * @param string $message Human-readable message.
     * @param int    $status  HTTP status code.
     * @param array  $extra   Additional top-level fields to merge in.
     * @return WP_REST_Response
     */
    private function ask_error( string $code, string $message, int $status, array $extra = [] ): WP_REST_Response {
        $body = array_merge( [
            'code'    => $code,
            'message' => $message,
        ], $extra );
        return new WP_REST_Response( $body, $status );
    }

    /**
     * Modes advertised to callers. `summarize` is only listed when the
     * premium LLM helper is actually available on the install — otherwise
     * the route silently omits it so free sites never claim support they
     * can't fulfil.
     *
     * @return string[]
     */
    private function supported_modes(): array {
        $modes = [ 'list' ];
        if ( class_exists( '\\ProRank\\SEO\\AI\\AskSummarizer' ) ) {
            $modes[] = 'summarize';
        }
        if ( class_exists( '\\ProRank\\SEO\\AI\\AskGenerator' ) ) {
            $modes[] = 'generate';
        }
        return $modes;
    }
}
