<?php
/**
 * Locates published WordPress content that embeds built-in forms.
 *
 * @package SubmissionInbox
 */

namespace SUBIN\Forms;

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

// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- One read-only content scan avoids per-form queries.
// phpcs:disable PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table and placeholders use WordPress-owned or sanitized values.

/**
 * Batch-scans published content for built-in Submission Inbox form references.
 *
 * Locations are direct references only. Synced patterns, widgets, theme options,
 * and inherited templates are outside this service's scope.
 */
class FormLocationService {
	const SHORTCODE_TAG = 'submission_inbox_form';
	const BLOCK_NAME    = 'submission-inbox/form';
	const CACHE_KEY     = 'subin_form_locations_v1';
	const CACHE_TTL     = 300;

	/**
	 * Register cache invalidation hooks.
	 *
	 * @return void
	 */
	public function register() {
		add_action( 'save_post', array( $this, 'invalidate_cache' ), 10, 3 );
		add_action( 'trashed_post', array( $this, 'invalidate_cache' ), 10, 2 );
		add_action( 'untrashed_post', array( $this, 'invalidate_cache' ), 10, 2 );
		add_action( 'deleted_post', array( $this, 'invalidate_cache' ), 10, 2 );
	}

	/**
	 * Invalidate the global direct-reference map.
	 *
	 * Any post transition can add or remove a supported direct form reference.
	 *
	 * @param int       $post_id Post id.
	 * @param mixed     $post Optional post or hook context.
	 * @param bool|null $update Optional save context.
	 * @return void
	 */
	public function invalidate_cache( $post_id = 0, $post = null, $update = null ) {
		delete_transient( self::CACHE_KEY );
	}

	/**
	 * Locate published posts for every requested form in one content query.
	 *
	 * Invalid and zero identifiers are omitted.
	 *
	 * @param array<int,int|string> $form_ids Built-in form identifiers.
	 * @return array<int,array{locations:array<int,array{id:int,title:string,type:string,status:string,url:?string,edit_url:?string}>,count:int,orphaned:bool}>
	 */
	public function get_locations( array $form_ids ) {
		$results = $this->empty_results( $form_ids );
		if ( empty( $results ) ) {
			return array();
		}

		$location_map = $this->global_location_map();
		$post_ids     = array();
		foreach ( array_keys( $results ) as $form_id ) {
			foreach ( $location_map[ $form_id ] ?? array() as $post_id ) {
				$post_ids[ $post_id ] = true;
			}
		}
		if ( ! empty( $post_ids ) && function_exists( '_prime_post_caches' ) ) {
			_prime_post_caches( array_keys( $post_ids ), false, false );
		}

		foreach ( $results as $form_id => &$result ) {
			foreach ( $location_map[ $form_id ] ?? array() as $post_id ) {
				$post = get_post( $post_id );
				if ( $post instanceof \WP_Post && 'publish' === $post->post_status ) {
					$result['locations'][] = $this->post_location( $post );
				}
			}
			$result['count']    = count( $result['locations'] );
			$result['orphaned'] = empty( $result['locations'] );
		}
		unset( $result );

		return $results;
	}

	/**
	 * Return the short-lived global form-to-post reference map.
	 *
	 * @return array<int,array<int,int>>
	 */
	private function global_location_map() {
		$cached = get_transient( self::CACHE_KEY );
		if ( is_array( $cached ) ) {
			return $this->normalize_location_map( $cached );
		}

		$map = $this->scan_global_location_map();
		set_transient( self::CACHE_KEY, $map, self::CACHE_TTL );
		return $map;
	}

	/**
	 * Scan marker-bearing posts once and index every discovered form id.
	 *
	 * @return array<int,array<int,int>>
	 */
	private function scan_global_location_map() {
		$map = array();

		foreach ( $this->candidate_posts() as $post ) {
			if ( ! $post instanceof \WP_Post || absint( $post->ID ) <= 0 ) {
				continue;
			}
			foreach ( array_keys( $this->references_in_content( (string) $post->post_content ) ) as $form_id ) {
				if ( ! isset( $map[ $form_id ] ) ) {
					$map[ $form_id ] = array();
				}
				$map[ $form_id ][] = absint( $post->ID );
			}
		}

		ksort( $map, SORT_NUMERIC );
		return $map;
	}

