<?php
/**
 * Block Template Loader
 *
 * Central registry for plugin-supplied block templates. Handles both block
 * (FSE) themes via register_block_template() and classic themes via a shared
 * PHP fallback that renders the .html file through do_blocks().
 *
 * Booted explicitly by FrontendModule::init(). Templates are registered by
 * individual modules via BlockTemplateLoader::register() in their own init().
 *
 * ## Usage — register from any module's init():
 *
 *   BlockTemplateLoader::register( [
 *       'slug'        => 'single-fundraiser',
 *       'title'       => __( 'Single Fundraiser', 'ambassadorhub' ),
 *       'post_types'  => [ 'fundraiser' ],
 *       'path'        => AMBASSADOR_HUB_PATH . 'src/Fundraisers/block-templates/single-fundraiser.html',
 *       'theme_slugs' => [ 'single-fundraiser.php' ], // human-friendly override filenames for classic themes
 *   ] );
 *
 * ## Classic-theme override priority per template:
 *   1. theme_slugs entries     (e.g. single-campaign.php — documented for theme developers)
 *   2. single-{post_type}.php (e.g. single-ambh_campaign.php — raw WP convention; unlikely in practice)
 *   3. single.php
 *   4. Plugin shared fallback  (src/Frontend/templates/block-template-fallback.php)
 *
 * @package AmbassadorHub\Frontend
 */

namespace AmbassadorHub\Frontend;

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

/**
 * Central loader for AmbassadorHub block templates.
 */
final class BlockTemplateLoader {

	/**
	 * Registered templates keyed by slug.
	 *
	 * @var array<string, array{slug: string, title: string, post_types: string[], page_ids: int[], path: string, theme_slugs: string[]}>
	 */
	private static array $templates = array();

	/**
	 * Template config resolved for the current request.
	 *
	 * Set during classic_theme_template() so the shared PHP fallback can read it.
	 *
	 * @var array{slug: string, title: string, post_types: string[], path: string, theme_slugs: string[]}|null
	 */
	private static ?array $current = null;

	/**
	 * Register hooks. Called once by FrontendModule::init().
	 *
	 * Separating boot() from register() means the hooks are always in place
	 * before modules add templates, regardless of module load order. It also
	 * makes the template system visible in FrontendModule rather than hidden
	 * as a side-effect of the first register() call.
	 *
	 * @return void
	 */
	public static function boot(): void {
		// Priority 20 — after post types register at priority 10.
		add_action( 'init', array( self::class, 'register_block_templates' ), 20 );
		add_filter( 'template_include', array( self::class, 'classic_theme_template' ) );
	}

	/**
	 * Register a block template.
	 *
	 * Call from any module's init(). FrontendModule::boot() must have been called
	 * first — Plugin::load_components() guarantees this via load order.
	 *
	 * @param array{slug: string, title: string, post_types: string[], path: string, theme_slugs?: string[]} $config Template configuration.
	 *
	 * @return void
	 */
	public static function register( array $config ): void {
		$config['theme_slugs']   = $config['theme_slugs'] ?? array();
		$config['page_ids']      = $config['page_ids'] ?? array();
		$config['template_type'] = $config['template_type'] ?? 'single';

		self::$templates[ $config['slug'] ] = $config;
	}

	/**
	 * Register all collected templates with the block template API (FSE themes only).
	 *
	 * Hooked to 'init' at priority 20 by boot(). Silently skips on WordPress
	 * versions that do not support register_block_template() (< 6.7).
	 *
	 * @return void
	 */
	public static function register_block_templates(): void {
		if ( ! function_exists( 'register_block_template' ) ) {
			return;
		}

		foreach ( self::$templates as $slug => $config ) {
			// Page-ID templates have no associated post type; skip FSE API registration
			// since they are served via the classic-theme the_content injection path.
			if ( empty( $config['post_types'] ) ) {
				continue;
			}

			$content = self::read_template_file( $config['path'] );
			if ( null === $content ) {
				continue;
			}

			// Wrap with header/footer template parts — classic-theme fallback
			// uses get_header()/get_footer() instead, so these are FSE-only.
			$content = '<!-- wp:template-part {"slug":"header"} /-->'
				. $content
				. '<!-- wp:template-part {"slug":"footer"} /-->';

			// Use a slug that matches WordPress's template hierarchy so the FSE
			// engine applies this template automatically for the registered post
			// types. For a single post type, '{type}-{post_type}' is the correct
			// hierarchy slot (e.g. 'single-ambh_campaign', 'archive-ambh_fundraiser').
			// When the config slug already matches, this produces the same value.
			$type     = $config['template_type'] ?? 'single';
			$fse_slug = count( $config['post_types'] ) === 1
				? $type . '-' . $config['post_types'][0]
				: $slug;

			if ( function_exists( 'register_block_template' ) ) {
				register_block_template(
					'ambassadorhub//' . $fse_slug,
					array(
						'title'      => $config['title'],
						'post_types' => $config['post_types'],
						'content'    => $content,
					)
				);
			}
		}
	}

