<?php
/**
 * Spec for ppok/rest-call — Phase 3 agent-DX optimizations.
 *
 * Covers: slim default response shape, params.include_extended escape hatch,
 * cursor pagination round-trip, and reserved-key stripping before dispatch.
 *
 * @package Ppok\AIToolkit\Tests
 * @license GPL-2.0-or-later
 */

namespace Ppok\AIToolkit\Tests;

use Ppok\AIToolkit\Abilities\Rest_Call;

class Test_Rest_Call extends \WP_UnitTestCase {

	public function test_slug_constant(): void {
		$this->assertSame( 'ppok/rest-call', Rest_Call::SLUG );
	}

	public function test_descriptor_marks_write_true(): void {
		$d = Rest_Call::descriptor();
		$this->assertTrue( $d['meta']['ppok']['write'], 'rest-call must be flagged as write so every dispatch is audited.' );
	}

	public function test_input_schema_requires_reason(): void {
		$schema = Rest_Call::input_schema();
		$this->assertContains( 'reason', $schema['required'] );
		$this->assertContains( 'method', $schema['required'] );
		$this->assertContains( 'path', $schema['required'] );
	}

	public function test_output_schema_advertises_pagination_fields(): void {
		$schema = Rest_Call::output_schema();
		$this->assertArrayHasKey( 'has_more', $schema['properties'] );
		$this->assertArrayHasKey( 'next_cursor', $schema['properties'] );
	}

	public function test_example_payload_is_sensible(): void {
		$ex = Rest_Call::example();
		$this->assertSame( 'GET', $ex['method'] );
		$this->assertStringStartsWith( '/', $ex['path'] );
		$this->assertNotEmpty( $ex['reason'] );
	}

	public function test_slim_strips_noisy_post_fields_by_default(): void {
		$post_id = self::factory()->post->create( array( 'post_status' => 'publish' ) );
		// Force the REST controller to advertise yoast_head_like noise by
		// stuffing a known shape into the meta — we simulate it by adding
		// the fields via the prepare filter, which is the real path Yoast et al.
		// take. Simulating here keeps the test runtime free of Yoast.
		$filter = static function ( $response ) {
			$data = $response->get_data();
			if ( is_array( $data ) && isset( $data['id'] ) ) {
				$data['yoast_head']         = '<title>seo</title>';
				$data['yoast_head_json']    = array( 'title' => 'seo' );
				$data['permalink_template'] = 'https://example.test/%postname%/';
				$data['class_list']         = array( 'post-1', 'post' );
				$data['generated_slug']     = 'auto-slug';
				$response->set_data( $data );
			}
			return $response;
		};
		add_filter( 'rest_prepare_post', $filter, 999 );

		$result = Rest_Call::execute_callback(
			array(
				'method' => 'GET',
				'path'   => '/wp/v2/posts/' . $post_id,
				'params' => array(),
				'reason' => 'phpunit fetch single post',
			)
		);

		remove_filter( 'rest_prepare_post', $filter, 999 );

		$this->assertSame( 200, $result['status'] );
		foreach ( array( 'yoast_head', 'yoast_head_json', 'permalink_template', 'class_list', 'generated_slug' ) as $field ) {
			$this->assertArrayNotHasKey( $field, $result['data'], "Slim mode must strip {$field}." );
		}
		// Core fields survive.
		$this->assertSame( $post_id, $result['data']['id'] );
	}

	public function test_include_extended_preserves_noisy_fields(): void {
		$post_id = self::factory()->post->create( array( 'post_status' => 'publish' ) );
		$filter  = static function ( $response ) {
			$data = $response->get_data();
			if ( is_array( $data ) && isset( $data['id'] ) ) {
				$data['yoast_head']     = '<title>seo</title>';
				$data['generated_slug'] = 'auto-slug';
				$response->set_data( $data );
			}
			return $response;
		};
		add_filter( 'rest_prepare_post', $filter, 999 );

		$result = Rest_Call::execute_callback(
			array(
				'method' => 'GET',
				'path'   => '/wp/v2/posts/' . $post_id,
				'params' => array( 'include_extended' => true ),
				'reason' => 'phpunit fetch single post with extended',
			)
		);

		remove_filter( 'rest_prepare_post', $filter, 999 );

		$this->assertSame( 200, $result['status'] );
		$this->assertArrayHasKey( 'yoast_head', $result['data'] );
		$this->assertArrayHasKey( 'generated_slug', $result['data'] );
	}

