<?php
/**
 * ppok/rest/call — generic pass-through to the WordPress REST API.
 *
 * One ability, full dashboard reach. The ability-layer permission_callback
 * only checks that the caller is logged in; each REST endpoint's own
 * permission_callback gates the actual action when rest_do_request()
 * dispatches it.
 *
 * Phase 3 agent-DX optimizations (additive, schema-compatible):
 *
 * - Slim default response: noisy post fields (yoast_head, yoast_head_json,
 *   permalink_template, class_list, generated_slug) are stripped from the
 *   returned `data` unless `params.include_extended=true`. Field-presence
 *   based, so it is a no-op on endpoints that never emit these keys.
 * - Cursor pagination: when the dispatched response is a collection and
 *   the X-WP-TotalPages header reports more pages, the wrapper appends
 *   `has_more: true` and an opaque base64 `next_cursor` that round-trips
 *   the next page's params. Pass it back as `params.cursor` to advance.
 *
 * @package Ppok\AIToolkit
 * @license GPL-2.0-or-later
 */

namespace Ppok\AIToolkit\Abilities;

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

final class Rest_Call {

	public const SLUG = 'ppok/rest-call';

	private const METHODS_WITH_BODY = array( 'POST', 'PUT', 'PATCH' );

	/**
	 * Default hard cap on serialized response payload bytes. Returning
	 * anything larger as inline JSON has consistently produced the
	 * "MCP error -32000: Connection closed" symptom on the bridge side.
	 * Cap surfaces as a structured response_too_large WP_Error instead.
	 * Override with the ppok_ai_toolkit_rest_call_max_response_bytes filter.
	 */
	private const DEFAULT_MAX_RESPONSE_BYTES = 1048576; // 1 MiB

	/**
	 * Fields stripped from post-shaped response objects by default. They
	 * account for the bulk of /wp/v2/posts/* payload bytes and routinely
	 * push agents past MCP output caps without carrying useful information.
	 */
	private const NOISY_POST_FIELDS = array(
		'yoast_head',
		'yoast_head_json',
		'permalink_template',
		'class_list',
		'generated_slug',
		'_links',
	);

	public static function descriptor(): array {
		return array(
			'label'               => __( 'Call any WordPress REST endpoint', 'ppok-ai-toolkit' ),
			'description'         => __( 'Dispatches a request through the WordPress REST API. The authenticated user\'s capabilities gate every call. Use this to read or modify anything reachable via /wp-json — posts, users, media, settings, comments, taxonomies, plus anything any active plugin registers (ACF, Yoast, Redirection, etc.). Provide a short rationale in "reason"; it is required and goes into the audit log. Responses are slimmed by default (yoast_head, permalink_template, class_list, etc. removed); pass params.include_extended=true to keep them. List endpoints with more pages return next_cursor — pass it back as params.cursor.', 'ppok-ai-toolkit' ),
			'category'            => 'ppok',
			'input_schema'        => self::input_schema(),
			'output_schema'       => self::output_schema(),
			'permission_callback' => array( __CLASS__, 'permission_callback' ),
			'execute_callback'    => array( __CLASS__, 'execute_callback' ),
			'meta'                => array(
				'show_in_rest' => true,
				'mcp'          => array( 'public' => true ),
				'ppok'         => array( 'write' => true ),
			),
		);
	}

	public static function example(): array {
		return array(
			'method' => 'GET',
			'path'   => '/wp/v2/posts',
			'params' => array(
				'per_page' => 5,
				'status'   => 'publish',
			),
			'reason' => 'list recent published posts',
		);
	}

	public static function input_schema(): array {
		return array(
			'type'                 => 'object',
			'additionalProperties' => false,
			'required'             => array( 'method', 'path', 'reason' ),
			'properties'           => array(
				'method' => array(
					'type' => 'string',
					'enum' => array( 'GET', 'POST', 'PUT', 'PATCH', 'DELETE' ),
				),
				'path'   => array(
					'type'        => 'string',
					'description' => 'Path under /wp-json, leading slash required (e.g. "/wp/v2/posts" or "/wp/v2/posts/42").',
					'minLength'   => 2,
					'maxLength'   => 2048,
					'pattern'     => '^/',
				),
				'params' => array(
					'type'                 => 'object',
					'description'         => 'Query string for GET/DELETE; JSON body for POST/PUT/PATCH. Two reserved meta-keys: include_extended (bool, default false — set true to keep yoast_head / permalink_template / class_list / generated_slug in the response) and cursor (string — opaque next_cursor from a previous list response; when present, replaces all other params).',
					'additionalProperties' => true,
					'default'              => new \stdClass(),
				),
				'reason' => array(
					'type'        => 'string',
					'description' => 'Short human-readable rationale for the call. Logged.',
					'minLength'   => 8,
					'maxLength'   => 500,
				),
			),
		);
	}

