<?php
/**
 * Post Scanner.
 *
 * Given a target phrase + orphan URL, returns the published posts/pages
 * that contain the phrase in their content. Filtering:
 *
 *   - Only `publish` status, only `post` + `page` post types (filterable)
 *   - Excludes the orphan post itself (self-reference)
 *   - Excludes posts that already link to the orphan URL (already-linked)
 *   - Excludes draft / pending / future / trash
 *
 * Search covers both `post_content` and `_elementor_data` postmeta so
 * Elementor-built pages are included.
 *
 * @package Platform\Helm\Modules\Link_Architect
 */

declare( strict_types = 1 );

namespace Platform\Helm\Modules\Link_Architect;

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

final class PostScanner {

	private const DEFAULT_MAX_MATCHES = 10;

	/**
	 * Count how many published posts/pages contain $phrase. Cheap — used
	 * to display the "47 posts contain this phrase" hint in the UI before
	 * the user picks a candidate.
	 */
	public function count_matches( string $phrase, int $exclude_post_id = 0, string $orphan_url = '' ): int {
		$ids = $this->find_matching_post_ids( $phrase, $exclude_post_id, $orphan_url, 0 );
		return count( $ids );
	}

	/**
	 * Find candidate source posts that contain $phrase in their content.
	 *
	 * @return array<int,array{id:int,title:string,post_type:string,edit_link:string,excerpt:string}>
	 */
	public function find_source_posts( string $phrase, int $exclude_post_id = 0, string $orphan_url = '', int $limit = self::DEFAULT_MAX_MATCHES ): array {
		$ids = $this->find_matching_post_ids( $phrase, $exclude_post_id, $orphan_url, max( 1, $limit ) );
		if ( empty( $ids ) ) {
			return [];
		}
		$out = [];
		foreach ( $ids as $id ) {
			$post = get_post( $id );
			if ( ! $post instanceof \WP_Post ) {
				continue;
			}
			$out[] = [
				'id'        => (int) $post->ID,
				'title'     => '' !== $post->post_title ? $post->post_title : __( '(no title)', 'platform-helm' ),
				'post_type' => (string) $post->post_type,
				'edit_link' => (string) get_edit_post_link( $post->ID ),
				'excerpt'   => $this->build_excerpt( $post->post_content, $phrase ),
			];
		}
		return $out;
	}

	/* =========================================================================
	 * INTERNALS
	 * ========================================================================= */

	/**
	 * @return int[]
	 */
	private function find_matching_post_ids( string $phrase, int $exclude_post_id, string $orphan_url, int $limit ): array {
		global $wpdb;

		$phrase = trim( $phrase );
		if ( '' === $phrase ) {
			return [];
		}

		$post_types = $this->allowed_post_types();
		if ( empty( $post_types ) ) {
			return [];
		}

		$type_placeholders = implode( ',', array_fill( 0, count( $post_types ), '%s' ) );
		$phrase_like       = '%' . $wpdb->esc_like( $phrase ) . '%';

		// Find posts whose content contains the phrase.
		$sql = "SELECT p.ID
			FROM {$wpdb->posts} p
			WHERE p.post_status = 'publish'
			  AND p.post_type IN ({$type_placeholders})
			  AND p.post_content LIKE %s
			ORDER BY p.post_date DESC";

		$args = array_merge( $post_types, [ $phrase_like ] );

		if ( $limit > 0 ) {
			// We will UNION + dedupe with Elementor-only matches, so pull a bigger
			// candidate pool here then trim later.
			$sql .= ' LIMIT %d';
			$args[] = $limit * 3;
		}

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared
		$content_ids = $wpdb->get_col( $wpdb->prepare( $sql, $args ) );
		$content_ids = is_array( $content_ids ) ? array_map( 'intval', $content_ids ) : [];

		// Also find posts whose Elementor data contains the phrase but post_content does NOT.
		// (Elementor pages often have post_content empty.)
		$em_sql = "SELECT pm.post_id
			FROM {$wpdb->postmeta} pm
			INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
			WHERE pm.meta_key = '_elementor_data'
			  AND pm.meta_value LIKE %s
			  AND p.post_status = 'publish'
			  AND p.post_type IN ({$type_placeholders})";

		$em_args = array_merge( [ $phrase_like ], $post_types );

		if ( $limit > 0 ) {
			$em_sql .= ' LIMIT %d';
			$em_args[] = $limit * 3;
		}

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared
		$em_ids = $wpdb->get_col( $wpdb->prepare( $em_sql, $em_args ) );
		$em_ids = is_array( $em_ids ) ? array_map( 'intval', $em_ids ) : [];

		// Merge + dedupe.
		$all_ids = array_values( array_unique( array_merge( $content_ids, $em_ids ) ) );

		// Filter: self-reference + already-linked + image-only matches.
		$filtered = [];
		foreach ( $all_ids as $id ) {
			if ( $id === $exclude_post_id ) {
				continue;
			}
			if ( '' !== $orphan_url && $this->already_links_to( $id, $orphan_url ) ) {
				continue;
			}
			// Anti-image-only match: if the phrase only appears in <img> alt
			// attributes, image filenames, or background_image settings, the
			// LinkInserter would never be able to wrap it (alt is not a text
			// node). Reject these candidates early so the moderator doesn't
			// approve suggestions that will all skip at execute time.
			// This is what Link Whisper gets wrong and what frustrates the user.
			if ( ! $this->phrase_in_real_text( $id, $phrase ) ) {
				continue;
			}
			$filtered[] = $id;
			if ( $limit > 0 && count( $filtered ) >= $limit ) {
				break;
			}
		}
		return $filtered;
	}