	/**
	 * Defensively normalize transient data before using it in post queries.
	 *
	 * @param mixed $cached Cached value.
	 * @return array<int,array<int,int>>
	 */
	private function normalize_location_map( $cached ) {
		$map = array();

		foreach ( is_array( $cached ) ? $cached : array() as $form_id => $post_ids ) {
			$form_id = absint( $form_id );
			if ( $form_id <= 0 || ! is_array( $post_ids ) ) {
				continue;
			}

			$ids = array();
			foreach ( $post_ids as $post_id ) {
				$post_id = absint( $post_id );
				if ( $post_id > 0 ) {
					$ids[ $post_id ] = $post_id;
				}
			}
			if ( ! empty( $ids ) ) {
				sort( $ids, SORT_NUMERIC );
				$map[ $form_id ] = array_values( $ids );
			}
		}

		ksort( $map, SORT_NUMERIC );
		return $map;
	}

	/**
	 * Build one result bucket per unique valid form id.
	 *
	 * @param array<int,int|string> $form_ids Raw identifiers.
	 * @return array<int,array{locations:array<int,array<string,mixed>>,count:int,orphaned:bool}>
	 */
	private function empty_results( array $form_ids ) {
		$results = array();

		foreach ( $form_ids as $form_id ) {
			$form_id = absint( $form_id );
			if ( $form_id <= 0 || isset( $results[ $form_id ] ) ) {
				continue;
			}
			$results[ $form_id ] = array(
				'locations' => array(),
				'count'     => 0,
				'orphaned'  => true,
			);
		}

		return $results;
	}

	/**
	 * Fetch marker-bearing published public content in a single query.
	 *
	 * @return array<int,object>
	 */
	private function candidate_posts() {
		global $wpdb;

		$post_types = get_post_types( array( 'public' => true ), 'names' );
		$post_types = array_values(
			array_filter(
				array_map( 'sanitize_key', is_array( $post_types ) ? $post_types : array() ),
				static function ( $post_type ) {
					return '' !== $post_type && ! in_array( $post_type, array( 'attachment', 'revision', 'nav_menu_item' ), true );
				}
			)
		);
		if ( empty( $post_types ) ) {
			return array();
		}

		$type_placeholders = implode( ', ', array_fill( 0, count( $post_types ), '%s' ) );
		$query_args        = array_merge(
			array( 'publish' ),
			$post_types,
			array(
				'%' . $wpdb->esc_like( self::SHORTCODE_TAG ) . '%',
				'%' . $wpdb->esc_like( self::BLOCK_NAME ) . '%',
			)
		);
		$sql = $wpdb->prepare(
			"SELECT *
			FROM {$wpdb->posts}
			WHERE post_status = %s
				AND post_type IN ({$type_placeholders})
				AND (post_content LIKE %s OR post_content LIKE %s)
			ORDER BY ID ASC",
			$query_args
		);

		// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The query is prepared immediately above from registered post types and escaped LIKE values.
		$rows = $wpdb->get_results( $sql );
		if ( ! is_array( $rows ) ) {
			return array();
		}

		$posts = array_map(
			static function ( $row ) {
				return $row instanceof \WP_Post ? $row : new \WP_Post( $row );
			},
			$rows
		);
		if ( function_exists( 'update_post_caches' ) ) {
			update_post_caches( $posts, 'any', false, false );
		}

		return $posts;
	}

	/**
	 * Extract direct shortcode and Gutenberg block references.
	 *
	 * @param string $content Post content.
	 * @return array<int,array<string,bool>>
	 */
	private function references_in_content( $content ) {
		$references       = array();
		$shortcode_content = preg_replace( '/<!--.*?-->/s', '', (string) $content );
		$this->collect_shortcode_references(
			is_string( $shortcode_content ) ? $shortcode_content : (string) $content,
			'shortcode',
			$references
		);

		if ( function_exists( 'parse_blocks' ) ) {
			$blocks = parse_blocks( (string) $content );
			if ( is_array( $blocks ) ) {
				$this->collect_block_references( $blocks, $references );
			}
		}

		return $references;
	}