	public static function output_schema(): array {
		return array(
			'type'       => 'object',
			'required'   => array( 'status', 'data' ),
			'properties' => array(
				'status'      => array( 'type' => 'integer' ),
				'data'        => array(),
				'headers'     => array( 'type' => 'object' ),
				'has_more'    => array(
					'type'        => 'boolean',
					'description' => 'Present on list responses when more pages exist. Pair with next_cursor.',
				),
				'next_cursor' => array(
					'type'        => 'string',
					'description' => 'Opaque base64 token. Pass back as params.cursor to fetch the next page.',
				),
			),
		);
	}

	public static function permission_callback(): bool {
		return is_user_logged_in();
	}

	/**
	 * @param array<string, mixed> $args
	 * @return array<string, mixed>|\WP_Error
	 */
	public static function execute_callback( array $args = array() ) {
		$method = strtoupper( (string) ( $args['method'] ?? 'GET' ) );
		$path   = (string) ( $args['path'] ?? '' );
		$params = isset( $args['params'] ) && is_array( $args['params'] ) ? $args['params'] : array();

		// Cursor wins. If present, it replaces params entirely with the
		// stored payload from the previous page's next_cursor.
		if ( isset( $params['cursor'] ) && is_string( $params['cursor'] ) && '' !== $params['cursor'] ) {
			$decoded = self::decode_cursor( $params['cursor'] );
			if ( is_array( $decoded ) && isset( $decoded['params'] ) && is_array( $decoded['params'] ) ) {
				$params = $decoded['params'];
			} else {
				unset( $params['cursor'] );
			}
		}

		// Pull out our reserved meta-keys so they never reach rest_do_request.
		$include_extended = ! empty( $params['include_extended'] );
		unset( $params['include_extended'] );

		try {
			$request = new \WP_REST_Request( $method, $path );

			if ( in_array( $method, self::METHODS_WITH_BODY, true ) ) {
				$request->set_body( (string) wp_json_encode( $params ) );
				$request->set_header( 'content-type', 'application/json' );
			} else {
				$request->set_query_params( $params );
			}

			$response = rest_do_request( $request );
		} catch ( \Throwable $e ) {
			// PHP exception inside dispatch is the root cause of many bridge
			// "Connection closed" symptoms — turn it into a structured WP_Error
			// so the agent gets a real error_code instead of a dead socket.
			return self::decorate_error(
				new \WP_Error(
					'rest_call_dispatch_exception',
					sprintf(
						/* translators: 1: exception class, 2: message */
						__( 'REST dispatch raised %1$s: %2$s', 'ppok-ai-toolkit' ),
						get_class( $e ),
						$e->getMessage()
					),
					array(
						'status'          => 500,
						'exception_class' => get_class( $e ),
					)
				)
			);
		}

		if ( $response->is_error() ) {
			// Hand the WP_Error back (decorated) so the registrar's logger captures the precise error_code.
			return self::decorate_error( $response->as_error() );
		}

		$data    = $response->get_data();
		$headers = $response->get_headers();

		if ( ! $include_extended ) {
			$data = self::slim( $data );
		}

		$out = array(
			'status'  => (int) $response->get_status(),
			'data'    => $data,
			'headers' => (object) $headers,
		);

		$cursor_meta = self::next_cursor_meta( $method, $path, $params, $data, $headers );
		if ( null !== $cursor_meta ) {
			$out['has_more']    = true;
			$out['next_cursor'] = $cursor_meta;
		}

		// Last-mile size guard. The MCP bridge cannot reliably stream
		// arbitrarily large responses; rather than let the transport drop
		// the call with "Connection closed", measure the JSON-encoded
		// payload and return a structured WP_Error when it exceeds the cap.
		$encoded = wp_json_encode( $out );
		if ( false === $encoded ) {
			return self::decorate_error(
				new \WP_Error(
					'response_encode_failed',
					__( 'Response could not be JSON-encoded (likely contains non-UTF-8 bytes).', 'ppok-ai-toolkit' ),
					array( 'status' => 500 )
				)
			);
		}
		$actual = strlen( $encoded );
		$limit  = self::max_response_bytes();
		if ( $actual > $limit ) {
			return self::decorate_error(
				new \WP_Error(
					'response_too_large',
					sprintf(
						/* translators: 1: actual bytes, 2: limit bytes */
						__( 'Response is %1$s bytes; the rest-call cap is %2$s bytes. Refine the request (use _fields, per_page, pagination) or pass params.include_extended=false to slim it.', 'ppok-ai-toolkit' ),
						number_format_i18n( $actual ),
						number_format_i18n( $limit )
					),
					array(
						'status'       => 413,
						'limit_bytes'  => $limit,
						'actual_bytes' => $actual,
						'path'         => $path,
						'method'       => $method,
					)
				)
			);
		}

		return $out;
	}

