<?php
/**
 * Characterization tests for the ppok/preview-url ability — token
 * mint/verify, ability execute path, and the front-end pre_get_posts
 * status-expansion handler.
 *
 * @package Ppok\AIToolkit\Tests
 * @license GPL-2.0-or-later
 */

namespace Ppok\AIToolkit\Tests;

use Ppok\AIToolkit\Abilities\Preview_Url;

class Test_Preview_Url extends \WP_UnitTestCase {

	public function test_slug_constant(): void {
		$this->assertSame( 'ppok/preview-url', Preview_Url::SLUG );
	}

	public function test_ttl_is_five_minutes(): void {
		$this->assertSame( 300, Preview_Url::TTL_SECONDS );
	}

	public function test_descriptor_marks_write_true_and_mcp_public_default_off(): void {
		$d = Preview_Url::descriptor();

		$this->assertTrue( $d['meta']['ppok']['write'], 'Preview URL mints a bypass credential; must be flagged as a write for audit + admin opt-in.' );
		$this->assertFalse( $d['meta']['mcp']['public'], 'Descriptor must default mcp.public=false so registrar can opt in via abilities_enabled.' );
	}

	public function test_input_schema_requires_post_id_and_reason(): void {
		$schema = Preview_Url::input_schema();

		$this->assertSame( array( 'post_id', 'reason' ), $schema['required'] );
		$this->assertFalse( $schema['additionalProperties'] );
		$this->assertSame( 8, $schema['properties']['reason']['minLength'] );
	}

	public function test_mint_and_verify_token_roundtrip(): void {
		$expires = time() + 60;
		$token   = Preview_Url::mint_token( 42, $expires );

		$this->assertNotEmpty( $token );
		$this->assertTrue( Preview_Url::verify_token( 42, $expires, $token ) );
	}

	public function test_verify_token_rejects_expired(): void {
		$expired_at = time() - 10;
		$token      = Preview_Url::mint_token( 42, $expired_at );

		$this->assertFalse( Preview_Url::verify_token( 42, $expired_at, $token ) );
	}

	public function test_verify_token_rejects_tampered_post_id(): void {
		$expires = time() + 60;
		$token   = Preview_Url::mint_token( 42, $expires );

		$this->assertFalse( Preview_Url::verify_token( 43, $expires, $token ) );
	}

	public function test_verify_token_rejects_tampered_expires(): void {
		$expires = time() + 60;
		$token   = Preview_Url::mint_token( 42, $expires );

		// Attacker extends the deadline by an hour.
		$this->assertFalse( Preview_Url::verify_token( 42, $expires + 3600, $token ) );
	}

	public function test_verify_token_rejects_empty_inputs(): void {
		$this->assertFalse( Preview_Url::verify_token( 0, time() + 60, 'whatever' ) );
		$this->assertFalse( Preview_Url::verify_token( 42, 0, 'whatever' ) );
		$this->assertFalse( Preview_Url::verify_token( 42, time() + 60, '' ) );
	}

	public function test_permission_callback_requires_edit_post_cap(): void {
		$author_id = self::factory()->user->create( array( 'role' => 'author' ) );
		$editor_id = self::factory()->user->create( array( 'role' => 'editor' ) );
		$post_id   = self::factory()->post->create(
			array(
				'post_status' => 'draft',
				'post_author' => $author_id,
			)
		);

		// Logged out — no.
		wp_set_current_user( 0 );
		$this->assertFalse( Preview_Url::permission_callback( array( 'post_id' => $post_id ) ) );

		// Author of the post — yes.
		wp_set_current_user( $author_id );
		$this->assertTrue( Preview_Url::permission_callback( array( 'post_id' => $post_id ) ) );

		// Editor (edit_others_posts) — yes.
		wp_set_current_user( $editor_id );
		$this->assertTrue( Preview_Url::permission_callback( array( 'post_id' => $post_id ) ) );

		// Missing post_id — no.
		$this->assertFalse( Preview_Url::permission_callback( array() ) );
	}