	/**
	 * Extract one shortcode using WordPress's attribute grammar.
	 *
	 * @param string                         $content Content fragment.
	 * @param string                         $reference_type Type label.
	 * @param array<int,array<string,bool>> $references Mutable reference map.
	 * @return void
	 */
	private function collect_shortcode_references( $content, $reference_type, array &$references ) {
		$pattern = get_shortcode_regex( array( self::SHORTCODE_TAG ) );
		if ( ! preg_match_all( '~' . $pattern . '~s', (string) $content, $matches, PREG_SET_ORDER ) ) {
			return;
		}

		foreach ( $matches as $match ) {
			if ( '[' === ( $match[1] ?? '' ) && ']' === ( $match[6] ?? '' ) ) {
				continue;
			}
			$attributes = shortcode_parse_atts( (string) ( $match[3] ?? '' ) );
			$form_id    = is_array( $attributes ) ? absint( $attributes['id'] ?? 0 ) : 0;
			$this->add_reference( $references, $form_id, $reference_type );
		}
	}

	/**
	 * Traverse parsed Gutenberg blocks.
	 *
	 * Core Shortcode blocks are marked as both shortcode and block references.
	 * The canonical dynamic submission-inbox/form block is also supported.
	 *
	 * @param array<int,array<string,mixed>> $blocks Parsed blocks.
	 * @param array<int,array<string,bool>>   $references Mutable reference map.
	 * @return void
	 */
	private function collect_block_references( array $blocks, array &$references ) {
		foreach ( $blocks as $block ) {
			if ( ! is_array( $block ) ) {
				continue;
			}

			$name = (string) ( $block['blockName'] ?? '' );
			if ( 'core/shortcode' === $name ) {
				$this->collect_shortcode_references( (string) ( $block['innerHTML'] ?? '' ), 'block', $references );
			} elseif ( self::BLOCK_NAME === $name ) {
				$attributes = is_array( $block['attrs'] ?? null ) ? $block['attrs'] : array();
				$form_id    = 0;
				foreach ( array( 'id', 'formId', 'form_id' ) as $attribute_key ) {
					if ( array_key_exists( $attribute_key, $attributes ) ) {
						$form_id = absint( $attributes[ $attribute_key ] );
						break;
					}
				}
				$this->add_reference( $references, $form_id, 'block' );
			}

			$inner_blocks = is_array( $block['innerBlocks'] ?? null ) ? $block['innerBlocks'] : array();
			if ( ! empty( $inner_blocks ) ) {
				$this->collect_block_references( $inner_blocks, $references );
			}
		}
	}

	/**
	 * Add one deduplicated reference.
	 *
	 * @param array<int,array<string,bool>> $references Mutable map.
	 * @param int                            $form_id Form id.
	 * @param string                         $reference_type Type label.
	 * @return void
	 */
	private function add_reference( array &$references, $form_id, $reference_type ) {
		$form_id = absint( $form_id );
		if ( $form_id <= 0 ) {
			return;
		}
		if ( ! isset( $references[ $form_id ] ) ) {
			$references[ $form_id ] = array();
		}
		$references[ $form_id ][ sanitize_key( $reference_type ) ] = true;
	}

	/**
	 * Build metadata once per post, even if it references several forms.
	 *
	 * @param \WP_Post $post Published post.
	 * @return array<string,mixed>
	 */
	private function post_location( \WP_Post $post ) {
		$edit_url = get_edit_post_link( $post, 'raw' );
		$view_url = get_permalink( $post );

		return array(
			'id'       => absint( $post->ID ),
			'title'    => wp_strip_all_tags( (string) $post->post_title, true ),
			'type'     => sanitize_key( $post->post_type ),
			'status'   => sanitize_key( $post->post_status ),
			'url'      => is_string( $view_url ) && '' !== $view_url ? esc_url_raw( $view_url ) : null,
			'edit_url' => is_string( $edit_url ) && '' !== $edit_url ? esc_url_raw( $edit_url ) : null,
		);
	}
}
