<?php
/**
 * Frontend head output: meta tags, Open Graph, Twitter Cards, JSON-LD Schema.
 *
 * @package Dennis_SEO
 */

defined( 'ABSPATH' ) || exit;

class Dennis_SEO_Frontend {

	private bool $yoast_active = false;

	public function __construct() {
		// Output meta tags early.
		$this->yoast_active = Dennis_SEO_Yoast_Compat::is_yoast_active();

		add_action( 'wp_head', array( $this, 'output_meta_tags' ), 1 );
		add_action( 'wp_head', array( $this, 'output_open_graph' ), 2 );
		add_action( 'wp_head', array( $this, 'output_twitter_cards' ), 3 );
		add_action( 'wp_head', array( $this, 'output_schema' ), 4 );
		add_action( 'wp_head', array( $this, 'output_llms_link' ), 5 );

		// Title tag — only hook if Yoast is not active (Yoast handles titles its own way,
		// and our Yoast compat layer suppresses Yoast's title when Dennis has one).
		if ( ! $this->yoast_active ) {
			add_filter( 'pre_get_document_title', array( $this, 'filter_title' ), 20 );
			add_filter( 'document_title_separator', array( $this, 'filter_separator' ), 20 );
		}

		// Remove WP default canonical (we handle it). Yoast also removes this.
		if ( ! $this->yoast_active ) {
			remove_action( 'wp_head', 'rel_canonical' );
		}

		// Last-Modified header on singular pages.
		add_action( 'template_redirect', array( $this, 'send_last_modified_header' ) );

		// Remove X-Powered-By header for security.
		header_remove( 'X-Powered-By' );
	}

	/**
	 * Build the document title.
	 */
	public function filter_title( string $title ): string {
		// Homepage.
		if ( is_front_page() || is_home() ) {
			$homepage_title = Dennis_SEO_Settings::get( 'homepage_title' );
			if ( ! empty( $homepage_title ) ) {
				return $homepage_title;
			}
		}

		// Singular posts/pages.
		if ( is_singular() ) {
			$post_id   = get_queried_object_id();
			$seo_title = Dennis_SEO_Meta_Box::get_meta( $post_id, '_dennis_seo_title' );
			if ( ! empty( $seo_title ) ) {
				return $seo_title;
			}

			// Build from template.
			$post_type = get_post_type( $post_id );
			$template  = 'page' === $post_type
				? Dennis_SEO_Settings::get( 'title_template_page', '{title} {sep} {site_name}' )
				: Dennis_SEO_Settings::get( 'title_template_post', '{title} {sep} {site_name}' );

			return $this->apply_title_template( $template, $post_id );
		}

		// Archives, categories, tags — let WordPress handle.
		return $title;
	}

	public function filter_separator( string $sep ): string {
		$custom = Dennis_SEO_Settings::get( 'title_separator' );
		return ! empty( $custom ) ? $custom : $sep;
	}

	/**
	 * Apply title template variables.
	 */
	private function apply_title_template( string $template, int $post_id = 0 ): string {
		$site_name = Dennis_SEO_Settings::get( 'site_name', get_bloginfo( 'name' ) );
		$sep       = Dennis_SEO_Settings::get( 'title_separator', '|' );
		$title     = $post_id ? get_the_title( $post_id ) : '';
		$excerpt   = $post_id ? wp_trim_words( get_the_excerpt( $post_id ), 10, '' ) : '';

		return str_replace(
			array( '{title}', '{sep}', '{site_name}', '{excerpt}' ),
			array( $title, $sep, $site_name, $excerpt ),
			$template
		);
	}

