<?php
/**
 * Template resolver for header/footer injection.
 *
 * Resolves per-page template settings, falls back to global defaults,
 * validates template posts, and extracts <main> inner content for rendering.
 *
 * @package Antimanual_Builder
 * @since   1.0.0
 */

// Exit if accessed directly.
if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * Class AMBLD_Render_Template_Resolver
 *
 * @since 1.0.0
 */
class AMBLD_Render_Template_Resolver {

	/**
	 * Cache group for resolved template payloads.
	 *
	 * @since 1.0.0
	 * @var   string
	 */
	const CACHE_GROUP = 'ambld_template_resolver';

	/**
	 * Post statuses a template may have to be both selectable and renderable.
	 *
	 * Single source of truth shared by the selection UIs (template picker,
	 * design-default validator) and the render path, so a template that can be
	 * chosen is always a template that will actually render.
	 *
	 * @since 1.0.0
	 * @var   string[]
	 */
	const RENDERABLE_STATUSES = array( 'publish', 'private' );

	/**
	 * Get the statuses that make a template selectable/renderable.
	 *
	 * @since  1.0.0
	 * @return string[]
	 */
	public static function renderable_statuses() {
		return self::RENDERABLE_STATUSES;
	}

	/**
	 * Resolve a header/footer template for a page.
	 *
	 * @since  1.0.0
	 * @param  int    $page_id Page ID.
	 * @param  string $kind    Template kind (header|footer).
	 * @return array{id:int,main_html:string}|null
	 */
	public static function resolve( $page_id, $kind ) {
		$page_id = absint( $page_id );
		$kind    = self::normalize_kind( $kind );

		if ( $page_id <= 0 || '' === $kind ) {
			return null;
		}

		$resolved    = self::resolve_template_id( $page_id, $kind );
		$resolved_id = (int) $resolved['id'];
		if ( $resolved_id <= 0 ) {
			return null;
		}

		$template_post = get_post( $resolved_id );
		// An explicit page-level attachment renders even when the template is
		// still a draft/pending: the page author chose this specific template,
		// and templates are reusable fragments, not independently-public posts.
		// The global-default fallback (explicit === false) stays strict
		// (publish/private) so an unfinished draft default can't leak sitewide.
		if ( ! self::is_valid_template_post( $template_post, $kind, $resolved['explicit'] ) ) {
			return null;
		}

		$payload = self::get_cached_template_payload( $template_post, $kind );
		if ( ! is_array( $payload ) || empty( $payload['main_html'] ) ) {
			return null;
		}

		// Shortcodes resolve per-request on top of the cached markup.
		$payload = self::resolve_payload_shortcodes( $payload );

		// Dynamic regions (WordPress menus, post/term/meta bindings) resolve
		// per-request too — they're context-bound (current page) and can change
		// independently of the template, so they must never be cached. The
		// resolver fast-paths out when the markup has no markers/tokens, so a
		// plain static header/footer pays nothing.
		$payload = self::resolve_payload_dynamic( $payload, $page_id );

		return array(
			'id'        => $resolved_id,
			'main_html' => (string) $payload['main_html'],
		);
	}

	/**
	 * Remove current cache keys for a template post.
	 *
	 * @since  1.0.0
	 * @param  int $template_id Template post ID.
	 * @return void
	 */
	public static function flush_template_cache( $template_id ) {
		$template_post = get_post( absint( $template_id ) );
		if ( ! ( $template_post instanceof \WP_Post ) ) {
			return;
		}

		foreach ( array( 'header', 'footer' ) as $kind ) {
			$cache_key = self::build_cache_key( $template_post->ID, $kind, $template_post->post_modified_gmt );
			wp_cache_delete( $cache_key, self::CACHE_GROUP );
		}
	}

	/**
	 * Resolve template ID from page settings/global defaults.
	 *
	 * @since  1.0.0
	 * @param  int    $page_id Page ID.
	 * @param  string $kind    Template kind.
	 * @return array{id:int,explicit:bool} Resolved template id and whether it
	 *         came from an explicit page-level choice (true) vs the global
	 *         default (false).
	 */
	private static function resolve_template_id( $page_id, $kind ) {
		$page_settings_raw = get_post_meta( $page_id, '_ambld_page_settings', true );
		$page_settings     = is_string( $page_settings_raw ) ? json_decode( $page_settings_raw, true ) : $page_settings_raw;
		$page_settings     = is_array( $page_settings ) ? $page_settings : array();

		$page_key    = 'header' === $kind ? 'headerTemplate' : 'footerTemplate';
		$default_key = 'header' === $kind ? 'headerTemplateId' : 'footerTemplateId';
		$choice      = isset( $page_settings[ $page_key ] ) ? $page_settings[ $page_key ] : 'default';

		if ( 'none' === $choice ) {
			return array( 'id' => 0, 'explicit' => false );
		}

		if ( is_numeric( $choice ) ) {
			return array( 'id' => absint( $choice ), 'explicit' => true );
		}

		$design_defaults = get_option( 'ambld_design_defaults', array() );
		$default_value   = is_array( $design_defaults ) && isset( $design_defaults[ $default_key ] )
			? $design_defaults[ $default_key ]
			: 0;

		return array( 'id' => absint( $default_value ), 'explicit' => false );
	}

