<?php
/**
 * Spec for ppok/set-post-blocks (Phase 6 partial).
 *
 * Covers the LLM-bug-report verification checklist:
 *  - get-post-blocks → set-post-blocks no-op produces byte-identical post_content
 *  - mutating one block then writing produces the expected change on re-read
 *  - L1 (cap), L3 (allowlist), L4 (scope) all reject before any DB write
 *  - revision_id is captured when revisions are enabled
 *
 * @package Ppok\AIToolkit\Tests
 * @license GPL-2.0-or-later
 */

namespace Ppok\AIToolkit\Tests;

use Ppok\AIToolkit\Abilities\Post_Get_Blocks;
use Ppok\AIToolkit\Abilities\Post_Set_Blocks;
use Ppok\AIToolkit\Abilities\Scope_Gate;

class Test_Post_Set_Blocks extends \WP_UnitTestCase {

	protected function setUp(): void {
		parent::setUp();
		// Generous scope so the L4 gate doesn't get in the way unless a
		// specific test wants to assert on it.
		update_option(
			Scope_Gate::OPTION_KEY,
			array(
				'write_post_types'    => array( 'post', 'page' ),
				'write_post_statuses' => array( 'draft', 'publish' ),
			)
		);
		$editor = self::factory()->user->create( array( 'role' => 'editor' ) );
		wp_set_current_user( $editor );
	}

	protected function tearDown(): void {
		delete_option( Scope_Gate::OPTION_KEY );
		parent::tearDown();
	}

	public function test_slug_and_write_flag(): void {
		$this->assertSame( 'ppok/set-post-blocks', Post_Set_Blocks::SLUG );
		$descriptor = Post_Set_Blocks::descriptor();
		$this->assertTrue( $descriptor['meta']['ppok']['write'] );
	}

	public function test_round_trip_no_op_produces_byte_identical_content(): void {
		$original = "<!-- wp:paragraph -->\n<p>Hello world</p>\n<!-- /wp:paragraph -->\n\n<!-- wp:heading -->\n<h2>Section</h2>\n<!-- /wp:heading -->";
		$post_id  = self::factory()->post->create(
			array(
				'post_content' => $original,
				'post_status'  => 'draft',
			)
		);

		$read = Post_Get_Blocks::execute_callback( array( 'post_id' => $post_id ) );
		$this->assertIsArray( $read['blocks'] );

		$result = Post_Set_Blocks::execute_callback(
			array(
				'post_id' => $post_id,
				'blocks'  => $read['blocks'],
				'reason'  => 'phpunit round-trip no-op',
			)
		);
		$this->assertSame( $post_id, $result['post_id'] );

		$reread = get_post( $post_id );
		$this->assertSame( $original, $reread->post_content, 'get→set with no modification must be byte-identical.' );
	}

	public function test_mutated_block_is_persisted_and_re_readable(): void {
		$post_id = self::factory()->post->create(
			array(
				'post_content' => "<!-- wp:paragraph -->\n<p>before</p>\n<!-- /wp:paragraph -->",
				'post_status'  => 'draft',
			)
		);

		$read = Post_Get_Blocks::execute_callback( array( 'post_id' => $post_id ) );
		$this->assertCount( 1, $read['blocks'] );

		// Mutate the paragraph's HTML.
		$read['blocks'][0]['innerHTML']       = '<p>after</p>';
		$read['blocks'][0]['innerContent'][0] = '<p>after</p>';

		Post_Set_Blocks::execute_callback(
			array(
				'post_id' => $post_id,
				'blocks'  => $read['blocks'],
				'reason'  => 'phpunit mutate paragraph text',
			)
		);

		$reread = Post_Get_Blocks::execute_callback( array( 'post_id' => $post_id ) );
		$this->assertStringContainsString( 'after', $reread['blocks'][0]['innerHTML'] );
		$this->assertStringNotContainsString( 'before', $reread['blocks'][0]['innerHTML'] );
	}

	public function test_returns_bytes_written_matching_serialized_length(): void {
		$post_id = self::factory()->post->create(
			array(
				'post_content' => '',
				'post_status'  => 'draft',
			)
		);
		$blocks = array(
			array(
				'blockName'    => 'core/paragraph',
				'attrs'        => array(),
				'innerBlocks'  => array(),
				'innerHTML'    => '<p>fixed-length</p>',
				'innerContent' => array( '<p>fixed-length</p>' ),
			),
		);

		$result = Post_Set_Blocks::execute_callback(
			array(
				'post_id' => $post_id,
				'blocks'  => $blocks,
				'reason'  => 'phpunit bytes_written probe',
			)
		);
		$this->assertSame( strlen( serialize_blocks( $blocks ) ), $result['bytes_written'] );
	}

