<?php
/**
 * ppok/replace-in-content — bulk block-aware find/replace.
 *
 * Operates on parse_blocks() trees, not raw post_content, so Gutenberg
 * delimiters / shortcode HTML comments / embed markers are never disturbed.
 * For each candidate post: parse the block tree, walk it, run the find/
 * replace against innerHTML + innerContent of every block, and (if not in
 * dry_run mode) serialize the tree back and wp_update_post().
 *
 * Defaults to dry_run=true: the caller must explicitly pass dry_run=false
 * to actually mutate content. The dry-run path measures matches and emits
 * up to MAX_PREVIEWS_PER_POST before/after snippets per post so the agent
 * can verify intent before committing.
 *
 * Gating:
 *   L1 capability — edit_posts to call at all; edit_post per ID before each write
 *   L2 schema     — find/replace length bounds, dry_run boolean, etc.
 *   L4 scope      — per-post Scope_Gate (post_type + post_status) on writes
 *   L5 audit      — Ability_Registrar wrapper records one row per call
 *
 * Capped at MAX_POSTS_PER_CALL candidates so a stray broad query cannot
 * rewrite the entire corpus in one shot; pass the ids array to address
 * larger batches in chunks.
 *
 * @package Ppok\AIToolkit
 * @license GPL-2.0-or-later
 */

namespace Ppok\AIToolkit\Abilities;

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

final class Replace_In_Content {

	public const SLUG = 'ppok/replace-in-content';

	private const MAX_POSTS_PER_CALL    = 500;
	private const MAX_PREVIEWS_PER_POST = 3;
	private const PREVIEW_CONTEXT_CHARS = 40;