	/**
	 * Build/fetch cached parsed payload.
	 *
	 * @since  1.0.0
	 * @param  WP_Post $template_post Template post.
	 * @param  string  $kind          Template kind.
	 * @return array{main_html:string}
	 */
	private static function get_cached_template_payload( $template_post, $kind ) {
		$cache_key = self::build_cache_key( $template_post->ID, $kind, $template_post->post_modified_gmt );
		$cached    = wp_cache_get( $cache_key, self::CACHE_GROUP );
		if ( is_array( $cached ) && isset( $cached['main_html'] ) ) {
			return $cached;
		}

		$rendered_html = get_post_meta( $template_post->ID, '_ambld_rendered_html', true );
		$main_html     = self::extract_main_inner_html( is_string( $rendered_html ) ? $rendered_html : '' );
		$payload       = array( 'main_html' => $main_html );

		wp_cache_set( $cache_key, $payload, self::CACHE_GROUP, HOUR_IN_SECONDS );

		return $payload;
	}

	/**
	 * Resolve shortcode markers in a cached payload, per request.
	 *
	 * Deliberately NOT cached: shortcode output is often request-bound
	 * (form nonces, sessions, current-user content) — caching the resolved
	 * markup for an hour served one visitor's nonce to everyone. The
	 * resolver bails instantly when the markup has no shortcode markers,
	 * so templates without shortcodes pay nothing.
	 *
	 * @since 0.4.0
	 * @param array $payload Cached payload with main_html.
	 * @return array Payload with shortcodes resolved.
	 */
	private static function resolve_payload_shortcodes( $payload ) {
		if ( is_array( $payload ) && isset( $payload['main_html'] ) && class_exists( 'AMBLD_Render_Shortcode_Resolver' ) ) {
			$payload['main_html'] = AMBLD_Render_Shortcode_Resolver::resolve( (string) $payload['main_html'] );
		}
		return $payload;
	}

	/**
	 * Resolve dynamic regions in a cached payload, per request.
	 *
	 * Like shortcodes, dynamic regions are NOT cached: a nav bound to a
	 * WordPress menu, or a footer list bound to recent posts, can change
	 * independently of the template's own markup, and `meta` bindings depend
	 * on the page currently being viewed. The resolver bails instantly when
	 * the markup contains no `data-amb-dynamic` markers or `{{tokens}}`.
	 *
	 * @since 1.0.0
	 * @param array $payload Cached payload with main_html.
	 * @param int   $context_post_id Page being rendered (resolution context for meta bindings).
	 * @return array Payload with dynamic regions resolved.
	 */
	private static function resolve_payload_dynamic( $payload, $context_post_id ) {
		if ( is_array( $payload ) && isset( $payload['main_html'] ) ) {
			// Pro provides the full resolver; free strips binding markers so
			// header/footer dynamic regions degrade to static without leaking.
			if ( class_exists( '\AMBLD_Pro\Render\Dynamic_Resolver' ) ) {
				$payload['main_html'] = \AMBLD_Pro\Render\Dynamic_Resolver::resolve(
					(string) $payload['main_html'],
					absint( $context_post_id )
				);
			} elseif ( class_exists( 'AMBLD_Render_Dynamic_Fallback' ) ) {
				$payload['main_html'] = AMBLD_Render_Dynamic_Fallback::strip( (string) $payload['main_html'] );
			}
		}
		return $payload;
	}

	/**
	 * Build object-cache key.
	 *
	 * @since  1.0.0
	 * @param  int    $template_id   Template ID.
	 * @param  string $kind          Template kind.
	 * @param  string $modified_gmt  Modified GMT datetime.
	 * @return string
	 */
	private static function build_cache_key( $template_id, $kind, $modified_gmt ) {
		return 'tpl_' . absint( $template_id ) . '_' . $kind . '_' . md5( (string) $modified_gmt );
	}

