<?php
/**
 * Spec for ppok/replace-in-content (block-aware bulk find/replace).
 *
 * Covers the LLM-bug-report verification checklist:
 *  - dry_run=true previews matches without writing
 *  - dry_run=false applies and re-run reports zero matches
 *  - case_sensitive default true, opt-out works
 *  - block delimiters / shortcodes survive (round-trip safe)
 *  - L4 scope and edit_post cap enforced on apply
 *
 * @package Ppok\AIToolkit\Tests
 * @license GPL-2.0-or-later
 */

namespace Ppok\AIToolkit\Tests;

use Ppok\AIToolkit\Abilities\Replace_In_Content;
use Ppok\AIToolkit\Abilities\Scope_Gate;

class Test_Replace_In_Content extends \WP_UnitTestCase {

	protected function setUp(): void {
		parent::setUp();
		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();
	}

	private function block_post( string $marker, string $body = 'needle goes here' ): int {
		$content = "<!-- wp:paragraph -->\n<p>{$marker} {$body}</p>\n<!-- /wp:paragraph -->";
		return self::factory()->post->create(
			array(
				'post_status'  => 'publish',
				'post_content' => $content,
				'post_title'   => 'Marker ' . $marker,
			)
		);
	}

	public function test_slug_and_write_flag(): void {
		$this->assertSame( 'ppok/replace-in-content', Replace_In_Content::SLUG );
		$this->assertTrue( Replace_In_Content::descriptor()['meta']['ppok']['write'] );
	}

	public function test_dry_run_previews_without_writing(): void {
		$marker = 'ric-dryrun-' . wp_generate_password( 8, false );
		$ids    = array(
			$this->block_post( $marker ),
			$this->block_post( $marker ),
			$this->block_post( $marker ),
		);

		$result = Replace_In_Content::execute_callback(
			array(
				'find'    => 'needle',
				'replace' => 'haystack',
				'ids'     => $ids,
				'dry_run' => true,
				'reason'  => 'phpunit dry-run preview',
			)
		);

		$this->assertSame( 3, $result['matched_posts'] );
		$this->assertSame( 3, $result['total_replacements'] );
		$this->assertFalse( $result['applied'] );
		$this->assertCount( 3, $result['changes'] );
		$first = $result['changes'][0];
		$this->assertArrayHasKey( 'preview', $first );
		$this->assertNotEmpty( $first['preview'] );
		$this->assertStringContainsString( 'needle', $first['preview'][0]['before'] );
		$this->assertStringContainsString( 'haystack', $first['preview'][0]['after'] );

		// Nothing was actually written.
		foreach ( $ids as $pid ) {
			$this->assertStringContainsString( 'needle', get_post( $pid )->post_content );
			$this->assertStringNotContainsString( 'haystack', get_post( $pid )->post_content );
		}
	}

	public function test_apply_writes_and_rerun_reports_zero(): void {
		$marker = 'ric-apply-' . wp_generate_password( 8, false );
		$ids    = array(
			$this->block_post( $marker ),
			$this->block_post( $marker ),
		);

		$apply = Replace_In_Content::execute_callback(
			array(
				'find'    => 'needle',
				'replace' => 'haystack',
				'ids'     => $ids,
				'dry_run' => false,
				'reason'  => 'phpunit apply pass',
			)
		);
		$this->assertTrue( $apply['applied'] );
		$this->assertSame( 2, $apply['matched_posts'] );

		// Re-run on the same set: zero matches now.
		$rerun = Replace_In_Content::execute_callback(
			array(
				'find'    => 'needle',
				'replace' => 'haystack',
				'ids'     => $ids,
				'dry_run' => true,
				'reason'  => 'phpunit rerun verify',
			)
		);
		$this->assertSame( 0, $rerun['matched_posts'] );
		$this->assertSame( 0, $rerun['total_replacements'] );
		$this->assertSame( array(), $rerun['changes'] );
	}