	public function test_execute_returns_signed_url_for_draft_post(): void {
		$post_id = self::factory()->post->create(
			array(
				'post_status' => 'draft',
				'post_type'   => 'post',
			)
		);

		$out = Preview_Url::execute_callback( array( 'post_id' => $post_id ) );

		$this->assertIsArray( $out );
		$this->assertSame( $post_id, $out['post_id'] );
		$this->assertSame( 'draft', $out['post_status'] );
		$this->assertSame( 300, $out['expires_in_seconds'] );

		// URL must carry our token + expires query args.
		$parsed = wp_parse_url( $out['preview_url'] );
		parse_str( $parsed['query'] ?? '', $qs );
		$this->assertArrayHasKey( Preview_Url::QUERY_VAR_TOKEN, $qs );
		$this->assertArrayHasKey( Preview_Url::QUERY_VAR_EXP, $qs );
		$this->assertSame( (string) $post_id, $qs['p'] );

		// Round-trip the token back through verify.
		$this->assertTrue(
			Preview_Url::verify_token(
				$post_id,
				(int) $qs[ Preview_Url::QUERY_VAR_EXP ],
				(string) $qs[ Preview_Url::QUERY_VAR_TOKEN ]
			)
		);
	}

	public function test_execute_returns_wp_error_when_post_missing(): void {
		$out = Preview_Url::execute_callback( array( 'post_id' => 999999 ) );

		$this->assertInstanceOf( \WP_Error::class, $out );
		$this->assertSame( 'ppok_post_not_found', $out->get_error_code() );
	}

	public function test_register_query_vars_appends_both_keys(): void {
		$out = Preview_Url::register_query_vars( array( 'existing_var' ) );

		$this->assertContains( 'existing_var', $out );
		$this->assertContains( Preview_Url::QUERY_VAR_TOKEN, $out );
		$this->assertContains( Preview_Url::QUERY_VAR_EXP, $out );
	}

	public function test_maybe_allow_preview_query_expands_status_with_valid_token(): void {
		global $wp_the_query;

		$post_id = self::factory()->post->create( array( 'post_status' => 'draft' ) );
		$expires = time() + 60;
		$token   = Preview_Url::mint_token( $post_id, $expires );

		$query = new \WP_Query();
		$query->init();
		$query->set( 'p', $post_id );
		$query->set( Preview_Url::QUERY_VAR_TOKEN, $token );
		$query->set( Preview_Url::QUERY_VAR_EXP, $expires );

		// Pose as the main query so is_main_query() passes.
		$wp_the_query = $query;

		Preview_Url::maybe_allow_preview_query( $query );

		$status = $query->get( 'post_status' );
		$this->assertIsArray( $status );
		$this->assertContains( 'draft', $status );
		$this->assertContains( 'pending', $status );
		$this->assertContains( 'private', $status );
	}

	public function test_maybe_allow_preview_query_noop_with_invalid_token(): void {
		global $wp_the_query;

		$post_id = self::factory()->post->create( array( 'post_status' => 'draft' ) );

		$query = new \WP_Query();
		$query->init();
		$query->set( 'p', $post_id );
		$query->set( Preview_Url::QUERY_VAR_TOKEN, 'definitely-not-the-right-token' );
		$query->set( Preview_Url::QUERY_VAR_EXP, time() + 60 );

		$wp_the_query = $query;

		Preview_Url::maybe_allow_preview_query( $query );

		// No expansion happened — post_status stays empty (WP's default).
		$this->assertEmpty( $query->get( 'post_status' ) );
	}

	public function test_maybe_allow_preview_query_noop_when_expired(): void {
		global $wp_the_query;

		$post_id    = self::factory()->post->create( array( 'post_status' => 'draft' ) );
		$expired_at = time() - 10;
		$token      = Preview_Url::mint_token( $post_id, $expired_at );

		$query = new \WP_Query();
		$query->init();
		$query->set( 'p', $post_id );
		$query->set( Preview_Url::QUERY_VAR_TOKEN, $token );
		$query->set( Preview_Url::QUERY_VAR_EXP, $expired_at );

		$wp_the_query = $query;

		Preview_Url::maybe_allow_preview_query( $query );

		$this->assertEmpty( $query->get( 'post_status' ) );
	}

	public function test_example_returns_array(): void {
		$this->assertIsArray( Preview_Url::example() );
		$this->assertArrayHasKey( 'post_id', Preview_Url::example() );
		$this->assertArrayHasKey( 'reason', Preview_Url::example() );
	}
}