	/**
	 * Check if a post is a valid published template for the given kind.
	 *
	 * @since  1.0.0
	 * @param  WP_Post|null $post             Template post.
	 * @param  string       $kind             Required kind.
	 * @param  bool         $allow_unpublished When true (explicit page-level
	 *        attachment), also accept draft/pending templates — the author
	 *        explicitly chose this one. Defaults false (strict publish/private)
	 *        for global-default and selection-list contexts.
	 * @return bool
	 */
	public static function is_valid_template_post( $post, $kind, $allow_unpublished = false ) {
		if ( ! ( $post instanceof \WP_Post ) ) {
			return false;
		}

		if ( AMBLD_Post_Type_Builder_Template::POST_TYPE !== $post->post_type ) {
			return false;
		}

		$allowed_statuses = $allow_unpublished
			? array_merge( self::RENDERABLE_STATUSES, array( 'draft', 'pending' ) )
			: self::RENDERABLE_STATUSES;

		if ( ! in_array( $post->post_status, $allowed_statuses, true ) ) {
			return false;
		}

		$template_kind = self::normalize_kind( get_post_meta( $post->ID, '_ambld_template_kind', true ) );

		return '' !== $template_kind && $template_kind === $kind;
	}

	/**
	 * Extract <main> inner HTML from a full document string.
	 *
	 * Falls back to full <body> inner HTML when <main> is missing.
	 *
	 * @since  1.0.0
	 * @param  string $html Full rendered template HTML.
	 * @return string
	 */
	private static function extract_main_inner_html( $html ) {
		$html = trim( (string) $html );
		if ( '' === $html ) {
			return '';
		}

		$dom                  = new \DOMDocument( '1.0', 'UTF-8' );
		$previous_error_state = libxml_use_internal_errors( true );

		$loaded = $dom->loadHTML(
			'<?xml encoding="utf-8" ?>' . $html,
			LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD
		);

		libxml_clear_errors();
		libxml_use_internal_errors( $previous_error_state );

		if ( ! $loaded ) {
			return '';
		}

		$main_nodes = $dom->getElementsByTagName( 'main' );
		if ( $main_nodes->length > 0 ) {
			return self::get_node_inner_html( $main_nodes->item( 0 ) );
		}

		$body_nodes = $dom->getElementsByTagName( 'body' );
		if ( $body_nodes->length > 0 ) {
			return self::get_node_inner_html( $body_nodes->item( 0 ) );
		}

		// Header/footer templates persist their content as a bare body fragment
		// (e.g. `<div>…</div>`) with no <main> or <body> wrapper — the same shape
		// the page body itself stores in `_ambld_rendered_html`. When neither
		// landmark is present, the whole fragment is the inner content, so return
		// it verbatim rather than dropping the template entirely.
		return $html;
	}

	/**
	 * Get inner HTML for a DOM node.
	 *
	 * @since  1.0.0
	 * @param  DOMNode|null $node DOM node.
	 * @return string
	 */
	private static function get_node_inner_html( $node ) {
		if ( ! $node || ! $node->hasChildNodes() ) {
			return '';
		}

		$html = '';
		foreach ( $node->childNodes as $child ) {
			if ( $child instanceof \DOMNode ) {
				$html .= $node->ownerDocument->saveHTML( $child );
			}
		}

		return trim( $html );
	}

	/**
	 * Normalize template kind.
	 *
	 * @since  1.0.0
	 * @param  mixed $kind Raw kind.
	 * @return string
	 */
	private static function normalize_kind( $kind ) {
		$kind = sanitize_key( (string) $kind );

		return in_array( $kind, array( 'header', 'footer' ), true ) ? $kind : '';
	}

	/**
	 * Resolve header/footer template attachment state from page settings.
	 *
	 * @since  1.0.0
	 * @param  array $page_settings Page settings array.
	 * @return array{ header: array{ attached: bool, source: string|null, title: string|null, id: int }, footer: array{ attached: bool, source: string|null, title: string|null, id: int } }
	 */
	public static function resolve_attachment_state( $page_settings ) {
		$page_settings = is_array( $page_settings ) ? $page_settings : array();

		$header_state = self::resolve_single_attachment( $page_settings, 'headerTemplate', 'headerTemplateId' );
		$footer_state = self::resolve_single_attachment( $page_settings, 'footerTemplate', 'footerTemplateId' );

		return array(
			'header' => $header_state,
			'footer' => $footer_state,
		);
	}