	/**
	 * Serve the classic-theme fallback for any registered template.
	 *
	 * Skipped entirely on block (FSE) themes — register_block_template() handles those.
	 *
	 * Override priority for classic themes:
	 *   1. theme_slugs entries       (e.g. single-campaign.php — theme developer intentionally opted in)
	 *   2. single-{post_type}.php   (e.g. single-ambh_campaign.php — WP convention for this CPT)
	 *   3. Theme's own page.php / single.php / index.php, with our blocks
	 *      injected via the_content filter (sidebar, wrappers, post classes all work)
	 *   4. Plugin standalone fallback (no standard theme templates found — very rare)
	 *
	 * Note: generic single.php is only used as a transparent host (step 3), not as
	 * an early-exit override, so the theme's structural chrome always wraps our blocks.
	 *
	 * @param string $template Resolved template path.
	 *
	 * @return string
	 */
	public static function classic_theme_template( string $template ): string {
		if ( wp_is_block_theme() ) {
			return $template;
		}

		$config = self::resolve_template_for_current_request();
		if ( null === $config ) {
			return $template;
		}

		// Explicit theme-supplied overrides always win — the theme developer has
		// deliberately handled this CPT themselves.
		$is_archive = 'archive' === ( $config['template_type'] ?? 'single' );
		$candidates = $config['theme_slugs'];
		foreach ( $config['post_types'] as $post_type ) {
			$candidates[] = ( $is_archive ? 'archive-' : 'single-' ) . $post_type . '.php';
		}

		$theme_override = locate_template( $candidates );
		if ( $theme_override ) {
			return $theme_override;
		}

		// Store resolved config so inject_block_content() and the standalone
		// fallback file can both read it.
		self::$current = $config;

		if ( $is_archive ) {
			// Archive templates contain the full query loop — injecting via the_content
			// is not possible (that filter fires inside a single-post loop). Serve the
			// standalone fallback directly so do_blocks() runs the query loop block.
			return AMBASSADOR_HUB_PATH . 'src/Frontend/templates/block-template-fallback.php';
		}

		// Inject our block layout via the_content so the theme's own structural
		// chrome (primary wrapper, sidebar, post classes, breadcrumbs, etc.)
		// renders normally around our content.
		add_filter( 'the_content', array( self::class, 'inject_block_content' ), 1 );

		// Return the theme's own page/single/index template as the host.
		// page.php is preferred because it is typically full-width and has no
		// blog-specific meta (date, author, tags); single.php is the correct
		// semantic fallback for CPT posts; index.php is the mandatory safety net.
		$host = locate_template( array( 'page.php', 'single.php', 'index.php' ) );
		if ( $host ) {
			return $host;
		}

		// Last resort: no standard theme templates found at all.
		return AMBASSADOR_HUB_PATH . 'src/Frontend/templates/block-template-fallback.php';
	}