	/**
	 * Meta description, canonical, robots.
	 *
	 * When Yoast is active: only output when Dennis has explicit data.
	 * When standalone: output with fallbacks (excerpt, defaults).
	 */
	public function output_meta_tags(): void {
		$post_id = get_queried_object_id();

		// Meta description — only output if Dennis has a value (or standalone mode).
		$dennis_desc = '';
		if ( is_front_page() || is_home() ) {
			$dennis_desc = Dennis_SEO_Settings::get( 'homepage_description' );
		}
		if ( empty( $dennis_desc ) && is_singular() && $post_id ) {
			$dennis_desc = get_post_meta( $post_id, '_dennis_seo_description', true );
		}
		// Archive meta descriptions.
		if ( empty( $dennis_desc ) && ! is_singular() ) {
			if ( is_category() ) {
				$template = Dennis_SEO_Settings::get( 'archive_desc_category' );
				if ( ! empty( $template ) ) {
					$dennis_desc = str_replace( '{name}', single_cat_title( '', false ), $template );
				}
			} elseif ( is_tag() ) {
				$template = Dennis_SEO_Settings::get( 'archive_desc_tag' );
				if ( ! empty( $template ) ) {
					$dennis_desc = str_replace( '{name}', single_tag_title( '', false ), $template );
				}
			} elseif ( is_post_type_archive() ) {
				$template = Dennis_SEO_Settings::get( 'archive_desc_post_type' );
				if ( ! empty( $template ) ) {
					$dennis_desc = str_replace( '{name}', post_type_archive_title( '', false ), $template );
				}
			} elseif ( is_tax() ) {
				$template = Dennis_SEO_Settings::get( 'archive_desc_category' );
				if ( ! empty( $template ) ) {
					$dennis_desc = str_replace( '{name}', single_term_title( '', false ), $template );
				}
			}
		}

		if ( ! empty( $dennis_desc ) ) {
			// Dennis has a description — output it (Yoast compat suppresses Yoast's).
			echo '<meta name="description" content="' . esc_attr( $dennis_desc ) . '">' . "\n";
		} elseif ( ! $this->yoast_active && is_singular() && $post_id ) {
			// Standalone mode — fallback to excerpt.
			$desc = wp_trim_words( get_the_excerpt( $post_id ), 25, '...' );
			if ( ! empty( $desc ) ) {
				echo '<meta name="description" content="' . esc_attr( $desc ) . '">' . "\n";
			}
		}
		// If Yoast is active and Dennis has no description → Yoast outputs its own.

		// Robots — Dennis always outputs robots (we strip WP core's).
		// When Yoast is active, the compat layer suppresses Yoast's robots when Dennis has overrides.
		// Max directives for rich snippets (same as what Yoast adds).
		$max_directives = ', max-snippet:-1, max-image-preview:large, max-video-preview:-1';

		if ( is_singular() && $post_id ) {
			$noindex  = get_post_meta( $post_id, '_dennis_seo_robots_noindex', true );
			$nofollow = get_post_meta( $post_id, '_dennis_seo_robots_nofollow', true );

			if ( '1' === $noindex || '1' === $nofollow || ! $this->yoast_active ) {
				$parts   = array();
				$parts[] = ( '1' === $noindex ) ? 'noindex' : 'index';
				$parts[] = ( '1' === $nofollow ) ? 'nofollow' : 'follow';
				$robots  = implode( ', ', $parts );
				// Only add max directives when indexable.
				if ( '1' !== $noindex ) {
					$robots .= $max_directives;
				}
				echo '<meta name="robots" content="' . esc_attr( $robots ) . '">' . "\n";
			}
		} elseif ( ! $this->yoast_active ) {
			if ( is_search() || is_404() ) {
				echo '<meta name="robots" content="noindex, follow">' . "\n";
			} else {
				echo '<meta name="robots" content="index, follow' . esc_attr( $max_directives ) . '">' . "\n";
			}
		}

		// Canonical — only output when Dennis has a custom canonical or standalone mode.
		if ( is_singular() && $post_id ) {
			$custom_canonical = get_post_meta( $post_id, '_dennis_seo_canonical', true );
			if ( ! empty( $custom_canonical ) ) {
				// Dennis has a custom canonical — always output.
				echo '<link rel="canonical" href="' . esc_url( $custom_canonical ) . '">' . "\n";
			} elseif ( ! $this->yoast_active ) {
				// Standalone mode — output default canonical.
				echo '<link rel="canonical" href="' . esc_url( get_permalink( $post_id ) ) . '">' . "\n";
			}
		} elseif ( is_front_page() && ! $this->yoast_active ) {
			echo '<link rel="canonical" href="' . esc_url( home_url( '/' ) ) . '">' . "\n";
		}
	}