	/**
	 * Stamp the request-scoped correlation_id onto a WP_Error's data array
	 * so the agent can echo it back to the user for log grep. No-op when no
	 * Plugin orchestrator has wired a Request_Context (e.g. unit tests).
	 */
	private static function decorate_error( \WP_Error $err ): \WP_Error {
		$context = \Ppok\AIToolkit\Request_Context::instance();
		if ( ! $context instanceof \Ppok\AIToolkit\Request_Context ) {
			return $err;
		}
		$correlation_id = $context->get_correlation_id();
		if ( '' === $correlation_id ) {
			return $err;
		}
		$data                   = (array) $err->get_error_data();
		$data['correlation_id'] = $correlation_id;
		$err->add_data( $data );
		return $err;
	}

	private static function max_response_bytes(): int {
		$limit = (int) apply_filters( 'ppok_ai_toolkit_rest_call_max_response_bytes', self::DEFAULT_MAX_RESPONSE_BYTES );
		return $limit > 0 ? $limit : self::DEFAULT_MAX_RESPONSE_BYTES;
	}

	/**
	 * Recursively strip the noisy post fields from a response body.
	 * Lists are walked; objects/assoc-arrays have keys removed if present.
	 * Anything that is not an array passes through untouched.
	 *
	 * @param mixed $data
	 * @return mixed
	 */
	private static function slim( $data ) {
		if ( ! is_array( $data ) ) {
			return $data;
		}
		if ( self::is_list( $data ) ) {
			return array_map( array( __CLASS__, 'slim' ), $data );
		}
		foreach ( self::NOISY_POST_FIELDS as $field ) {
			unset( $data[ $field ] );
		}
		return $data;
	}

	/**
	 * Decide whether the dispatched call has more pages to offer. Only
	 * GET collection responses are eligible; everything else returns null.
	 *
	 * @param array<string, mixed>            $params
	 * @param mixed                           $data
	 * @param array<string, string|int>       $headers
	 */
	private static function next_cursor_meta( string $method, string $path, array $params, $data, array $headers ): ?string {
		if ( 'GET' !== $method ) {
			return null;
		}
		if ( ! is_array( $data ) || ! self::is_list( $data ) ) {
			return null;
		}

		$total_pages  = isset( $headers['X-WP-TotalPages'] ) ? (int) $headers['X-WP-TotalPages'] : 0;
		$current_page = isset( $params['page'] ) ? (int) $params['page'] : 1;
		if ( $current_page < 1 ) {
			$current_page = 1;
		}
		if ( $total_pages <= $current_page ) {
			return null;
		}

		$next_params         = $params;
		$next_params['page'] = $current_page + 1;

		return self::encode_cursor(
			array(
				'path'   => $path,
				'params' => $next_params,
			)
		);
	}

	/**
	 * @param array<string, mixed> $payload
	 */
	private static function encode_cursor( array $payload ): string {
		return base64_encode( (string) wp_json_encode( $payload ) );
	}

	private static function decode_cursor( string $cursor ): ?array {
		$json = base64_decode( $cursor, true );
		if ( false === $json ) {
			return null;
		}
		$data = json_decode( $json, true );
		return is_array( $data ) ? $data : null;
	}

	/**
	 * Pure-PHP array_is_list() shim — minimum PHP is 8.1 so this is just
	 * a thin wrapper, kept private to avoid leaking onto the global ns.
	 *
	 * @param array<int|string, mixed> $value
	 */
	private static function is_list( array $value ): bool {
		return array_is_list( $value );
	}
}