	/**
	 * Replace the post's raw content with the rendered block template.
	 *
	 * Hooked to 'the_content' at priority 1 by classic_theme_template(). Fires
	 * exactly once per request (self-removing) so re-entrant calls (e.g. a theme
	 * that calls the_content() twice) get plain empty string the second time.
	 *
	 * The post title block is stripped from the output: classic themes show the
	 * post title via the_title() in their own template, so keeping it would cause
	 * the title to appear twice on the page.
	 *
	 * Escaping note: this mirrors WordPress core's own `the_content` handling —
	 * core hooks `do_blocks()` directly onto `the_content` (priority 9) and
	 * returns its output unescaped, because escaping is each block's own
	 * responsibility, not the caller's. The same applies here, with one
	 * additional guarantee core's hook doesn't have: `$raw` never comes from
	 * post content or any other request-influenced source. It is read from a
	 * .html file path that only plugin code registers via
	 * BlockTemplateLoader::register() (see class docblock) — never a value
	 * derived from $_GET/$_POST/post meta/user input — so there is no
	 * attacker-controlled path into do_blocks() here, only the plugin's own
	 * bundled template markup.
	 *
	 * @param string $content Original post_content from the database (unused;
	 *                        classic themes get the block template instead).
	 *
	 * @return string Rendered block HTML.
	 */
	public static function inject_block_content( string $content ): string {
		remove_filter( 'the_content', array( self::class, 'inject_block_content' ), 1 );

		$raw = self::$current ? self::read_template_file( self::$current['path'] ) : null;
		if ( null === $raw ) {
			return $content;
		}

		// Remove the post-title block — the theme renders the title itself via
		// the_title(), keeping it inside the theme's own heading markup.
		$raw = preg_replace( '/<!--\s*wp:post-title[^-]*\/-->/', '', $raw );

		// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- see docblock above: same contract as core's do_blocks-on-the_content hook, over a plugin-bundled template file, never request-influenced input.
		return do_blocks( (string) $raw );
	}

	/**
	 * Return the template config resolved for the current request.
	 *
	 * Called by the shared fallback PHP file (block-template-fallback.php).
	 *
	 * @return array{slug: string, title: string, post_types: string[], path: string, theme_slugs: string[]}|null
	 */
	public static function get_current(): ?array {
		return self::$current;
	}

	/**
	 * Find the first registered template whose post_types match the current request.
	 *
	 * @return array{slug: string, title: string, post_types: string[], path: string, theme_slugs: string[]}|null
	 */
	private static function resolve_template_for_current_request(): ?array {
		foreach ( self::$templates as $config ) {
			$is_archive = 'archive' === ( $config['template_type'] ?? 'single' );
			foreach ( $config['post_types'] as $post_type ) {
				if ( $is_archive ? is_post_type_archive( $post_type ) : is_singular( $post_type ) ) {
					return $config;
				}
			}
			foreach ( $config['page_ids'] as $page_id ) {
				if ( $page_id > 0 && is_page( $page_id ) ) {
					return $config;
				}
			}
		}

		return null;
	}

	/**
	 * Read a template file and return its contents, or null on failure.
	 *
	 * @param string $path Absolute path to the .html file.
	 *
	 * @return string|null
	 */
	private static function read_template_file( string $path ): ?string {
		if ( ! file_exists( $path ) ) {
			return null;
		}

		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- reading local plugin file.
		$content = file_get_contents( $path );

		return false !== $content ? self::translate_marked_strings( $content ) : null;
	}

	/**
	 * Translate static text marked for translation inside a raw template file.
	 *
	 * Block templates are plain .html files, so their text content never passes
	 * through gettext the way a normal PHP __() call would. Authors wrap any
	 * translatable text in <!-- ambh:i18n -->...<!-- /ambh:i18n --> and this
	 * swaps it for the translated string (domain 'ambassadorhub' — the same
	 * domain every module, including add-ons like Petitions, already uses, so
	 * these strings live in core's own .po/.mo files alongside everything else).
	 *
	 * @param string $content Raw template HTML.
	 *
	 * @return string Content with marked strings translated.
	 */
	private static function translate_marked_strings( string $content ): string {
		return (string) preg_replace_callback(
			'/<!--\s*ambh:i18n\s*-->(.*?)<!--\s*\/ambh:i18n\s*-->/s',
			static function ( array $matches ): string {
				// Deliberately not __( $matches[1], 'ambassadorhub' ): WordPress.org
				// guidelines require the text/domain arguments of __()/_e()/etc. to
				// be string literals, since make-pot (and their submission review)
				// reads source without executing it — it can never see a value that
				// only exists at runtime. get_translations_for_domain()->translate()
				// is the same lookup __() does internally, minus the wrapper
				// function whose literal-argument requirement doesn't apply here.
				// The literal, scannable __() calls make-pot needs live in
				// BlockTemplateStrings.php, kept in sync by hand with these templates.
				// phpcs:ignore WordPress.WP.I18n.LowLevelTranslationFunction -- see above; this is the documented escape hatch for runtime-only translation lookups.
				return get_translations_for_domain( 'ambassadorhub' )->translate( $matches[1] );
			},
			$content
		);
	}
}