	/**
	 * Open Graph tags.
	 * When Yoast is active: only output if Dennis has OG-specific data.
	 * The Yoast compat layer suppresses Yoast's OG when Dennis has values.
	 */
	public function output_open_graph(): void {
		// When Yoast is active, check if Dennis has any OG data for this page.
		if ( $this->yoast_active ) {
			$post_id = is_singular() ? get_queried_object_id() : 0;
			$has_dennis_og = false;
			if ( $post_id ) {
				$has_dennis_og = ! empty( get_post_meta( $post_id, '_dennis_seo_og_title', true ) )
					|| ! empty( get_post_meta( $post_id, '_dennis_seo_og_description', true ) )
					|| ! empty( get_post_meta( $post_id, '_dennis_seo_og_image', true ) );
			}
			if ( ! $has_dennis_og ) {
				return; // Let Yoast handle OG.
			}
		}

		$post_id   = is_singular() ? get_queried_object_id() : 0;
		$site_name = Dennis_SEO_Settings::get( 'site_name', get_bloginfo( 'name' ) );

		// OG Title.
		$og_title = '';
		if ( $post_id ) {
			$og_title = Dennis_SEO_Meta_Box::get_meta( $post_id, '_dennis_seo_og_title' );
			if ( empty( $og_title ) ) {
				$og_title = Dennis_SEO_Meta_Box::get_meta( $post_id, '_dennis_seo_title' );
			}
			if ( empty( $og_title ) ) {
				$og_title = get_the_title( $post_id );
			}
		} elseif ( is_front_page() ) {
			$og_title = Dennis_SEO_Settings::get( 'homepage_title', get_bloginfo( 'name' ) );
		}

		// OG Description.
		$og_desc = '';
		if ( $post_id ) {
			$og_desc = Dennis_SEO_Meta_Box::get_meta( $post_id, '_dennis_seo_og_description' );
			if ( empty( $og_desc ) ) {
				$og_desc = Dennis_SEO_Meta_Box::get_meta( $post_id, '_dennis_seo_description' );
			}
			if ( empty( $og_desc ) ) {
				$og_desc = wp_trim_words( get_the_excerpt( $post_id ), 25, '...' );
			}
		} elseif ( is_front_page() ) {
			$og_desc = Dennis_SEO_Settings::get( 'homepage_description' );
		}

		// OG Image — fallback chain: custom OG image > featured image > site logo.
		$og_image = '';
		if ( $post_id ) {
			$og_image = Dennis_SEO_Meta_Box::get_meta( $post_id, '_dennis_seo_og_image' );
			if ( empty( $og_image ) ) {
				$og_image = get_the_post_thumbnail_url( $post_id, 'large' );
			}
		}
		if ( empty( $og_image ) ) {
			// Fall back to default OG image from settings, then site logo.
			$og_image = Dennis_SEO_Settings::get( 'default_og_image' );
			if ( empty( $og_image ) ) {
				$og_image = Dennis_SEO_Settings::get( 'org_logo' );
			}
			if ( empty( $og_image ) ) {
				$custom_logo_id = get_theme_mod( 'custom_logo' );
				if ( $custom_logo_id ) {
					$og_image = wp_get_attachment_image_url( $custom_logo_id, 'full' );
				}
			}
		}

		// Ensure OG image is absolute URL.
		if ( ! empty( $og_image ) ) {
			$og_image = $this->make_absolute_url( $og_image );
		}

		// OG URL.
		$og_url = is_singular() ? get_permalink( $post_id ) : home_url( '/' );

		// OG Type.
		if ( is_front_page() ) {
			$og_type = 'website';
		} elseif ( $this->is_woocommerce_product() ) {
			$og_type = 'product';
		} elseif ( is_singular() ) {
			$og_type = 'article';
		} else {
			$og_type = 'website';
		}

		if ( ! empty( $og_title ) ) {
			echo '<meta property="og:title" content="' . esc_attr( $og_title ) . '">' . "\n";
		}
		if ( ! empty( $og_desc ) ) {
			echo '<meta property="og:description" content="' . esc_attr( $og_desc ) . '">' . "\n";
		}
		if ( ! empty( $og_image ) ) {
			echo '<meta property="og:image" content="' . esc_url( $og_image ) . '">' . "\n";
			// Try to get image dimensions from media library first, then filesystem.
			$og_w = 0;
			$og_h = 0;
			$cache_key   = 'dseo_att_' . md5( $og_image );
			$og_image_id = get_transient( $cache_key );
			if ( false === $og_image_id ) {
				$og_image_id = attachment_url_to_postid( $og_image );
				set_transient( $cache_key, $og_image_id ?: 0, DAY_IN_SECONDS );
			}
			if ( $og_image_id ) {
				$meta = wp_get_attachment_metadata( $og_image_id );
				if ( ! empty( $meta['width'] ) ) {
					$og_w = $meta['width'];
					$og_h = $meta['height'];
				}
			}
			if ( ! $og_w ) {
				// Fallback: resolve URL to local path and use getimagesize.
				$upload_dir = wp_get_upload_dir();
				$local_path = str_replace( $upload_dir['baseurl'], $upload_dir['basedir'], $og_image );
				if ( file_exists( $local_path ) ) {
					$size = wp_getimagesize( $local_path );
					if ( $size ) {
						$og_w = $size[0];
						$og_h = $size[1];
					}
				}
			}
			if ( $og_w && $og_h ) {
				echo '<meta property="og:image:width" content="' . esc_attr( $og_w ) . '">' . "\n";
				echo '<meta property="og:image:height" content="' . esc_attr( $og_h ) . '">' . "\n";
			}
		}
		echo '<meta property="og:url" content="' . esc_url( $og_url ) . '">' . "\n";
		echo '<meta property="og:type" content="' . esc_attr( $og_type ) . '">' . "\n";
		echo '<meta property="og:site_name" content="' . esc_attr( $site_name ) . '">' . "\n";
		echo '<meta property="og:locale" content="' . esc_attr( get_locale() ) . '">' . "\n";

		// Article timestamps for article-type pages.
		if ( 'article' === $og_type && $post_id ) {
			$modified = get_the_modified_date( 'c', $post_id );
			if ( $modified ) {
				echo '<meta property="article:modified_time" content="' . esc_attr( $modified ) . '">' . "\n";
			}
			$published = get_the_date( 'c', $post_id );
			if ( $published ) {
				echo '<meta property="article:published_time" content="' . esc_attr( $published ) . '">' . "\n";
			}
		}
	}

