<?php

/**
 * Ask Query Service
 *
 * Single source of truth for the NLWeb-compatible search pipeline. Both
 * AskController (POST /ask) and McpController (POST /mcp via tools/call
 * "ask") delegate here so the candidate gathering, premium-filter
 * enrichment, dedupe, ranking, result projection, and auth policy stay
 * identical across endpoints.
 *
 * The service does NOT format wire bodies — controllers project the returned
 * structured array onto their respective protocols (raw NLWeb envelope vs
 * MCP tools/call content blocks). On validation failures the service throws
 * AskQueryException; controllers map that to their error wire format.
 *
 * Scope:
 *   - `mode=list` — public under SchemaMapGate.
 *   - `mode=summarize` — BYOK via premium AI\AskSummarizer. Requires the
 *     caller to be authenticated AND hold the `edit_posts` capability
 *     (controllers pre-compute the flag). Falls back to `tier_required`
 *     403 on installs where AskSummarizer isn't available (defence in
 *     depth — controllers already filter summarize out of their public
 *     advertising when the helper is absent).
 *   - `mode=generate` — BYOK via premium AI\AskGenerator. Same auth and
 *     tier gate as summarize. Returns the list envelope plus `answer`
 *     and `citations` so callers can surface a grounded RAG-style answer
 *     alongside the underlying results.
 *   - Shared SchemaMapGate with /schema-map. Module off → throws
 *     `schema_map_disabled` with HTTP 404.
 *
 * @package ProRank\SEO\Core\Schema
 * @since   2.4.0
 */

declare(strict_types=1);

namespace ProRank\SEO\Core\Schema;

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


class AskQueryService {

    /**
     * Default result count if caller doesn't specify a limit.
     */
    public const DEFAULT_LIMIT = 10;

    /**
     * Hard cap on query length. Anything longer is almost certainly abuse
     * or misuse — NLWeb queries are one-liners.
     */
    public const MAX_QUERY_LENGTH = 500;

    /**
     * Modes the service supports. `list` is public; `summarize` and
     * `generate` are BYOK and require an authenticated WordPress user
     * (WP app-password or session) holding edit_posts.
     */
    public const SUPPORTED_MODES = [ 'list', 'summarize', 'generate' ];

    /**
     * Modes that require an authenticated caller. The controller passes
     * the authentication flag; the service enforces the policy here so
     * /ask and /mcp share the same gate.
     */
    public const AUTH_REQUIRED_MODES = [ 'summarize', 'generate' ];