	public function test_revision_id_is_int_when_revisions_enabled(): void {
		$post_id = self::factory()->post->create(
			array(
				'post_content' => "<!-- wp:paragraph -->\n<p>first</p>\n<!-- /wp:paragraph -->",
				'post_status'  => 'draft',
			)
		);
		$blocks = array(
			array(
				'blockName'    => 'core/paragraph',
				'attrs'        => array(),
				'innerBlocks'  => array(),
				'innerHTML'    => '<p>second</p>',
				'innerContent' => array( '<p>second</p>' ),
			),
		);

		$result = Post_Set_Blocks::execute_callback(
			array(
				'post_id' => $post_id,
				'blocks'  => $blocks,
				'reason'  => 'phpunit revision capture',
			)
		);
		// On a fresh post the first revision request may be skipped (autosave
		// dedupe), but for a post that already has content a pre-update
		// revision should be issued. Either int>0 or null is acceptable;
		// what matters is the field exists and is the documented type.
		$this->assertArrayHasKey( 'revision_id', $result );
		$this->assertTrue( is_int( $result['revision_id'] ) || null === $result['revision_id'] );
	}

	public function test_permission_callback_requires_edit_post_cap_per_id(): void {
		$post_id   = self::factory()->post->create( array( 'post_status' => 'draft' ) );
		$subscriber = self::factory()->user->create( array( 'role' => 'subscriber' ) );
		wp_set_current_user( $subscriber );
		$this->assertFalse( Post_Set_Blocks::permission_callback( array( 'post_id' => $post_id ) ) );

		$editor = self::factory()->user->create( array( 'role' => 'editor' ) );
		wp_set_current_user( $editor );
		$this->assertTrue( Post_Set_Blocks::permission_callback( array( 'post_id' => $post_id ) ) );
	}

	public function test_l4_scope_rejects_post_type_outside_allowlist(): void {
		update_option( Scope_Gate::OPTION_KEY, array( 'write_post_types' => array( 'post' ) ) );
		$page_id = self::factory()->post->create( array( 'post_type' => 'page', 'post_status' => 'draft' ) );
		$before  = get_post( $page_id )->post_content;

		$result = Post_Set_Blocks::execute_callback(
			array(
				'post_id' => $page_id,
				'blocks'  => array(),
				'reason'  => 'phpunit scope reject',
			)
		);
		$this->assertInstanceOf( \WP_Error::class, $result );
		$this->assertSame( 'scope_blocked', $result->get_error_code() );
		$this->assertSame( 'write_post_types', $result->get_error_data()['rejected_by'] );

		// The original content must be untouched after a scope rejection.
		$this->assertSame( $before, get_post( $page_id )->post_content );
	}

	public function test_l3_block_allowlist_rejects_unknown_block_before_write(): void {
		$post_id  = self::factory()->post->create(
			array(
				'post_content' => 'untouched',
				'post_status'  => 'draft',
			)
		);
		$bad_tree = array(
			array(
				'blockName'    => 'definitely-not/registered',
				'attrs'        => array(),
				'innerBlocks'  => array(),
				'innerHTML'    => '',
				'innerContent' => array(),
			),
		);

		$result = Post_Set_Blocks::execute_callback(
			array(
				'post_id' => $post_id,
				'blocks'  => $bad_tree,
				'reason'  => 'phpunit block reject',
			)
		);
		$this->assertInstanceOf( \WP_Error::class, $result );
		$this->assertSame( 'unknown_block', $result->get_error_code() );

		$this->assertSame( 'untouched', get_post( $post_id )->post_content );
	}

	public function test_post_not_found_returns_structured_error(): void {
		$result = Post_Set_Blocks::execute_callback(
			array(
				'post_id' => 99999999,
				'blocks'  => array(),
				'reason'  => 'phpunit missing post',
			)
		);
		$this->assertInstanceOf( \WP_Error::class, $result );
		$this->assertSame( 'ppok_post_not_found', $result->get_error_code() );
	}
}