	/**
	 * Twitter Card tags.
	 */
	public function output_twitter_cards(): void {
		// When Yoast is active, only output if Dennis has OG data (Twitter mirrors OG).
		if ( $this->yoast_active ) {
			$post_id = is_singular() ? get_queried_object_id() : 0;
			if ( $post_id ) {
				$has_og = ! empty( get_post_meta( $post_id, '_dennis_seo_og_title', true ) )
					|| ! empty( get_post_meta( $post_id, '_dennis_seo_og_description', true ) );
				if ( ! $has_og ) {
					return; // Let Yoast handle Twitter cards.
				}
			} else {
				return;
			}
		}

		$post_id = is_singular() ? get_queried_object_id() : 0;

		$title = '';
		$desc  = '';
		$image = '';

		if ( $post_id ) {
			$title = Dennis_SEO_Meta_Box::get_meta( $post_id, '_dennis_seo_og_title' )
				?: Dennis_SEO_Meta_Box::get_meta( $post_id, '_dennis_seo_title' )
				?: get_the_title( $post_id );
			$desc = Dennis_SEO_Meta_Box::get_meta( $post_id, '_dennis_seo_og_description' )
				?: Dennis_SEO_Meta_Box::get_meta( $post_id, '_dennis_seo_description' )
				?: wp_trim_words( get_the_excerpt( $post_id ), 25, '...' );
			$image = Dennis_SEO_Meta_Box::get_meta( $post_id, '_dennis_seo_og_image' )
				?: get_the_post_thumbnail_url( $post_id, 'large' );
		}
		// Fallback: default OG image > site logo > theme custom logo.
		if ( empty( $image ) ) {
			$image = Dennis_SEO_Settings::get( 'default_og_image' );
			if ( empty( $image ) ) {
				$image = Dennis_SEO_Settings::get( 'org_logo' );
			}
			if ( empty( $image ) ) {
				$custom_logo_id = get_theme_mod( 'custom_logo' );
				if ( $custom_logo_id ) {
					$image = wp_get_attachment_image_url( $custom_logo_id, 'full' );
				}
			}
		}
		// Ensure absolute URL.
		if ( ! empty( $image ) ) {
			$image = $this->make_absolute_url( $image );
		}

		echo '<meta name="twitter:card" content="summary_large_image">' . "\n";
		if ( ! empty( $title ) ) {
			echo '<meta name="twitter:title" content="' . esc_attr( $title ) . '">' . "\n";
		}
		if ( ! empty( $desc ) ) {
			echo '<meta name="twitter:description" content="' . esc_attr( $desc ) . '">' . "\n";
		}
		if ( ! empty( $image ) ) {
			echo '<meta name="twitter:image" content="' . esc_url( $image ) . '">' . "\n";
		}
	}