    /**
     * Execute a query and return the result envelope.
     *
     * Base shape (all modes):
     *   [
     *     'query_id' => string,
     *     'query'    => string (normalised),
     *     'mode'     => 'list'|'summarize',
     *     'site'     => string (single-host token),
     *     'results'  => array<int, array{
     *       url: string, name: string, site: string,
     *       score: float, description: string, schema_object: array
     *     }>,
     *   ]
     *
     * summarize mode additionally includes a top-level `summary` string and
     * a `usage` block describing which provider handled the request.
     *
     * @param string      $raw_query         Caller-supplied query.
     * @param string      $mode              Caller-supplied mode (default 'list').
     * @param int         $limit             Max results (clamped to PostSearchEngine::MAX_LIMIT).
     * @param string|null $query_id          Optional caller-supplied id; auto-generated if null/empty.
     * @param bool        $is_authenticated  Whether the caller is an authenticated WP user.
     * @return array<string, mixed>
     * @throws AskQueryException On gate-closed, mode-rejected, empty/long query, auth-required,
     *                            or LLM-backend failure when mode=summarize.
     */
    public function execute( string $raw_query, string $mode = 'list', int $limit = self::DEFAULT_LIMIT, ?string $query_id = null, bool $is_authenticated = false ): array {
        // Shared on/off gate. Same module toggle that controls /schema-map.
        if ( ! SchemaMapGate::is_enabled() ) {
            throw new AskQueryException(
                'schema_map_disabled',
                __( 'Schema aggregation is disabled on this site.', 'prorank-seo' ),
                404
            );
        }

        $mode = strtolower( trim( $mode ) );
        if ( $mode === '' ) {
            $mode = 'list';
        }
        if ( ! in_array( $mode, self::SUPPORTED_MODES, true ) ) {
            throw new AskQueryException(
                'unsupported_mode',
                sprintf(
                    /* translators: %s is the list of supported modes. */
                    __( 'Unsupported mode. This endpoint supports: %s.', 'prorank-seo' ),
                    implode( ', ', self::SUPPORTED_MODES )
                ),
                400,
                [ 'supported_modes' => self::SUPPORTED_MODES ]
            );
        }

        // Auth gate for LLM-backed modes. Enforced here (not at the controller
        // layer) so /ask and /mcp share a single policy. Both controllers
        // pass their own $is_authenticated determination in — the semantics
        // are "logged-in AND holds a content-edit capability" so a bare
        // subscriber account can't burn the site owner's BYOK credits.
        // Controllers compute this via is_user_logged_in() &&
        // current_user_can('edit_posts'). Additional auth surfaces (API
        // keys, JWT, etc.) aren't accepted in this phase.
        if ( in_array( $mode, self::AUTH_REQUIRED_MODES, true ) && ! $is_authenticated ) {
            throw new AskQueryException(
                'auth_required',
                sprintf(
                    /* translators: %s is the mode name that requires auth. */
                    __( 'Authentication required for mode "%s". Use a WordPress application password for a user with edit_posts capability on this request.', 'prorank-seo' ),
                    $mode
                ),
                401,
                [
                    'mode'             => $mode,
                    'auth_methods'     => [ 'wordpress_application_password' ],
                    'required_capability' => 'edit_posts',
                ]
            );
        }

        $query = $this->normalise_query( $raw_query );
        if ( $query === '' ) {
            throw new AskQueryException(
                'empty_query',
                __( 'Query is required and must be non-empty.', 'prorank-seo' ),
                400
            );
        }
        // Character-based length check (not byte-based). A 500-character
        // query in a multibyte script like Japanese or Arabic would
        // otherwise fail the byte-based strlen() long before it reaches
        // the documented 500-character limit.
        $query_length = function_exists( 'mb_strlen' )
            ? (int) mb_strlen( $query, 'UTF-8' )
            : strlen( $query );
        if ( $query_length > self::MAX_QUERY_LENGTH ) {
            throw new AskQueryException(
                'query_too_long',
                sprintf(
                    /* translators: %d is the max query length in characters. */
                    __( 'Query exceeds maximum length of %d characters.', 'prorank-seo' ),
                    self::MAX_QUERY_LENGTH
                ),
                400,
                [ 'max_length' => self::MAX_QUERY_LENGTH ]
            );
        }

        if ( $limit <= 0 ) {
            $limit = self::DEFAULT_LIMIT;
        }
        $limit = min( $limit, PostSearchEngine::MAX_LIMIT );

        if ( $query_id === null || $query_id === '' ) {
            $query_id = $this->generate_query_id();
        }

        $builder = new PostEntityBuilder();
        $engine  = new PostSearchEngine( $builder );
        $ranker  = new SchemaRanker();

        // Cast a wider net for candidate posts than we'll ultimately return,
        // so the ranker has enough to choose from after enricher entities
        // join the pool.
        $candidate_cap = max( 20, $limit * 3 );
        $post_matches  = $engine->search( $query, $candidate_cap );

        // Build the seed entity list the `prorank_schema_map_entities`
        // filter consumes. This is what lets the premium enricher produce
        // LocalBusiness, targeted FAQPage, and targeted HowTo candidates
        // alongside post matches. Seed includes:
        //   1. WP_Query post matches on the query text
        //   2. Posts targeted by any active targeted-FAQ/HowTo config
        // Without (2), queries that match FAQ *questions* but not the
        // parent post's content would return nothing.
        $seed_post_ids = [];
        foreach ( $post_matches as $match ) {
            $seed_post_ids[ $match['post']->ID ] = $match['post'];
        }
        foreach ( $this->gather_targeted_post_ids() as $post_id ) {
            if ( isset( $seed_post_ids[ $post_id ] ) ) {
                continue;
            }
            $post = get_post( $post_id );
            if ( $post instanceof \WP_Post && $builder->is_post_eligible( $post ) ) {
                $seed_post_ids[ $post_id ] = $post;
            }
        }

        $seed = [];
        foreach ( $seed_post_ids as $post ) {
            foreach ( $builder->build_for_post( $post ) as $entity ) {
                $seed[] = $entity;
            }
        }

        /**
         * Same filter SiteSchemaCollector::collect() uses — every endpoint
         * that surfaces aggregated entities runs through it so enricher
         * extras stay consistent. Page 1 is hard-coded because the search
         * pipeline is a top-N ranking, not paginated browsing; the
         * enricher only emits site-level entities (LocalBusiness, homepage
         * FAQ/HowTo) when page === 1.
         */
        $enriched = apply_filters( 'prorank_schema_map_entities', $seed, 1, $candidate_cap );
        if ( ! is_array( $enriched ) ) {
            $enriched = $seed;
        }

        // Dedupe by stable @id, matching what /schema-map would emit for
        // the same entity set.
        $deduper    = new EntityDeduplicator();
        $candidates = $deduper->dedupe( $enriched );

        // Identify which URLs also have a non-WebPage entity in the pool.
        // For a standard post, the candidate pool holds BOTH a `#webpage`
        // wrapper and the richer Article/BlogPosting/etc. — drop the
        // wrapper, return the content entity. For a plain WordPress `page`,
        // the primary content type IS WebPage, so after dedupe there's
        // only ONE WebPage entity representing it; keep that one.
        // Distinguishing rule: "does any other entity in the pool share
        // this URL?" — if yes, the WebPage is a wrapper.
        $urls_with_non_wrapper = [];
        foreach ( $candidates as $entity ) {
            if ( ! is_array( $entity ) ) {
                continue;
            }
            $id = isset( $entity['@id'] ) && is_string( $entity['@id'] ) ? $entity['@id'] : '';
            if ( $id !== '' && substr( $id, -8 ) === '#webpage' ) {
                continue;
            }
            $url = isset( $entity['url'] ) && is_string( $entity['url'] ) ? $entity['url'] : '';
            if ( $url !== '' ) {
                $urls_with_non_wrapper[ $url ] = true;
            }
        }

        // Score every candidate against the query and sort desc.
        $scored = [];
        foreach ( $candidates as $entity ) {
            if ( ! is_array( $entity ) || empty( $entity ) ) {
                continue;
            }
            $id            = isset( $entity['@id'] ) && is_string( $entity['@id'] ) ? $entity['@id'] : '';
            $url           = isset( $entity['url'] ) && is_string( $entity['url'] ) ? $entity['url'] : '';
            $is_wrapper_id = $id !== '' && substr( $id, -8 ) === '#webpage';
            if ( $is_wrapper_id && $url !== '' && isset( $urls_with_non_wrapper[ $url ] ) ) {
                continue;
            }
            $score = $ranker->score( $entity, $query );
            if ( $score <= 0 ) {
                continue;
            }
            $scored[] = [
                'entity' => $entity,
                'score'  => $score,
            ];
        }

        usort( $scored, static function ( $a, $b ) {
            return $b['score'] <=> $a['score'];
        } );
        $scored = array_slice( $scored, 0, $limit );

        $site_token = $this->site_token();
        $results    = [];
        foreach ( $scored as $item ) {
            $entity = $item['entity'];
            $url    = isset( $entity['url'] ) && is_string( $entity['url'] ) ? $entity['url'] : '';

            $name = '';
            foreach ( [ 'name', 'headline' ] as $candidate ) {
                if ( isset( $entity[ $candidate ] ) && is_string( $entity[ $candidate ] ) && $entity[ $candidate ] !== '' ) {
                    $name = $entity[ $candidate ];
                    break;
                }
            }

            $description = isset( $entity['description'] ) && is_string( $entity['description'] )
                ? $entity['description']
                : '';

            $results[] = [
                'url'           => $url,
                'name'          => $name,
                'site'          => $site_token,
                'score'         => (float) $item['score'],
                'description'   => $description,
                'schema_object' => $entity,
            ];
        }

        $envelope = [
            'query_id' => $query_id,
            'query'    => $query,
            'mode'     => $mode,
            'site'     => $site_token,
            'results'  => $results,
        ];

        if ( $mode === 'summarize' ) {
            $summary = $this->summarise_results( $query, $results );
            $envelope['summary'] = $summary['summary'];
            if ( isset( $summary['usage'] ) ) {
                $envelope['usage'] = $summary['usage'];
            }
        } elseif ( $mode === 'generate' ) {
            $generated = $this->generate_answer( $query, $results );
            $envelope['answer']    = $generated['answer'];
            $envelope['citations'] = $generated['citations'];
            if ( isset( $generated['usage'] ) ) {
                $envelope['usage'] = $generated['usage'];
            }
        }

        return $envelope;
    }