	public static function descriptor(): array {
		return array(
			'label'               => __( 'Find and replace in post content', 'ppok-ai-toolkit' ),
			'description'         => __( 'Block-aware bulk find/replace across posts. Operates on parsed block trees so Gutenberg delimiters and shortcodes are never broken. dry_run defaults to TRUE — the call returns a preview without writing; pass dry_run=false to apply. Scope defaults to {post_type: any public type, post_status: ["publish"]}; pass ids[] to restrict. Capped at 500 candidate posts per call.', '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(
			'find'           => 'old phrase',
			'replace'        => 'new phrase',
			'post_status'    => array( 'publish' ),
			'case_sensitive' => true,
			'dry_run'        => true,
			'reason'         => 'Preview before swapping the brand line site-wide.',
		);
	}

	public static function input_schema(): array {
		return array(
			'type'                 => 'object',
			'additionalProperties' => false,
			'required'             => array( 'find', 'replace', 'reason' ),
			'properties'           => array(
				'find'           => array(
					'type'      => 'string',
					'minLength' => 1,
					'maxLength' => 500,
				),
				'replace'        => array(
					'type'      => 'string',
					'minLength' => 0,
					'maxLength' => 500,
				),
				'post_type'      => array(
					'description' => 'Single string or array of strings. Default: all public post types.',
					// anyOf, not oneOf — WP's rest_validate_value_from_schema mishandles oneOf
					// here and rejects valid string input with "matches more than one of the
					// expected formats" (verified against live thrivecos site 2026-05-24).
					'anyOf'       => array(
						array( 'type' => 'string' ),
						array( 'type' => 'array', 'items' => array( 'type' => 'string' ) ),
					),
				),
				'post_status'    => array(
					'description' => 'Single string or array. Default: ["publish"].',
					'anyOf'       => array(
						array( 'type' => 'string' ),
						array( 'type' => 'array', 'items' => array( 'type' => 'string' ) ),
					),
				),
				'ids'            => array(
					'type'  => 'array',
					'items' => array( 'type' => 'integer', 'minimum' => 1 ),
				),
				'attrs_text_keys' => array(
					'type'        => 'array',
					'items'       => array( 'type' => 'string' ),
					'description' => 'Opt-in. Key names whose STRING values inside block attrs (recursively) should also be subjected to find/replace. Default: [] — only innerContent is walked. Use this on sites where user-facing text lives in attrs rather than innerHTML, e.g. Gutenbricks ({"text","alt","embed_code"}), ACF Blocks ({"title","subheading"}), Spectra, etc. Review the dry_run preview before applying — keys can shadow structural fields if you pick names like "id" or "name".',
					'default'     => array(),
				),
				'case_sensitive' => array(
					'type'    => 'boolean',
					'default' => true,
				),
				'dry_run'        => array(
					'type'    => 'boolean',
					'default' => true,
				),
				'reason'         => array(
					'type'      => 'string',
					'minLength' => 8,
					'maxLength' => 500,
				),
			),
		);
	}

	public static function output_schema(): array {
		return array(
			'type'       => 'object',
			'required'   => array( 'matched_posts', 'total_replacements', 'changes', 'applied' ),
			'properties' => array(
				'matched_posts'      => array( 'type' => 'integer' ),
				'total_replacements' => array( 'type' => 'integer' ),
				'changes'            => array(
					'type'  => 'array',
					'items' => array(
						'type'       => 'object',
						'properties' => array(
							'post_id'      => array( 'type' => 'integer' ),
							'post_title'   => array( 'type' => 'string' ),
							'replacements' => array( 'type' => 'integer' ),
							'preview'      => array(
								'type'  => 'array',
								'items' => array(
									'type'       => 'object',
									'properties' => array(
										'before'  => array( 'type' => 'string' ),
										'after'   => array( 'type' => 'string' ),
										'context' => array( 'type' => 'string' ),
									),
								),
							),
							'error'        => array(
								'type'        => 'string',
								'description' => 'Present only when this post was skipped during apply (cap or scope rejection). The preview block still reflects the would-be change.',
							),
						),
					),
				),
				'applied'            => array( 'type' => 'boolean' ),
				'truncated'          => array(
					'type'        => 'boolean',
					'description' => 'True when more posts matched than MAX_POSTS_PER_CALL. Pass an ids[] subset to widen.',
				),
			),
		);
	}

	public static function permission_callback( array $args = array() ): bool {
		// edit_posts is the operation-level floor. Per-post edit_post is
		// re-checked inside execute_callback before each individual write.
		return current_user_can( 'edit_posts' );
	}

	public static function execute_callback( array $args = array() ) {
		$find            = (string) ( $args['find'] ?? '' );
		$replace         = (string) ( $args['replace'] ?? '' );
		$case_sensitive  = isset( $args['case_sensitive'] ) ? (bool) $args['case_sensitive'] : true;
		$dry_run         = isset( $args['dry_run'] ) ? (bool) $args['dry_run'] : true;
		$attrs_text_keys = isset( $args['attrs_text_keys'] ) && is_array( $args['attrs_text_keys'] )
			? array_values( array_filter( array_map( 'strval', $args['attrs_text_keys'] ), 'strlen' ) )
			: array();

		if ( '' === $find ) {
			return new \WP_Error(
				'rest_invalid_param',
				__( 'find must be a non-empty string.', 'ppok-ai-toolkit' ),
				array( 'status' => 400 )
			);
		}

		$candidate_ids = self::resolve_candidates( $args );
		$truncated     = false;
		if ( count( $candidate_ids ) > self::MAX_POSTS_PER_CALL ) {
			$candidate_ids = array_slice( $candidate_ids, 0, self::MAX_POSTS_PER_CALL );
			$truncated     = true;
		}

		$changes            = array();
		$matched_posts      = 0;
		$total_replacements = 0;

		foreach ( $candidate_ids as $post_id ) {
			$post = get_post( $post_id );
			if ( ! $post instanceof \WP_Post ) {
				continue;
			}

			$blocks  = parse_blocks( (string) $post->post_content );
			$count   = 0;
			$preview = array();
			$mutated = self::transform_tree( $blocks, $find, $replace, $case_sensitive, $attrs_text_keys, $count, $preview );

			if ( 0 === $count ) {
				continue;
			}

			++$matched_posts;
			$total_replacements += $count;

			$change = array(
				'post_id'      => $post_id,
				'post_title'   => (string) $post->post_title,
				'replacements' => $count,
				'preview'      => $preview,
			);

			if ( ! $dry_run ) {
				// Per-post capability + scope check before each write.
				if ( ! current_user_can( 'edit_post', $post_id ) ) {
					$change['error'] = 'permission_denied';
					$changes[]       = $change;
					continue;
				}
				$scope_type   = Scope_Gate::check_post_type( (string) $post->post_type );
				$scope_status = Scope_Gate::check_post_status( (string) $post->post_status );
				if ( is_wp_error( $scope_type ) || is_wp_error( $scope_status ) ) {
					$change['error'] = 'scope_blocked';
					$changes[]       = $change;
					continue;
				}

				$serialized = serialize_blocks( $mutated );
				$update     = wp_update_post(
					array(
						'ID'           => $post_id,
						'post_content' => $serialized,
					),
					true
				);
				if ( is_wp_error( $update ) ) {
					$change['error'] = $update->get_error_code();
				}
			}

			$changes[] = $change;
		}

		$out = array(
			'matched_posts'      => $matched_posts,
			'total_replacements' => $total_replacements,
			'changes'            => $changes,
			'applied'            => ! $dry_run,
		);
		if ( $truncated ) {
			$out['truncated'] = true;
		}
		return $out;
	}

	/**
	 * Resolve the post IDs the call should touch. If ids[] is provided it
	 * wins (no query). Otherwise WP_Query selects by post_type + post_status
	 * with fields=ids; published posts only by default.
	 *
	 * @param array<string, mixed> $args
	 * @return array<int, int>
	 */
	private static function resolve_candidates( array $args ): array {
		if ( isset( $args['ids'] ) && is_array( $args['ids'] ) ) {
			return array_values(
				array_filter(
					array_map( 'intval', $args['ids'] ),
					static function ( int $id ) {
						return $id > 0;
					}
				)
			);
		}

		$post_type = self::as_string_list( $args['post_type'] ?? null );
		if ( empty( $post_type ) ) {
			$post_type = array_values( get_post_types( array( 'public' => true ) ) );
		}
		$post_status = self::as_string_list( $args['post_status'] ?? null );
		if ( empty( $post_status ) ) {
			$post_status = array( 'publish' );
		}

		$query = new \WP_Query(
			array(
				'post_type'              => $post_type,
				'post_status'            => $post_status,
				'posts_per_page'         => self::MAX_POSTS_PER_CALL + 1,
				'fields'                 => 'ids',
				'no_found_rows'          => true,
				'update_post_meta_cache' => false,
				'update_post_term_cache' => false,
				'ignore_sticky_posts'    => true,
			)
		);
		return array_map( 'intval', $query->posts );
	}

	/**
	 * @param mixed $value
	 * @return array<int, string>
	 */
	private static function as_string_list( $value ): array {
		if ( null === $value ) {
			return array();
		}
		if ( is_string( $value ) ) {
			$value = array( $value );
		}
		if ( ! is_array( $value ) ) {
			return array();
		}
		return array_values( array_filter( array_map( 'strval', $value ), 'strlen' ) );
	}

	/**
	 * Walk the parse_blocks() tree, mutating innerHTML + innerContent on
	 * every block (recursing into innerBlocks). $count and $preview are
	 * accumulated by reference so callers can inspect them after the walk.
	 *
	 * @param array<int, mixed>          $blocks
	 * @param array<int, array<string, string>> $preview
	 */
	/**
	 * @param array<int, mixed> $blocks
	 * @param array<int, string> $attrs_text_keys
	 * @param array<int, array<string, string>> $preview
	 */
	private static function transform_tree( array $blocks, string $find, string $replace, bool $case_sensitive, array $attrs_text_keys, int &$count, array &$preview, string $path_prefix = '' ): array {
		foreach ( $blocks as $index => &$block ) {
			if ( ! is_array( $block ) ) {
				continue;
			}
			$block_path = ( '' === $path_prefix ? '' : $path_prefix . '/' ) . self::block_label( $block, $index );

			// serialize_blocks() reads innerContent exclusively; innerHTML
			// is parse-time scaffolding only. Mutating both would double-
			// count every match, so we only touch innerContent here.
			if ( isset( $block['innerContent'] ) && is_array( $block['innerContent'] ) ) {
				foreach ( $block['innerContent'] as &$chunk ) {
					if ( is_string( $chunk ) ) {
						$chunk = self::apply_one( $chunk, $find, $replace, $case_sensitive, $count, $preview, $block_path );
					}
				}
				unset( $chunk );
			}

			// Opt-in: walk block attrs for string values whose key matches
			// the caller-supplied attrs_text_keys allowlist. Off by default
			// to keep the original innerContent-only semantics; enabled when
			// the agent knows the site stores user-facing text in attrs
			// (Gutenbricks, ACF Blocks, etc.).
			if ( ! empty( $attrs_text_keys ) && isset( $block['attrs'] ) && is_array( $block['attrs'] ) ) {
				$block['attrs'] = self::transform_attrs( $block['attrs'], $attrs_text_keys, $find, $replace, $case_sensitive, $count, $preview, $block_path . '.attrs' );
			}

			if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) {
				$block['innerBlocks'] = self::transform_tree( $block['innerBlocks'], $find, $replace, $case_sensitive, $attrs_text_keys, $count, $preview, $block_path );
			}
		}
		unset( $block );
		return $blocks;
	}