	/**
	 * JSON-LD Schema output.
	 */
	/**
	 * Check if the current page is a WooCommerce product.
	 */
	private function is_woocommerce_product(): bool {
		return function_exists( 'is_product' ) && is_product();
	}

	public function output_schema(): void {
		// When Yoast is active, only output Dennis schema if we have enhanced data.
		if ( $this->yoast_active ) {
			$post_id = is_singular() ? get_queried_object_id() : 0;
			$has_schema_override = $post_id && ! empty( get_post_meta( $post_id, '_dennis_seo_schema_type', true ) );
			$has_enhanced_org = ! empty( Dennis_SEO_Settings::get( 'org_geo_lat' ) )
				|| ! empty( Dennis_SEO_Settings::get( 'org_opening_hours' ) )
				|| ! empty( Dennis_SEO_Settings::get( 'org_parent_name' ) )
				|| ! empty( Dennis_SEO_Settings::get( 'org_service_area' ) );

			if ( ! $has_schema_override && ! $has_enhanced_org ) {
				return;
			}
		}

		$graph = array();

		// Organization / LocalBusiness.
		if ( Dennis_SEO_Settings::get( 'schema_organization', true ) ) {
			$org = $this->build_organization_schema();
			if ( $org ) {
				$graph[] = $org;
			}
		}

		// WebSite (homepage).
		if ( Dennis_SEO_Settings::get( 'schema_website', true ) ) {
			$graph[] = $this->build_website_schema();
		}

		// Per-page schema — skip on WooCommerce product pages (WooCommerce outputs its own Product schema).
		if ( is_singular() && ! $this->is_woocommerce_product() ) {
			$page_schema = $this->build_page_schema( get_queried_object_id() );
			if ( $page_schema ) {
				if ( isset( $page_schema[0] ) && is_array( $page_schema[0] ) ) {
					foreach ( $page_schema as $entity ) {
						$graph[] = $entity;
					}
				} else {
					$graph[] = $page_schema;
				}
			}
		}

		// BreadcrumbList — skip on WooCommerce product pages (WooCommerce outputs its own).
		if ( Dennis_SEO_Settings::get( 'schema_breadcrumbs', true ) && is_singular() && ! is_front_page() && ! $this->is_woocommerce_product() ) {
			$breadcrumbs = $this->build_breadcrumb_schema( get_queried_object_id() );
			if ( $breadcrumbs ) {
				$graph[] = $breadcrumbs;
			}
		}

		// FAQPage schema (auto-detect details blocks).
		if ( is_singular() ) {
			$faq = $this->build_faq_schema( get_queried_object_id() );
			if ( $faq ) {
				$graph[] = $faq;
			}
		}

		if ( empty( $graph ) ) {
			return;
		}

		$output = array(
			'@context' => 'https://schema.org',
			'@graph'   => $graph,
		);

		echo '<script type="application/ld+json">' . wp_json_encode( $output, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT ) . '</script>' . "\n";
	}