    /**
     * Invoke the BYOK summarizer, projecting its WP_Error outcomes onto
     * AskQueryException codes the controllers already know how to project
     * onto REST / JSON-RPC wire formats.
     *
     * The summarizer class lives in premium (AI\AskSummarizer). Free
     * installs don't have it, so the missing-class branch here is the
     * graceful degradation — callers get `provider_not_configured` instead
     * of a fatal error.
     *
     * @param string               $query   Normalised query.
     * @param array<int, array>    $results Already-projected list results.
     * @return array{summary: string, usage?: array}
     * @throws AskQueryException
     */
    private function summarise_results( string $query, array $results ): array {
        $class = '\\ProRank\\SEO\\AI\\AskSummarizer';
        if ( ! class_exists( $class ) ) {
            // The LLM helper only exists on Pro+ installs. Controllers
            // filter summarize out of their public enum / tools-list on
            // free, so reaching here means either a race (plugin upgrade
            // in-flight) or a client bypassing the advertised schema.
            // Either way, the clean answer is "the tier this install is
            // on doesn't include summarize", not "configure a provider".
            throw new AskQueryException(
                'tier_required',
                __( 'Summarize mode requires the Pro (or higher) tier of ProRank SEO.', 'prorank-seo' ),
                403,
                [ 'required_tier' => 'pro' ]
            );
        }

        $summarizer = new $class();
        $result     = $summarizer->summarise( $query, $results );

        if ( is_wp_error( $result ) ) {
            /** @var \WP_Error $result */
            $code    = (string) $result->get_error_code();
            $message = $result->get_error_message();
            // Map common WP_Error codes to predictable public codes. Anything
            // else bubbles up as provider_error so callers always get a
            // machine code they can switch on.
            $public_code = match ( $code ) {
                'provider_not_configured', 'ai_provider_not_configured' => 'provider_not_configured',
                'usage_cap_exceeded' => 'over_quota',
                'provider_empty_response' => 'provider_empty_response',
                default => 'provider_error',
            };
            $status = match ( $public_code ) {
                'provider_not_configured' => 503,
                'over_quota'              => 429,
                default                   => 502,
            };
            throw new AskQueryException(
                $public_code,
                $message !== '' ? $message : __( 'AI provider failed to summarise the results.', 'prorank-seo' ),
                $status,
                [ 'provider_code' => $code ]
            );
        }

        if ( ! is_array( $result ) || empty( $result['summary'] ) || ! is_string( $result['summary'] ) ) {
            throw new AskQueryException(
                'provider_empty_response',
                __( 'AI provider returned an empty summary.', 'prorank-seo' ),
                502
            );
        }

        return $result;
    }