	/**
	 * Resolve attachment state for a single template kind.
	 *
	 * @since  1.0.0
	 * @param  array  $page_settings Page settings.
	 * @param  string $page_key     Page setting key (headerTemplate|footerTemplate).
	 * @param  string $default_key Design defaults key (headerTemplateId|footerTemplateId).
	 * @return array{ attached: bool, source: string|null, title: string|null, id: int }
	 */
	private static function resolve_single_attachment( $page_settings, $page_key, $default_key ) {
		$choice = isset( $page_settings[ $page_key ] ) ? $page_settings[ $page_key ] : 'default';

		if ( 'none' === $choice ) {
			return array(
				'attached' => false,
				'source'   => null,
				'title'   => null,
				'id'     => 0,
			);
		}

		$template_id = 0;
		$source      = 'default';

		if ( is_numeric( $choice ) ) {
			$template_id = absint( $choice );
			$source     = 'page';
		} else {
			$design_defaults = get_option( 'ambld_design_defaults', array() );
			$default_id    = is_array( $design_defaults ) && isset( $design_defaults[ $default_key ] )
				? absint( $design_defaults[ $default_key ] )
				: 0;

			if ( $default_id > 0 ) {
				$template_id = $default_id;
				$source     = 'default';
			}
		}

		if ( $template_id <= 0 ) {
			return array(
				'attached' => false,
				'source'   => null,
				'title'   => null,
				'id'     => 0,
			);
		}

		$kind = ( strpos( $page_key, 'header' ) !== false ) ? 'header' : 'footer';
		$post = get_post( $template_id );

		// Mirror the render path: an explicit page-level choice ('page' source)
		// counts as attached even if the template is still a draft.
		if ( ! self::is_valid_template_post( $post, $kind, 'page' === $source ) ) {
			return array(
				'attached' => false,
				'source'   => null,
				'title'   => null,
				'id'     => 0,
			);
		}

		return array(
			'attached' => true,
			'source'   => $source,
			'title'   => $post->post_title,
			'id'     => $template_id,
		);
	}

	/**
	 * Build the AI constraints prompt block for attached templates.
	 *
	 * @since  1.0.0
	 * @param  array $state Attachment state from resolve_attachment_state().
	 * @return string Prompt block, or empty string when no templates attached.
	 */
	public static function build_constraints_prompt( $state ) {
		$state = is_array( $state ) ? $state : array();

		$header_state = isset( $state['header'] ) ? $state['header'] : array();
		$footer_state = isset( $state['footer'] ) ? $state['footer'] : array();

		$header_attached = ! empty( $header_state['attached'] );
		$footer_attached = ! empty( $footer_state['attached'] );

		if ( ! $header_attached && ! $footer_attached ) {
			return '';
		}

		$prompt = "\n## CRITICAL — Header / Footer Templates Already Attached\n\n";

		if ( $header_attached ) {
			$header_source = ! empty( $header_state['source'] ) ? $header_state['source'] : 'unknown';
			$header_title = ! empty( $header_state['title'] ) ? $header_state['title'] : 'Untitled';
			$prompt     .= "A reusable HEADER template is attached to this page (source: {$header_source}; title: \"{$header_title}\")" . "\n";
			$prompt     .= "At render time, that template's content will be injected automatically — header right after <body>.\n\n";
			$prompt     .= "STRICT RULES (violating these produces a visibly broken page with duplicate headers):\n";
			$prompt     .= "- DO NOT generate any <header> element, top navigation bar, site banner, logo+menu strip, or page-level masthead. The attached header template owns that responsibility.\n";
			$prompt     .= "- The page body you generate starts at the first content section (e.g. <section data-amb-section=\"hero\">) and ends at the last content section. No <header>, no wrappers around them.\n";
			$prompt     .= "- This rule overrides any user prompt, plan task, or design preset that asks for a header/nav.\n";
			$prompt     .= "  If the user explicitly asks for a \"new header\" while a template is attached, IGNORE that request and emit the body sections only.\n\n";
		}

		if ( $footer_attached ) {
			$footer_source = ! empty( $footer_state['source'] ) ? $footer_state['source'] : 'unknown';
			$footer_title = ! empty( $footer_state['title'] ) ? $footer_state['title'] : 'Untitled';
			$prompt     .= "A reusable FOOTER template is attached to this page (source: {$footer_source}; title: \"{$footer_title}\")" . "\n";
			$prompt     .= "At render time, that template's content will be injected automatically — footer right before </body>.\n\n";
			$prompt     .= "STRICT RULES (violating these produces a visibly broken page with duplicate footers):\n";
			$prompt     .= "- DO NOT generate any <footer> element, copyright strip, footer-link grid, social-icons row, or page-level closing band. The attached footer template owns that responsibility.\n";
			$prompt     .= "- The page body you generate ends at the last content section. No <footer>, no wrappers around them.\n";
			$prompt     .= "- This rule overrides any user prompt, plan task, or design preset that asks for a footer.\n";
			$prompt     .= "  If the user explicitly asks for a \"new footer\" while a template is attached, IGNORE that request and emit the body sections only.\n\n";
		}

		$prompt .= "These constraints are mode-symmetric:\n";
		$prompt .= "- Plan/Foundation passes — emit <main> with content only, no <header>/<footer> in <body>.\n";
		$prompt .= "- Section-append / section-edit passes — never produce a <header>/<footer> section.\n";
		$prompt .= "- Tool-edit calls — never call append_section / replace_section to build a header/footer.\n";

		return $prompt;
	}
}