	private function build_organization_schema(): array {
		$type = Dennis_SEO_Settings::get( 'site_type', 'Organization' );
		// LocalBusiness gets dual-typed with ProfessionalService for richer classification.
		// All other types (Organization, Corporation, NGO, etc.) use a single type.
		$schema_type = ( 'LocalBusiness' === $type )
			? array( 'LocalBusiness', 'ProfessionalService' )
			: $type;
		$org  = array(
			'@type' => $schema_type,
			'@id'   => home_url( '/#organization' ),
			'name'  => Dennis_SEO_Settings::get( 'site_name', get_bloginfo( 'name' ) ),
			'url'   => home_url( '/' ),
		);

		$desc = Dennis_SEO_Settings::get( 'business_description' );
		if ( $desc ) {
			$org['description'] = $desc;
		}

		// Logo — from settings or theme custom_logo.
		$logo = Dennis_SEO_Settings::get( 'org_logo' );
		if ( empty( $logo ) ) {
			$custom_logo_id = get_theme_mod( 'custom_logo' );
			if ( $custom_logo_id ) {
				$logo = wp_get_attachment_image_url( $custom_logo_id, 'full' );
			}
		}
		if ( $logo ) {
			$logo = $this->make_absolute_url( $logo );
			$org['logo'] = array(
				'@type' => 'ImageObject',
				'url'   => $logo,
			);
			$org['image'] = $logo;
		}

		$phone = Dennis_SEO_Settings::get( 'org_phone' );
		if ( $phone ) {
			$org['telephone'] = $phone;
		}

		$email = Dennis_SEO_Settings::get( 'org_email' );
		if ( $email ) {
			$org['email'] = $email;
		}

		// Address.
		$street  = Dennis_SEO_Settings::get( 'org_address_street' );
		$city    = Dennis_SEO_Settings::get( 'org_address_city' );
		$zip     = Dennis_SEO_Settings::get( 'org_address_zip' );
		$country = Dennis_SEO_Settings::get( 'org_address_country' );
		if ( $street || $city ) {
			$address = array( '@type' => 'PostalAddress' );
			if ( $street ) {
				$address['streetAddress'] = $street;
			}
			if ( $city ) {
				$address['addressLocality'] = $city;
			}
			if ( $zip ) {
				$address['postalCode'] = $zip;
			}
			if ( $country ) {
				$address['addressCountry'] = $country;
			}
			$org['address'] = $address;
		}

		// Service area — auto-detect type (Country, City, or generic Place).
		$area = Dennis_SEO_Settings::get( 'org_service_area' );
		if ( $area ) {
			$countries = array( 'Sweden', 'Germany', 'France', 'Denmark', 'Norway', 'Finland', 'UK', 'United Kingdom', 'USA', 'United States', 'Netherlands', 'Belgium', 'Austria', 'Switzerland', 'Italy', 'Spain', 'Portugal', 'Poland', 'Czech Republic', 'Ireland' );
			$continents = array( 'Europe', 'North America', 'South America', 'Asia', 'Africa', 'Oceania', 'Worldwide' );
			$areas = array_map( 'trim', explode( ',', $area ) );

			$served = array();
			foreach ( $areas as $a ) {
				if ( in_array( $a, $continents, true ) ) {
					$served[] = array( '@type' => 'Place', 'name' => $a );
				} elseif ( in_array( $a, $countries, true ) ) {
					$served[] = array( '@type' => 'Country', 'name' => $a );
				} else {
					$served[] = array( '@type' => 'City', 'name' => $a );
				}
			}

			$org['areaServed'] = count( $served ) === 1 ? $served[0] : $served;
		}

		// Geo coordinates.
		$lat = Dennis_SEO_Settings::get( 'org_geo_lat' );
		$lng = Dennis_SEO_Settings::get( 'org_geo_lng' );
		if ( $lat && $lng ) {
			$org['geo'] = array(
				'@type'     => 'GeoCoordinates',
				'latitude'  => $lat,
				'longitude' => $lng,
			);
		}

		// Opening hours — output both shorthand and structured specification.
		$hours = Dennis_SEO_Settings::get( 'org_opening_hours' );
		if ( $hours ) {
			$org['openingHours'] = $hours;
			// Parse "Mo-Fr HH:MM-HH:MM" into OpeningHoursSpecification.
			if ( preg_match( '/^([A-Za-z]{2})-([A-Za-z]{2})\s+(\d{2}:\d{2})-(\d{2}:\d{2})$/', $hours, $m ) ) {
				$day_map = array(
					'Mo' => 'Monday', 'Tu' => 'Tuesday', 'We' => 'Wednesday',
					'Th' => 'Thursday', 'Fr' => 'Friday', 'Sa' => 'Saturday', 'Su' => 'Sunday',
				);
				$start_day = $day_map[ $m[1] ] ?? null;
				$end_day   = $day_map[ $m[2] ] ?? null;
				if ( $start_day && $end_day ) {
					$days      = array_keys( $day_map );
					$start_idx = array_search( $m[1], $days, true );
					$end_idx   = array_search( $m[2], $days, true );
					$active    = array();
					for ( $i = $start_idx; $i <= $end_idx; $i++ ) {
						$active[] = $day_map[ $days[ $i ] ];
					}
					$org['openingHoursSpecification'] = array(
						array(
							'@type'     => 'OpeningHoursSpecification',
							'dayOfWeek' => $active,
							'opens'     => $m[3],
							'closes'    => $m[4],
						),
					);
				}
			}
		}

		// Price range.
		$price_range = Dennis_SEO_Settings::get( 'org_price_range' );
		if ( $price_range ) {
			$org['priceRange'] = $price_range;
		}

		// sameAs (social profiles).
		$same_as = array();
		foreach ( array( 'social_facebook', 'social_instagram', 'social_linkedin', 'social_google' ) as $key ) {
			$url = Dennis_SEO_Settings::get( $key );
			if ( ! empty( $url ) ) {
				$same_as[] = $url;
			}
		}
		if ( ! empty( $same_as ) ) {
			$org['sameAs'] = $same_as;
		}

		// Parent organization (from settings, optional).
		$parent_name = Dennis_SEO_Settings::get( 'org_parent_name' );
		$parent_url  = Dennis_SEO_Settings::get( 'org_parent_url' );
		if ( $parent_name ) {
			$parent = array( '@type' => 'Organization', 'name' => $parent_name );
			if ( $parent_url ) {
				$parent['url'] = $parent_url;
			}
			$org['parentOrganization'] = $parent;
		}

		return $org;
	}