	public function test_block_delimiters_survive_round_trip(): void {
		// The whole point of going block-aware: the wp:paragraph delimiter
		// must NOT be disturbed even when the find string would otherwise
		// straddle adjacent comment boundaries.
		$id = self::factory()->post->create(
			array(
				'post_status'  => 'publish',
				'post_content' => "<!-- wp:paragraph -->\n<p>before needle after</p>\n<!-- /wp:paragraph -->",
			)
		);

		Replace_In_Content::execute_callback(
			array(
				'find'    => 'needle',
				'replace' => 'haystack',
				'ids'     => array( $id ),
				'dry_run' => false,
				'reason'  => 'phpunit delim safety',
			)
		);

		$content = get_post( $id )->post_content;
		$this->assertStringContainsString( '<!-- wp:paragraph -->', $content, 'Opening delimiter must survive.' );
		$this->assertStringContainsString( '<!-- /wp:paragraph -->', $content, 'Closing delimiter must survive.' );
		$this->assertStringContainsString( 'haystack', $content );
		$this->assertStringNotContainsString( 'needle', $content );
	}

	public function test_case_insensitive_match_when_opted_out(): void {
		$id = self::factory()->post->create(
			array(
				'post_status'  => 'publish',
				'post_content' => "<!-- wp:paragraph -->\n<p>NEEDLE in caps</p>\n<!-- /wp:paragraph -->",
			)
		);

		$ci = Replace_In_Content::execute_callback(
			array(
				'find'           => 'needle',
				'replace'        => 'haystack',
				'ids'            => array( $id ),
				'case_sensitive' => false,
				'dry_run'        => false,
				'reason'         => 'phpunit case insensitive',
			)
		);
		$this->assertSame( 1, $ci['matched_posts'] );
		$this->assertStringContainsString( 'haystack', get_post( $id )->post_content );
	}

	public function test_case_sensitive_default_skips_wrong_case(): void {
		$id = self::factory()->post->create(
			array(
				'post_status'  => 'publish',
				'post_content' => "<!-- wp:paragraph -->\n<p>NEEDLE in caps</p>\n<!-- /wp:paragraph -->",
			)
		);

		$cs = Replace_In_Content::execute_callback(
			array(
				'find'    => 'needle',
				'replace' => 'haystack',
				'ids'     => array( $id ),
				'dry_run' => true,
				'reason'  => 'phpunit case sensitive default',
			)
		);
		$this->assertSame( 0, $cs['matched_posts'] );
	}

	public function test_apply_skips_post_outside_scope_with_per_change_error(): void {
		update_option( Scope_Gate::OPTION_KEY, array( 'write_post_types' => array( 'post' ) ) );

		$page_id = self::factory()->post->create(
			array(
				'post_status'  => 'publish',
				'post_type'    => 'page',
				'post_content' => "<!-- wp:paragraph -->\n<p>needle here</p>\n<!-- /wp:paragraph -->",
			)
		);
		$before = get_post( $page_id )->post_content;

		$result = Replace_In_Content::execute_callback(
			array(
				'find'    => 'needle',
				'replace' => 'haystack',
				'ids'     => array( $page_id ),
				'dry_run' => false,
				'reason'  => 'phpunit scope skip',
			)
		);
		$this->assertTrue( $result['applied'] );
		$this->assertSame( 1, $result['matched_posts'], 'Match still counted — only the WRITE is skipped.' );
		$this->assertSame( 'scope_blocked', $result['changes'][0]['error'] );
		$this->assertSame( $before, get_post( $page_id )->post_content );
	}

	public function test_empty_find_returns_validation_error(): void {
		$result = Replace_In_Content::execute_callback(
			array(
				'find'    => '',
				'replace' => 'x',
				'ids'     => array( 1 ),
				'reason'  => 'phpunit empty find',
			)
		);
		$this->assertInstanceOf( \WP_Error::class, $result );
		$this->assertSame( 'rest_invalid_param', $result->get_error_code() );
	}