	public function test_slim_walks_list_responses(): void {
		// Two posts. The list response is a list of objects; each must be slimmed.
		self::factory()->post->create( array( 'post_status' => 'publish' ) );
		self::factory()->post->create( array( 'post_status' => 'publish' ) );

		$filter = static function ( $response ) {
			$data = $response->get_data();
			if ( is_array( $data ) && isset( $data['id'] ) ) {
				$data['yoast_head'] = '<title>seo</title>';
				$response->set_data( $data );
			}
			return $response;
		};
		add_filter( 'rest_prepare_post', $filter, 999 );

		$result = Rest_Call::execute_callback(
			array(
				'method' => 'GET',
				'path'   => '/wp/v2/posts',
				'params' => array( 'per_page' => 5 ),
				'reason' => 'phpunit list posts',
			)
		);

		remove_filter( 'rest_prepare_post', $filter, 999 );

		$this->assertSame( 200, $result['status'] );
		$this->assertIsArray( $result['data'] );
		foreach ( $result['data'] as $row ) {
			$this->assertArrayNotHasKey( 'yoast_head', $row );
		}
	}

	public function test_cursor_appears_when_more_pages_exist(): void {
		// Use an editor so draft/private don't muddy results; create
		// enough posts that per_page=2 produces multiple pages.
		$marker = 'rcursor-' . wp_generate_password( 8, false );
		for ( $i = 0; $i < 5; $i++ ) {
			self::factory()->post->create(
				array(
					'post_title'  => $marker . ' ' . $i,
					'post_status' => 'publish',
				)
			);
		}

		$result = Rest_Call::execute_callback(
			array(
				'method' => 'GET',
				'path'   => '/wp/v2/posts',
				'params' => array(
					'per_page' => 2,
					'search'   => $marker,
				),
				'reason' => 'phpunit cursor page 1',
			)
		);

		$this->assertSame( 200, $result['status'] );
		$this->assertArrayHasKey( 'has_more', $result, 'Multi-page list response must advertise has_more.' );
		$this->assertTrue( $result['has_more'] );
		$this->assertArrayHasKey( 'next_cursor', $result );
		$this->assertNotEmpty( $result['next_cursor'] );

		// Cursor decodes to a payload pinned to page 2.
		$decoded = json_decode( (string) base64_decode( $result['next_cursor'], true ), true );
		$this->assertIsArray( $decoded );
		$this->assertSame( 2, $decoded['params']['page'] );
		$this->assertSame( 2, $decoded['params']['per_page'] );
		$this->assertSame( $marker, $decoded['params']['search'] );
	}

	public function test_cursor_consumption_replaces_params(): void {
		// Round-trip: take the next_cursor from page 1 and hand it back as
		// params.cursor on a subsequent call. The wrapper must use the cursor's
		// params (page=2) rather than the empty params field on the new call.
		$marker = 'rcursorrt-' . wp_generate_password( 8, false );
		for ( $i = 0; $i < 5; $i++ ) {
			self::factory()->post->create(
				array(
					'post_title'  => $marker . ' ' . $i,
					'post_status' => 'publish',
				)
			);
		}

		$page1 = Rest_Call::execute_callback(
			array(
				'method' => 'GET',
				'path'   => '/wp/v2/posts',
				'params' => array(
					'per_page' => 2,
					'search'   => $marker,
				),
				'reason' => 'phpunit page 1',
			)
		);
		$this->assertNotEmpty( $page1['next_cursor'] );

		$page2 = Rest_Call::execute_callback(
			array(
				'method' => 'GET',
				'path'   => '/wp/v2/posts',
				'params' => array( 'cursor' => $page1['next_cursor'] ),
				'reason' => 'phpunit page 2 via cursor',
			)
		);
		$this->assertSame( 200, $page2['status'] );
		$this->assertCount( 2, $page2['data'] );

		// Page 2 IDs must not overlap page 1 IDs.
		$page1_ids = array_column( $page1['data'], 'id' );
		$page2_ids = array_column( $page2['data'], 'id' );
		$this->assertEmpty( array_intersect( $page1_ids, $page2_ids ), 'Cursor follow-up must return a disjoint page.' );
	}