	/**
	 * Convert WordPress locale (sv_SE) to BCP 47 format (sv-SE).
	 */
	private function get_language_tag(): string {
		return str_replace( '_', '-', get_locale() );
	}

	/**
	 * Ensure a URL is absolute. Converts relative paths to full URLs.
	 */
	private function make_absolute_url( string $url ): string {
		if ( empty( $url ) ) {
			return '';
		}
		// Already absolute.
		if ( str_starts_with( $url, 'http://' ) || str_starts_with( $url, 'https://' ) ) {
			return $url;
		}
		// Relative path — prepend site URL.
		return home_url( $url );
	}

	private function build_website_schema(): array {
		$schema = array(
			'@type'      => 'WebSite',
			'@id'        => home_url( '/#website' ),
			'url'        => home_url( '/' ),
			'name'       => Dennis_SEO_Settings::get( 'site_name', get_bloginfo( 'name' ) ),
			'inLanguage' => $this->get_language_tag(),
			'publisher'  => array( '@id' => home_url( '/#organization' ) ),
		);

		// SearchAction.
		$schema['potentialAction'] = array(
			'@type'       => 'SearchAction',
			'target'      => home_url( '/?s={search_term_string}' ),
			'query-input' => 'required name=search_term_string',
		);

		return $schema;
	}

	/**
	 * Build page-level schema. For Service pages, outputs both WebPage and Service as separate entities.
	 *
	 * @return array|array[] Single schema array, or array of arrays for Service pages.
	 */
	private function build_page_schema( int $post_id ) {
		$schema_type = Dennis_SEO_Meta_Box::get_meta( $post_id, '_dennis_seo_schema_type' );
		if ( empty( $schema_type ) ) {
			$post_type   = get_post_type( $post_id );
			$schema_type = 'page' === $post_type
				? Dennis_SEO_Settings::get( 'schema_default_page', 'WebPage' )
				: Dennis_SEO_Settings::get( 'schema_default_post', 'Article' );
		}

		$desc = Dennis_SEO_Meta_Box::get_meta( $post_id, '_dennis_seo_description' );
		$image = get_the_post_thumbnail_url( $post_id, 'large' );

		// Always create a WebPage entity.
		$webpage_type = 'Service' === $schema_type ? 'WebPage' : $schema_type;
		$webpage = array(
			'@type'         => $webpage_type,
			'@id'           => get_permalink( $post_id ) . '#webpage',
			'url'           => get_permalink( $post_id ),
			'name'          => get_the_title( $post_id ),
			'inLanguage'    => $this->get_language_tag(),
			'isPartOf'      => array( '@id' => home_url( '/#website' ) ),
			'datePublished' => get_the_date( 'c', $post_id ),
			'dateModified'  => get_the_modified_date( 'c', $post_id ),
		);
		if ( $desc ) {
			$webpage['description'] = $desc;
		}
		if ( $image ) {
			$webpage['image'] = $image;
		}

		// Author for articles.
		if ( in_array( $schema_type, array( 'Article', 'BlogPosting' ), true ) ) {
			$author              = get_the_author_meta( 'display_name', get_post_field( 'post_author', $post_id ) );
			$webpage['author']   = array( '@type' => 'Person', 'name' => $author );
			$webpage['publisher'] = array( '@id' => home_url( '/#organization' ) );
		}

		// For Service pages, create a separate Service entity linked to the WebPage.
		if ( 'Service' === $schema_type ) {
			$webpage['about'] = array( '@id' => get_permalink( $post_id ) . '#service' );

			$service = array(
				'@type'       => 'Service',
				'@id'         => get_permalink( $post_id ) . '#service',
				'name'        => get_the_title( $post_id ),
				'provider'    => array( '@id' => home_url( '/#organization' ) ),
				'serviceType' => get_the_title( $post_id ),
			);
			if ( $desc ) {
				$service['description'] = $desc;
			}
			$area = Dennis_SEO_Settings::get( 'org_service_area' );
			if ( $area ) {
				$areas = array_map( 'trim', explode( ',', $area ) );
				$service['areaServed'] = array_map( function ( $a ) {
					return array( '@type' => 'City', 'name' => $a );
				}, $areas );
			}
			// Service offer (configurable via settings).
			$offer_text = Dennis_SEO_Settings::get( 'service_offer_text' );
			if ( ! empty( $offer_text ) ) {
				$service['offers'] = array(
					'@type'         => 'Offer',
					'price'         => '0',
					'priceCurrency' => 'SEK',
					'description'   => $offer_text,
				);
			}

			return array( $webpage, $service );
		}

		return $webpage;
	}