    /**
     * Invoke the BYOK generator, projecting WP_Error outcomes onto the
     * same AskQueryException taxonomy summarise_results uses so both
     * controllers handle provider failures with identical wire shapes.
     *
     * Mirrors summarise_results — see that method for the rationale on
     * error projection. The only divergence is the set of returned fields
     * (`answer` + `citations` instead of `summary`) and the fallback
     * error text for missing helper / empty response.
     *
     * @param string            $query   Normalised query.
     * @param array<int, array> $results Already-projected list results.
     * @return array{answer: string, citations: array<int, array<string, mixed>>, usage?: array}
     * @throws AskQueryException
     */
    private function generate_answer( string $query, array $results ): array {
        $class = '\\ProRank\\SEO\\AI\\AskGenerator';
        if ( ! class_exists( $class ) ) {
            throw new AskQueryException(
                'tier_required',
                __( 'Generate mode requires the Pro (or higher) tier of ProRank SEO.', 'prorank-seo' ),
                403,
                [ 'required_tier' => 'pro' ]
            );
        }

        $generator = new $class();
        $result    = $generator->generate( $query, $results );

        if ( is_wp_error( $result ) ) {
            /** @var \WP_Error $result */
            $code    = (string) $result->get_error_code();
            $message = $result->get_error_message();
            $public_code = match ( $code ) {
                'provider_not_configured', 'ai_provider_not_configured' => 'provider_not_configured',
                'usage_cap_exceeded' => 'over_quota',
                'provider_empty_response' => 'provider_empty_response',
                default => 'provider_error',
            };
            $status = match ( $public_code ) {
                'provider_not_configured' => 503,
                'over_quota'              => 429,
                default                   => 502,
            };
            throw new AskQueryException(
                $public_code,
                $message !== '' ? $message : __( 'AI provider failed to generate an answer.', 'prorank-seo' ),
                $status,
                [ 'provider_code' => $code ]
            );
        }

        if ( ! is_array( $result ) || empty( $result['answer'] ) || ! is_string( $result['answer'] ) ) {
            throw new AskQueryException(
                'provider_empty_response',
                __( 'AI provider returned an empty answer.', 'prorank-seo' ),
                502
            );
        }

        if ( ! isset( $result['citations'] ) || ! is_array( $result['citations'] ) ) {
            $result['citations'] = [];
        }

        return $result;
    }