	/**
	 * Walk a block-attrs sub-tree, applying find/replace to STRING values
	 * whose key matches $allowed_keys. Recurses into nested arrays/objects.
	 * Non-string values at matching keys (numbers, booleans, sub-objects)
	 * are left alone — only strings receive the mutation.
	 *
	 * @param array<int|string, mixed> $attrs
	 * @param array<int, string>       $allowed_keys
	 * @param array<int, array<string, string>> $preview
	 */
	private static function transform_attrs( array $attrs, array $allowed_keys, string $find, string $replace, bool $case_sensitive, int &$count, array &$preview, string $context ): array {
		foreach ( $attrs as $key => &$value ) {
			$key_str  = (string) $key;
			$sub_path = $context . '.' . $key_str;
			if ( is_string( $value ) && in_array( $key_str, $allowed_keys, true ) ) {
				$value = self::apply_one( $value, $find, $replace, $case_sensitive, $count, $preview, $sub_path );
			} elseif ( is_array( $value ) ) {
				$value = self::transform_attrs( $value, $allowed_keys, $find, $replace, $case_sensitive, $count, $preview, $sub_path );
			}
		}
		unset( $value );
		return $attrs;
	}

	/**
	 * Apply the find/replace to a single string fragment, count the matches,
	 * and (if we're under the preview cap) capture a short before/after
	 * snippet so the dry-run response is self-documenting.
	 */
	private static function apply_one( string $haystack, string $find, string $replace, bool $case_sensitive, int &$count, array &$preview, string $context ): string {
		$matches = $case_sensitive
			? substr_count( $haystack, $find )
			: self::substr_count_ci( $haystack, $find );
		if ( 0 === $matches ) {
			return $haystack;
		}

		if ( count( $preview ) < self::MAX_PREVIEWS_PER_POST ) {
			$preview[] = array(
				'before'  => self::snippet( $haystack, $find, $case_sensitive ),
				'after'   => self::snippet(
					$case_sensitive ? str_replace( $find, $replace, $haystack ) : str_ireplace( $find, $replace, $haystack ),
					$replace,
					$case_sensitive
				),
				'context' => $context,
			);
		}

		$count += $matches;
		return $case_sensitive
			? str_replace( $find, $replace, $haystack )
			: str_ireplace( $find, $replace, $haystack );
	}