	private function build_breadcrumb_schema( int $post_id ): array {
		$items   = array();
		$items[] = array(
			'@type'    => 'ListItem',
			'position' => 1,
			'name'     => __( 'Home', 'dennis-geo' ),
			'item'     => home_url( '/' ),
		);

		// For pages with parents, add hierarchy.
		$ancestors = get_post_ancestors( $post_id );
		$position  = 2;
		foreach ( array_reverse( $ancestors ) as $ancestor_id ) {
			$items[] = array(
				'@type'    => 'ListItem',
				'position' => $position,
				'name'     => get_the_title( $ancestor_id ),
				'item'     => get_permalink( $ancestor_id ),
			);
			$position++;
		}

		$items[] = array(
			'@type'    => 'ListItem',
			'position' => $position,
			'name'     => get_the_title( $post_id ),
			'item'     => get_permalink( $post_id ),
		);

		return array(
			'@type'           => 'BreadcrumbList',
			'itemListElement' => $items,
		);
	}

	/**
	 * Auto-detect FAQ schema from details blocks.
	 */
	private function build_faq_schema( int $post_id ): ?array {
		$post = get_post( $post_id );
		if ( ! $post || ! has_block( 'core/details', $post->post_content ) ) {
			return null;
		}

		$faq_items = array();
		$blocks    = parse_blocks( $post->post_content );
		$this->find_faq_blocks( $blocks, $faq_items );

		if ( empty( $faq_items ) ) {
			return null;
		}

		return array(
			'@type'      => 'FAQPage',
			'mainEntity' => $faq_items,
		);
	}

	private function find_faq_blocks( array $blocks, array &$items ): void {
		foreach ( $blocks as $block ) {
			if ( 'core/details' === $block['blockName'] ) {
				$rendered = render_block( $block );
				if ( preg_match( '/<summary>(.*?)<\/summary>/s', $rendered, $q ) ) {
					$question = wp_strip_all_tags( $q[1] );
					if ( preg_match( '/<\/summary>(.*?)<\/details>/s', $rendered, $a ) ) {
						$answer  = wp_strip_all_tags( $a[1] );
						$items[] = array(
							'@type'          => 'Question',
							'name'           => trim( $question ),
							'acceptedAnswer' => array(
								'@type' => 'Answer',
								'text'  => trim( $answer ),
							),
						);
					}
				}
			}
			if ( ! empty( $block['innerBlocks'] ) ) {
				$this->find_faq_blocks( $block['innerBlocks'], $items );
			}
		}
	}

	/**
	 * llms.txt link tag.
	 */
	public function output_llms_link(): void {
		if ( Dennis_SEO_Settings::get( 'llms_link_tag', true ) && Dennis_SEO_Settings::get( 'llms_enabled', true ) ) {
			echo '<link rel="llms-txt" href="' . esc_url( home_url( '/llms.txt' ) ) . '">' . "\n";
		}
	}

	/**
	 * Send Last-Modified header.
	 */
	public function send_last_modified_header(): void {
		if ( is_singular() ) {
			$post_id  = get_queried_object_id();
			$timestamp = get_post_modified_time( 'U', true, $post_id );
			if ( $timestamp ) {
				// Use gmdate to force English day/month names per RFC 7232.
				header( 'Last-Modified: ' . gmdate( 'D, d M Y H:i:s', (int) $timestamp ) . ' GMT' );
			}
		}
	}
}