	public function test_no_cursor_on_single_page_result(): void {
		$marker = 'rcursorone-' . wp_generate_password( 8, false );
		self::factory()->post->create(
			array(
				'post_title'  => $marker,
				'post_status' => 'publish',
			)
		);

		$result = Rest_Call::execute_callback(
			array(
				'method' => 'GET',
				'path'   => '/wp/v2/posts',
				'params' => array(
					'per_page' => 10,
					'search'   => $marker,
				),
				'reason' => 'phpunit one page',
			)
		);
		$this->assertSame( 200, $result['status'] );
		$this->assertArrayNotHasKey( 'has_more', $result );
		$this->assertArrayNotHasKey( 'next_cursor', $result );
	}

	public function test_no_cursor_on_single_resource_get(): void {
		// /wp/v2/posts/{id} returns an object, not a list. No pagination.
		$post_id = self::factory()->post->create( array( 'post_status' => 'publish' ) );
		$result  = Rest_Call::execute_callback(
			array(
				'method' => 'GET',
				'path'   => '/wp/v2/posts/' . $post_id,
				'params' => array(),
				'reason' => 'phpunit single resource',
			)
		);
		$this->assertSame( 200, $result['status'] );
		$this->assertArrayNotHasKey( 'has_more', $result );
		$this->assertArrayNotHasKey( 'next_cursor', $result );
	}

	public function test_oversize_response_returns_structured_error_not_connection_drop(): void {
		// /wp/v2/posts/{id}?context=edit requires editing privileges.
		$editor = self::factory()->user->create( array( 'role' => 'editor' ) );
		wp_set_current_user( $editor );

		// Squeeze the cap so we can trigger response_too_large with a small fixture.
		add_filter(
			'ppok_ai_toolkit_rest_call_max_response_bytes',
			static function () {
				return 2048;
			}
		);

		// Even a slimmed single-post response easily clears 2KB once we
		// inject a meaningful content blob.
		$post_id = self::factory()->post->create(
			array(
				'post_status'  => 'publish',
				'post_content' => str_repeat( 'x', 5000 ),
			)
		);

		$result = Rest_Call::execute_callback(
			array(
				'method' => 'GET',
				'path'   => '/wp/v2/posts/' . $post_id,
				'params' => array( 'context' => 'edit' ),
				'reason' => 'phpunit oversize probe',
			)
		);

		remove_all_filters( 'ppok_ai_toolkit_rest_call_max_response_bytes' );

		$this->assertInstanceOf( \WP_Error::class, $result );
		$this->assertSame( 'response_too_large', $result->get_error_code() );
		$data = $result->get_error_data();
		$this->assertSame( 413, $data['status'] );
		$this->assertSame( 2048, $data['limit_bytes'] );
		$this->assertGreaterThan( 2048, $data['actual_bytes'] );
		$this->assertSame( '/wp/v2/posts/' . $post_id, $data['path'] );
		$this->assertSame( 'GET', $data['method'] );
	}

	public function test_correlation_id_appears_on_error_when_context_is_wired(): void {
		$editor = self::factory()->user->create( array( 'role' => 'editor' ) );
		wp_set_current_user( $editor );

		// Stand up a request context so decorate_error has something to stamp on.
		$context = new \Ppok\AIToolkit\Request_Context();
		$req     = new \WP_REST_Request( 'GET', '/wp/v2/posts' );
		$req->set_header( 'X-Request-ID', 'phpunit-correlation-1234' );
		$context->capture( $req );
		\Ppok\AIToolkit\Request_Context::set_instance( $context );

		add_filter(
			'ppok_ai_toolkit_rest_call_max_response_bytes',
			static function () {
				return 1024;
			}
		);
		$post_id = self::factory()->post->create(
			array(
				'post_status'  => 'publish',
				'post_content' => str_repeat( 'y', 3000 ),
			)
		);

		$result = Rest_Call::execute_callback(
			array(
				'method' => 'GET',
				'path'   => '/wp/v2/posts/' . $post_id,
				'params' => array( 'context' => 'edit' ),
				'reason' => 'phpunit correlation probe',
			)
		);

		remove_all_filters( 'ppok_ai_toolkit_rest_call_max_response_bytes' );
		\Ppok\AIToolkit\Request_Context::reset_instance();

		$this->assertInstanceOf( \WP_Error::class, $result );
		$data = $result->get_error_data();
		$this->assertArrayHasKey( 'correlation_id', $data );
		$this->assertSame( 'phpunit-correlation-1234', $data['correlation_id'] );
	}
}