    /**
     * Clean and bound the incoming query string.
     *
     * @param string $raw Raw query.
     * @return string
     */
    private function normalise_query( string $raw ): string {
        $trimmed   = trim( wp_strip_all_tags( $raw ) );
        $collapsed = preg_replace( '/\s+/u', ' ', $trimmed );
        return is_string( $collapsed ) ? $collapsed : '';
    }

    /**
     * Generate a UUID for query_id when the caller doesn't supply one.
     *
     * @return string
     */
    private function generate_query_id(): string {
        if ( function_exists( 'wp_generate_uuid4' ) ) {
            return wp_generate_uuid4();
        }
        return bin2hex( random_bytes( 16 ) );
    }

    /**
     * Collect every post ID referenced by an active targeted-schema config.
     *
     * Reads `prorank_targeted_faq_schemas` and `prorank_targeted_howto_schemas`
     * (the option keys the premium TargetedFaq/HowTo modules write to) and
     * returns the union of all `targeting.postIds` from active configs —
     * but ONLY when the corresponding source module is actually active.
     *
     * The premium enricher itself already gates on module activity when
     * emitting entities, so /ask|/mcp must agree on the same prerequisite —
     * otherwise we'd seed posts whose FAQ entities the enricher would
     * refuse to emit, wasting work.
     *
     * @return int[]
     */
    private function gather_targeted_post_ids(): array {
        $gated_options = [
            'prorank_targeted_faq_schemas'   => 'targeted-faq-schema',
            'prorank_targeted_howto_schemas' => 'targeted-howto-schema',
        ];

        $ids = [];
        foreach ( $gated_options as $option_name => $module_slug ) {
            if ( ! $this->is_source_module_active( $module_slug ) ) {
                continue;
            }
            $configs = get_option( $option_name, [] );
            if ( ! is_array( $configs ) ) {
                continue;
            }
            foreach ( $configs as $config ) {
                if ( ! is_array( $config ) ) {
                    continue;
                }
                if ( ( $config['status'] ?? 'active' ) !== 'active' ) {
                    continue;
                }
                $targeting = $config['targeting'] ?? [];
                if ( ! is_array( $targeting ) || empty( $targeting['postIds'] ) || ! is_array( $targeting['postIds'] ) ) {
                    continue;
                }
                foreach ( $targeting['postIds'] as $pid ) {
                    $pid = (int) $pid;
                    if ( $pid > 0 ) {
                        $ids[ $pid ] = true;
                    }
                }
            }
        }

        /**
         * Filter the must-seed post IDs used to build the candidate pool.
         *
         * Third-party enrichment paths that emit per-post entities via the
         * `prorank_schema_map_entities` filter can hook here to ensure
         * their target posts are always seeded, even when the post content
         * itself doesn't match the query.
         *
         * @param int[] $ids Post IDs.
         */
        $filtered = apply_filters( 'prorank_schema_ask_must_seed_post_ids', array_keys( $ids ) );
        return is_array( $filtered )
            ? array_values( array_filter( array_map( 'intval', $filtered ), static fn( $v ) => $v > 0 ) )
            : [];
    }

    /**
     * Whether a premium source module is active in ModuleManager.
     *
     * Mirrors SchemaMapEnricherModule::is_source_module_active() so the
     * search pipeline and the enricher share the same gate contract.
     * Default-deny on unavailable environments.
     *
     * @param string $slug Source module slug.
     * @return bool
     */
    private function is_source_module_active( string $slug ): bool {
        if ( ! class_exists( '\\ProRank\\SEO\\Plugin' ) ) {
            return false;
        }
        try {
            $plugin = \ProRank\SEO\Plugin::get_instance();
        } catch ( \Throwable $e ) {
            return false;
        }
        if ( ! $plugin || ! method_exists( $plugin, 'modules' ) ) {
            return false;
        }
        $modules = $plugin->modules();
        if ( ! $modules || ! method_exists( $modules, 'is_active' ) ) {
            return false;
        }
        return (bool) $modules->is_active( $slug );
    }

    /**
     * Single-host token used in every result. Multi-tenant hosts could
     * return something different; for single-site WP the lowercased host
     * name is the most meaningful stable identifier.
     *
     * @return string
     */
    private function site_token(): string {
        $home = home_url( '/' );
        $host = wp_parse_url( $home, PHP_URL_HOST );
        return is_string( $host ) && $host !== '' ? strtolower( $host ) : $home;
    }
}