	public function test_permission_callback_requires_edit_posts(): void {
		$sub = self::factory()->user->create( array( 'role' => 'subscriber' ) );
		wp_set_current_user( $sub );
		$this->assertFalse( Replace_In_Content::permission_callback() );

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

	// ---------------------------------------------------------------------
	// Bug 1: oneOf → anyOf — post_type accepts string OR array per WP REST.
	// Caught live against thrivecos 2026-05-24: oneOf produced
	// "matches more than one of the expected formats" on valid string input.
	// ---------------------------------------------------------------------

	public function test_schema_accepts_post_type_as_string(): void {
		$schema = Replace_In_Content::input_schema();
		$value  = array(
			'find'      => 'x',
			'replace'   => 'y',
			'reason'    => 'phpunit string post_type',
			'post_type' => 'post',
		);
		$this->assertTrue( rest_validate_value_from_schema( $value, $schema ) );
	}

	public function test_schema_accepts_post_type_as_array(): void {
		$schema = Replace_In_Content::input_schema();
		$value  = array(
			'find'      => 'x',
			'replace'   => 'y',
			'reason'    => 'phpunit array post_type',
			'post_type' => array( 'post', 'page' ),
		);
		$this->assertTrue( rest_validate_value_from_schema( $value, $schema ) );
	}

	public function test_schema_accepts_post_status_as_string_and_array(): void {
		$schema = Replace_In_Content::input_schema();
		$str = array( 'find' => 'x', 'replace' => 'y', 'reason' => 'phpunit str status', 'post_status' => 'draft' );
		$arr = array( 'find' => 'x', 'replace' => 'y', 'reason' => 'phpunit arr status', 'post_status' => array( 'draft', 'publish' ) );
		$this->assertTrue( rest_validate_value_from_schema( $str, $schema ) );
		$this->assertTrue( rest_validate_value_from_schema( $arr, $schema ) );
	}

	// ---------------------------------------------------------------------
	// Bug 2: attrs_text_keys opt-in walk
	// ---------------------------------------------------------------------

	private function attrs_block_post( string $marker ): int {
		// Build a block whose user-facing text lives entirely in attrs
		// (mirrors Gutenbricks / ACF Blocks shapes seen on thrivecos).
		// innerContent is empty — replacing it with str_replace would find nothing.
		$block_markup = '<!-- wp:custom/embed ' . wp_json_encode(
			array(
				'data' => array(
					'text'       => $marker . ' attr text needle',
					'alt'        => $marker . ' image alt needle',
					'background' => '#ffffff',
					'slides'     => array(
						array( 'text' => $marker . ' nested needle' ),
					),
				),
			)
		) . ' /-->';
		return self::factory()->post->create(
			array(
				'post_status'  => 'publish',
				'post_content' => $block_markup,
				'post_title'   => $marker,
			)
		);
	}

	public function test_default_path_does_not_walk_attrs(): void {
		$marker = 'attrs-default-' . wp_generate_password( 8, false );
		$id     = $this->attrs_block_post( $marker );

		$result = Replace_In_Content::execute_callback(
			array(
				'find'    => 'needle',
				'replace' => 'haystack',
				'ids'     => array( $id ),
				'dry_run' => true,
				'reason'  => 'phpunit attrs default (off)',
			)
		);
		$this->assertSame( 0, $result['matched_posts'], 'Default attrs traversal must remain OFF — innerContent has no matches here.' );
	}

	public function test_attrs_text_keys_finds_string_values_at_matching_keys(): void {
		$marker = 'attrs-on-' . wp_generate_password( 8, false );
		$id     = $this->attrs_block_post( $marker );

		$result = Replace_In_Content::execute_callback(
			array(
				'find'            => 'needle',
				'replace'         => 'haystack',
				'ids'             => array( $id ),
				'attrs_text_keys' => array( 'text', 'alt' ),
				'dry_run'         => true,
				'reason'          => 'phpunit attrs walk',
			)
		);
		// "text" appears at top-level data.text AND inside data.slides[0].text;
		// "alt" appears once. So 3 matches total.
		$this->assertSame( 1, $result['matched_posts'] );
		$this->assertSame( 3, $result['total_replacements'] );
		$contexts = array_column( $result['changes'][0]['preview'], 'context' );
		// At least one preview entry must reference the attrs path.
		$this->assertTrue(
			(bool) array_filter( $contexts, static fn ( string $c ) => str_contains( $c, '.attrs.' ) ),
			'Preview snippets must label attrs-sourced matches with the attrs path.'
		);
	}

	public function test_attrs_text_keys_ignores_non_string_values_at_matching_keys(): void {
		// Build a block where the matching key holds a NUMBER (or array). The walker
		// must skip it without crashing or counting.
		$marker = 'attrs-skipnum-' . wp_generate_password( 8, false );
		$block_markup = '<!-- wp:custom/embed ' . wp_json_encode(
			array(
				'data' => array(
					'text'  => 99999, // number, not string — must be skipped
					'count' => 5,
				),
			)
		) . ' /-->';
		$id = self::factory()->post->create(
			array(
				'post_status'  => 'publish',
				'post_content' => $block_markup,
				'post_title'   => $marker,
			)
		);

		$result = Replace_In_Content::execute_callback(
			array(
				'find'            => '9',
				'replace'         => '0',
				'ids'             => array( $id ),
				'attrs_text_keys' => array( 'text' ),
				'dry_run'         => true,
				'reason'          => 'phpunit attrs skip non-string',
			)
		);
		$this->assertSame( 0, $result['matched_posts'], 'Numeric values at allowed keys must be skipped — no string mutation possible.' );
	}

	public function test_attrs_apply_writes_serialize_back_cleanly(): void {
		$marker = 'attrs-apply-' . wp_generate_password( 8, false );
		$id     = $this->attrs_block_post( $marker );

		$apply = Replace_In_Content::execute_callback(
			array(
				'find'            => 'needle',
				'replace'         => 'haystack',
				'ids'             => array( $id ),
				'attrs_text_keys' => array( 'text', 'alt' ),
				'dry_run'         => false,
				'reason'          => 'phpunit attrs apply round-trip',
			)
		);
		$this->assertTrue( $apply['applied'] );
		$this->assertSame( 1, $apply['matched_posts'] );

		// Re-parse the post and confirm the attrs were mutated and the block
		// markup still parses cleanly (no broken delimiters / JSON).
		$reparsed = parse_blocks( (string) get_post( $id )->post_content );
		$this->assertCount( 1, $reparsed );
		$this->assertSame( 'custom/embed', $reparsed[0]['blockName'] );
		$this->assertStringContainsString( 'haystack', $reparsed[0]['attrs']['data']['text'] );
		$this->assertStringNotContainsString( 'needle', $reparsed[0]['attrs']['data']['text'] );
		$this->assertStringContainsString( 'haystack', $reparsed[0]['attrs']['data']['slides'][0]['text'] );
	}

	public function test_attrs_apply_leaves_non_allowlisted_keys_alone(): void {
		// "background" should NOT be replaced even though it's a string in attrs,
		// because it isn't in attrs_text_keys.
		$marker = 'attrs-allow-' . wp_generate_password( 8, false );
		$block_markup = '<!-- wp:custom/embed ' . wp_json_encode(
			array(
				'data' => array(
					'text'       => $marker . ' needle here',
					'background' => 'needle-themed-color',
				),
			)
		) . ' /-->';
		$id = self::factory()->post->create(
			array(
				'post_status'  => 'publish',
				'post_content' => $block_markup,
				'post_title'   => $marker,
			)
		);

		Replace_In_Content::execute_callback(
			array(
				'find'            => 'needle',
				'replace'         => 'haystack',
				'ids'             => array( $id ),
				'attrs_text_keys' => array( 'text' ),
				'dry_run'         => false,
				'reason'          => 'phpunit attrs key-scoped',
			)
		);

		$reparsed = parse_blocks( (string) get_post( $id )->post_content );
		$this->assertStringContainsString( 'haystack', $reparsed[0]['attrs']['data']['text'] );
		$this->assertSame( 'needle-themed-color', $reparsed[0]['attrs']['data']['background'], 'Keys outside attrs_text_keys must be untouched.' );
	}
}