	/**
	 * Confirm the phrase appears in actual editable text content — not just
	 * in image attributes, filenames, or other non-text positions where
	 * we'd never be able to wrap an anchor.
	 */
	private function phrase_in_real_text( int $post_id, string $phrase ): bool {
		$post = get_post( $post_id );
		if ( ! $post instanceof \WP_Post ) {
			return false;
		}

		// Strategy: strip <img> tags entirely, then strip remaining HTML.
		// If the phrase survives, it lives in real text.
		$content_stripped = $this->strip_image_tags( (string) $post->post_content );
		$plain            = wp_strip_all_tags( $content_stripped );
		if ( '' !== $plain && false !== stripos( $plain, $phrase ) ) {
			return true;
		}

		// Same check on Elementor data — strip image-related JSON properties
		// before searching.
		$em = (string) get_post_meta( $post_id, '_elementor_data', true );
		if ( '' !== $em ) {
			$em_stripped       = $this->strip_image_json_props( $em );
			$em_plain          = wp_strip_all_tags( $em_stripped );
			if ( '' !== $em_plain && false !== stripos( $em_plain, $phrase ) ) {
				return true;
			}
		}
		return false;
	}

	private function strip_image_tags( string $html ): string {
		// Remove <img ...> entirely. Also handle <picture>, <source>, and
		// figure captions that just describe images.
		$cleaned = (string) preg_replace( '/<img\b[^>]*>/i', ' ', $html );
		$cleaned = (string) preg_replace( '/<source\b[^>]*\/?>/i', ' ', $cleaned );
		return $cleaned;
	}

	private function strip_image_json_props( string $json ): string {
		// Elementor stores images as JSON objects like:
		//   "image":{"id":..., "url":"...", "alt":"..."}
		// Strip these out so phrase matches in alt/url don't count.
		$cleaned = (string) preg_replace( '/"(?:image|background_image|background_overlay_image|_background_image)"\s*:\s*\{[^{}]*\}/i', '""', $json );
		// Also strip any standalone "alt" properties on image-like keys.
		$cleaned = (string) preg_replace( '/"alt"\s*:\s*"[^"]*"/i', '""', $cleaned );
		return $cleaned;
	}

	/**
	 * Does the post already contain an `<a href="$url">` (in content or
	 * Elementor data)? Surfaces in two forms: with or without query string.
	 */
	private function already_links_to( int $post_id, string $url ): bool {
		global $wpdb;
		// Cheap: search both columns at once with $url as LIKE pattern. The
		// chance of $url appearing as plain text (not as href) is small.
		$pattern = '%' . $wpdb->esc_like( $url ) . '%';
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared
		$found = $wpdb->get_var( $wpdb->prepare(
			"SELECT 1 FROM {$wpdb->posts}
			 WHERE ID = %d AND post_content LIKE %s
			 LIMIT 1",
			$post_id,
			$pattern
		) );
		if ( ! empty( $found ) ) {
			return true;
		}
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared
		$em_found = $wpdb->get_var( $wpdb->prepare(
			"SELECT 1 FROM {$wpdb->postmeta}
			 WHERE post_id = %d
			   AND meta_key = '_elementor_data'
			   AND meta_value LIKE %s
			 LIMIT 1",
			$post_id,
			$pattern
		) );
		return ! empty( $em_found );
	}

	/**
	 * @return string[]
	 */
	private function allowed_post_types(): array {
		/**
		 * Filter which post types Link Architect scans for source posts.
		 *
		 * @param string[] $types
		 */
		$types = apply_filters( 'platform_helm_link_architect_source_types', [ 'post', 'page' ] );
		return is_array( $types ) ? array_values( array_filter( $types, 'is_string' ) ) : [];
	}

	/**
	 * Build a short excerpt around the first phrase match. Used in the
	 * suggestions UI to give a preview of where the link will land.
	 */
	private function build_excerpt( string $content, string $phrase ): string {
		$plain = wp_strip_all_tags( $content );
		if ( '' === $plain ) {
			return '';
		}
		$pos = stripos( $plain, $phrase );
		if ( false === $pos ) {
			// Phrase not in post_content — probably in Elementor data. Skip excerpt.
			return '';
		}
		$start  = max( 0, $pos - 60 );
		$length = min( strlen( $plain ) - $start, 180 );
		$out    = substr( $plain, $start, $length );
		if ( $start > 0 ) {
			$out = '…' . ltrim( $out );
		}
		if ( $start + $length < strlen( $plain ) ) {
			$out = rtrim( $out ) . '…';
		}
		return (string) preg_replace( '/\s+/u', ' ', $out );
	}
}