	private static function substr_count_ci( string $haystack, string $needle ): int {
		if ( '' === $needle ) {
			return 0;
		}
		return substr_count( strtolower( $haystack ), strtolower( $needle ) );
	}

	private static function snippet( string $haystack, string $needle, bool $case_sensitive ): string {
		if ( '' === $needle ) {
			return self::trim_to( $haystack, self::PREVIEW_CONTEXT_CHARS * 2 );
		}
		$pos = $case_sensitive ? strpos( $haystack, $needle ) : stripos( $haystack, $needle );
		if ( false === $pos ) {
			return self::trim_to( $haystack, self::PREVIEW_CONTEXT_CHARS * 2 );
		}
		$start = max( 0, $pos - self::PREVIEW_CONTEXT_CHARS );
		$len   = strlen( $needle ) + ( self::PREVIEW_CONTEXT_CHARS * 2 );
		return ( $start > 0 ? '…' : '' ) . self::trim_to( substr( $haystack, $start, $len ), $len ) . ( strlen( $haystack ) > $start + $len ? '…' : '' );
	}

	private static function trim_to( string $value, int $max ): string {
		return strlen( $value ) <= $max ? $value : substr( $value, 0, $max );
	}

	private static function block_label( array $block, int $index ): string {
		$name = isset( $block['blockName'] ) && is_string( $block['blockName'] ) && '' !== $block['blockName']
			? $block['blockName']
			: 'freeform';
		return $name . '[' . $index . ']';
	}
}
