<?php
/**
 * Livada SEO — Meta Module
 *
 * Handles document title resolution, meta description, canonical URL, and
 * robots directives for every page type WordPress can serve.
 *
 * Priority chain for titles:
 *   post meta `_livada_seo_title` > CPT/page-type template from settings > WP default
 *
 * @package Livada_SEO
 */

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

class Livada_SEO_Meta {

	// ── Bootstrap ─────────────────────────────────────────────────────────────

	/**
	 * Registers all hooks for this module.
	 * Called once by Livada_SEO_Loader::init().
	 */
	public static function init(): void {
		// Title filters — both hooks needed because some themes (Hello Elementor,
		// Astra, etc.) use `pre_get_document_title` which bypasses `document_title_parts`.
		// Priority 999 ensures we override theme-injected titles.
		add_filter( 'pre_get_document_title', [ self::class, 'pre_resolve_title' ], 999 );
		add_filter( 'document_title_parts',   [ self::class, 'resolve_title' ], 10 );

		// Head output — description, canonical, robots.
		add_action( 'wp_head', [ self::class, 'output_head_tags' ], 1 );

		// Remove WP core duplicates — plugin handles canonical and robots.
		// We do it 3 times to be bullet-proof against themes/plugins that re-add them :
		//   1. Now (plugins_loaded) — usually enough
		//   2. On `wp` hook (after WP query is set up but before wp_head)
		//   3. Belt-and-suspenders : neutralize via wp_robots filter (always works)
		self::remove_core_duplicates();
		add_action( 'wp', [ self::class, 'remove_core_duplicates' ], 99 );
		add_filter( 'wp_robots', '__return_empty_array', 9999 );
	}

	/**
	 * Removes WordPress core duplicate canonical / robots tags.
	 * Called twice (on plugins_loaded and on `wp` hook) for robustness.
	 */
	public static function remove_core_duplicates(): void {
		remove_action( 'wp_head', 'rel_canonical' );
		remove_action( 'wp_head', 'wp_robots', 1 );
	}

	// ── Pre-title (high-priority override) ────────────────────────────────────

	/**
	 * Filter `pre_get_document_title` — runs BEFORE wp_get_document_title() builds
	 * the title from parts. Returning a non-empty string here completely overrides
	 * any subsequent filter chain (including theme overrides).
	 *
	 * Mirrors the priority chain of `resolve_title()` :
	 *   post meta `_livada_seo_title` > settings template > WP default (returns '')
	 *
	 * @param string $title Initial title (usually empty unless another plugin set it).
	 * @return string The fully rendered title, or empty string to defer to WP.
	 */
	public static function pre_resolve_title( string $title ): string {
		// 1. Post meta override.
		if ( is_singular() ) {
			$post_id    = get_queried_object_id();
			$meta_title = get_post_meta( $post_id, '_livada_seo_title', true );

			if ( $meta_title !== '' && $meta_title !== false ) {
				return self::replace_vars( $meta_title );
			}
		}

		// 2. Settings template (front page, post types, taxonomies, etc.).
		$template = self::get_template_for_context();

		if ( $template !== '' ) {
			return self::replace_vars( $template );
		}

		// 3. Defer to WP default rendering.
		return $title;
	}

	// ── Title ─────────────────────────────────────────────────────────────────

	/**
	 * Filters the document title parts array produced by `wp_get_document_title()`.
	 *
	 * When a custom title is resolved (via post meta or settings template) the
	 * `title` key is replaced with the fully rendered string and all other parts
	 * (`tagline`, `site`, `page`) are emptied so WP's concatenation step outputs
	 * exactly what we built.
	 *
	 * @param  array<string,string> $parts  Associative array with keys such as
	 *                                      `title`, `page`, `tagline`, `site`.
	 * @return array<string,string>
	 */
	public static function resolve_title( array $parts ): array {

		// 1. Post meta override — highest priority.
		if ( is_singular() ) {
			$post_id    = get_queried_object_id();
			$meta_title = get_post_meta( $post_id, '_livada_seo_title', true );

			if ( $meta_title !== '' && $meta_title !== false ) {
				$parts = self::flatten_parts( $parts, self::replace_vars( $meta_title ) );
				return $parts;
			}
		}

		// 2. Settings template — per page type.
		$template = self::get_template_for_context();

		if ( $template !== '' ) {
			$parts = self::flatten_parts( $parts, self::replace_vars( $template ) );
			return $parts;
		}

		// 3. WP default — return parts untouched.
		return $parts;
	}

	/**
	 * Collapses all title parts into a single `title` key so WP's separator
	 * logic outputs exactly the pre-rendered string.
	 *
	 * @param  array<string,string> $parts
	 * @param  string               $rendered
	 * @return array<string,string>
	 */
	private static function flatten_parts( array $parts, string $rendered ): array {
		return [
			'title'   => $rendered,
			'page'    => '',
			'tagline' => '',
			'site'    => '',
		];
	}

	// ── Head tags ─────────────────────────────────────────────────────────────

	/**
	 * Outputs `<meta name="description">`, `<link rel="canonical">`, and
	 * `<meta name="robots">` into `<head>`.
	 *
	 * Hooked on `wp_head` at priority 1 so it fires before most other plugins.
	 */
	public static function output_head_tags(): void {
		self::output_description();
		self::output_canonical();
		self::output_robots();
	}

	// ── Description ───────────────────────────────────────────────────────────

	/**
	 * Resolves and outputs `<meta name="description">`.
	 *
	 * Priority chain:
	 *   post meta `_livada_seo_description`
	 *   > settings `desc_home` (front page only)
	 *   > description template from settings (resolved via replace_vars)
	 *   > post excerpt (singular)
	 */
	private static function output_description(): void {
		$description = self::resolve_description();

		if ( $description === '' ) {
			return;
		}

		printf(
			'<meta name="description" content="%s" />' . "\n",
			esc_attr( $description )
		);
	}

	/**
	 * Returns the resolved meta description string, or empty string if none.
	 *
	 * @return string
	 */
	private static function resolve_description(): string {

		// 1. Post meta override.
		if ( is_singular() ) {
			$post_id = get_queried_object_id();
			$meta    = get_post_meta( $post_id, '_livada_seo_description', true );

			if ( $meta !== '' && $meta !== false ) {
				return wp_strip_all_tags( $meta );
			}
		}

		// 2. Home page setting.
		if ( is_front_page() || is_home() ) {
			$desc_home = Livada_SEO_Loader::get_setting( 'desc_home', '' );

			if ( $desc_home !== '' ) {
				return wp_strip_all_tags( self::replace_vars( $desc_home ) );
			}

			// Fall through to excerpt / bloginfo below.
		}

		// 3. Singular: post excerpt.
		if ( is_singular() ) {
			$post = get_queried_object();

			if ( $post instanceof WP_Post ) {
				$excerpt = $post->post_excerpt;

				if ( $excerpt === '' ) {
					// Auto-generate excerpt from content.
					$excerpt = wp_trim_words(
						strip_shortcodes( wp_strip_all_tags( $post->post_content ) ),
						30,
						''
					);
				}

				return wp_strip_all_tags( $excerpt );
			}
		}

		return '';
	}

	// ── Canonical ─────────────────────────────────────────────────────────────

	/**
	 * Resolves and outputs `<link rel="canonical">`.
	 *
	 * Priority chain:
	 *   post meta `_livada_seo_canonical`
	 *   > `get_permalink()` for singular; term link / archive link for archives
	 */
	private static function output_canonical(): void {
		$canonical = self::resolve_canonical();

		if ( $canonical === '' ) {
			return;
		}

		printf(
			'<link rel="canonical" href="%s" />' . "\n",
			esc_attr( esc_url( $canonical ) )
		);
	}

	/**
	 * Returns the resolved canonical URL, or empty string if none.
	 *
	 * @return string
	 */
	private static function resolve_canonical(): string {

		// 1. Post meta override (singular only).
		if ( is_singular() ) {
			$post_id = get_queried_object_id();
			$meta    = get_post_meta( $post_id, '_livada_seo_canonical', true );

			if ( $meta !== '' && $meta !== false ) {
				return esc_url_raw( $meta );
			}

			return (string) get_permalink( $post_id );
		}

		// 2. Front page.
		if ( is_front_page() ) {
			return home_url( '/' );
		}

		// 3. Term archives.
		if ( is_category() || is_tag() || is_tax() ) {
			$term = get_queried_object();

			if ( $term instanceof WP_Term ) {
				$link = get_term_link( $term );

				if ( ! is_wp_error( $link ) ) {
					return (string) $link;
				}
			}
		}

		// 4. Post-type archives.
		if ( is_post_type_archive() ) {
			$link = get_post_type_archive_link( get_queried_object()->name ?? '' );

			if ( $link ) {
				return (string) $link;
			}
		}

		// 5. Author archives.
		if ( is_author() ) {
			return (string) get_author_posts_url( get_queried_object_id() );
		}

		// 6. Date archives.
		if ( is_day() ) {
			return (string) get_day_link( get_query_var( 'year' ), get_query_var( 'monthnum' ), get_query_var( 'day' ) );
		}

		if ( is_month() ) {
			return (string) get_month_link( get_query_var( 'year' ), get_query_var( 'monthnum' ) );
		}

		if ( is_year() ) {
			return (string) get_year_link( get_query_var( 'year' ) );
		}

		return '';
	}

	// ── Robots ────────────────────────────────────────────────────────────────

	/**
	 * Resolves and outputs `<meta name="robots">` only when noindex or nofollow
	 * is active on the current post, or globally for its post type.
	 *
	 * Does not output the tag at all when both are off (lets WP default apply).
	 */
	private static function output_robots(): void {
		[ $noindex, $nofollow ] = self::resolve_robots();

		if ( ! $noindex && ! $nofollow ) {
			return;
		}

		$directives = [];

		if ( $noindex ) {
			$directives[] = 'noindex';
		} else {
			$directives[] = 'index';
		}

		if ( $nofollow ) {
			$directives[] = 'nofollow';
		} else {
			$directives[] = 'follow';
		}

		printf(
			'<meta name="robots" content="%s" />' . "\n",
			esc_attr( implode( ', ', $directives ) )
		);
	}

	/**
	 * Returns a two-element array [ $noindex (bool), $nofollow (bool) ]
	 * for the current page.
	 *
	 * Priority:
	 *   post meta (`_livada_seo_noindex`, `_livada_seo_nofollow`)
	 *   > CPT-level global from settings (`cpt_noindex_{post_type}`, `cpt_nofollow_{post_type}`)
	 *
	 * @return array{bool, bool}
	 */
	private static function resolve_robots(): array {
		$noindex  = false;
		$nofollow = false;

		if ( is_singular() ) {
			$post_id = get_queried_object_id();

			// Post-level flags.
			$meta_noindex  = get_post_meta( $post_id, '_livada_seo_noindex',  true );
			$meta_nofollow = get_post_meta( $post_id, '_livada_seo_nofollow', true );

			if ( $meta_noindex !== '' && $meta_noindex !== false ) {
				$noindex = (bool) $meta_noindex;
			}

			if ( $meta_nofollow !== '' && $meta_nofollow !== false ) {
				$nofollow = (bool) $meta_nofollow;
			}

			// CPT-level fallback from settings.
			if ( ! $noindex || ! $nofollow ) {
				$post_type = get_post_type( $post_id );

				if ( $post_type ) {
					if ( ! $noindex ) {
						$noindex = (bool) Livada_SEO_Loader::get_setting( 'cpt_noindex_' . $post_type, false );
					}

					if ( ! $nofollow ) {
						$nofollow = (bool) Livada_SEO_Loader::get_setting( 'cpt_nofollow_' . $post_type, false );
					}
				}
			}
		}

		return [ $noindex, $nofollow ];
	}

	// ── Template resolution ───────────────────────────────────────────────────

	/**
	 * Returns the settings template string for the current page context,
	 * or an empty string when no template applies.
	 *
	 * Checks (in order):
	 *   - Front page          → `title_home`
	 *   - Single post         → `title_post`
	 *   - Page (static)       → `title_page`
	 *   - Category archive    → `title_category`
	 *   - Tag archive         → `title_tag`
	 *   - Custom taxonomy     → `title_taxonomy`
	 *   - Custom post type    → `title_{post_type}` (falls back to `title_post`)
	 *
	 * @return string
	 */
	private static function get_template_for_context(): string {

		if ( is_front_page() || is_home() ) {
			return (string) Livada_SEO_Loader::get_setting( 'title_home', '' );
		}

		if ( is_singular() ) {
			$post_type = get_post_type();

			if ( $post_type === 'post' ) {
				return (string) Livada_SEO_Loader::get_setting( 'title_post', '' );
			}

			if ( $post_type === 'page' ) {
				return (string) Livada_SEO_Loader::get_setting( 'title_page', '' );
			}

			// Custom post types: look for a dedicated key, then fall back to title_post.
			$cpt_key = 'title_' . $post_type;
			$cpt_tpl = (string) Livada_SEO_Loader::get_setting( $cpt_key, '' );

			return $cpt_tpl !== ''
				? $cpt_tpl
				: (string) Livada_SEO_Loader::get_setting( 'title_post', '' );
		}

		if ( is_category() ) {
			return (string) Livada_SEO_Loader::get_setting( 'title_category', '' );
		}

		if ( is_tag() ) {
			return (string) Livada_SEO_Loader::get_setting( 'title_tag', '' );
		}

		if ( is_tax() ) {
			return (string) Livada_SEO_Loader::get_setting( 'title_taxonomy', '' );
		}

		return '';
	}

	// ── Variable replacement ──────────────────────────────────────────────────

	/**
	 * Replaces all supported template variables in the given string.
	 *
	 * Supported tokens:
	 *   %title%     — Post/page title or queried term name
	 *   %sitename%  — Site name from settings (fallback to get_bloginfo('name'))
	 *   %sep%       — Separator from settings (fallback '|')
	 *   %tagline%   — get_bloginfo('description')
	 *   %category%  — First assigned category name
	 *   %term%      — Queried term name (custom taxonomy)
	 *   %tag%       — First assigned tag name
	 *   %date%      — Post publication date (Y-m-d)
	 *   %page%      — Current page number (omitted on page 1)
	 *
	 * @param  string $template Raw template string.
	 * @return string           Rendered string with all tokens replaced.
	 */
	private static function replace_vars( string $template ): string {

		// Bail early when there's nothing to replace.
		if ( strpos( $template, '%' ) === false ) {
			return $template;
		}

		$replacements = self::build_replacements();

		return str_replace(
			array_keys( $replacements ),
			array_values( $replacements ),
			$template
		);
	}

	/**
	 * Builds the full token → value map for the current request context.
	 *
	 * Values are computed lazily only when the corresponding token appears in
	 * the template, but for clarity we build the full map here and let PHP's
	 * str_replace() skip unused tokens.
	 *
	 * @return array<string,string>
	 */
	private static function build_replacements(): array {

		// ── Globals (always cheap) ────────────────────────────────────────────

		$sep      = (string) Livada_SEO_Loader::get_setting( 'separator', '|' );
		$sitename = (string) Livada_SEO_Loader::get_setting( 'site_name', '' );

		if ( $sitename === '' ) {
			$sitename = get_bloginfo( 'name' );
		}

		$tagline = get_bloginfo( 'description' );

		// ── Page number ───────────────────────────────────────────────────────

		$paged     = max( 1, (int) get_query_var( 'paged' ) );
		$page_num  = $paged > 1 ? (string) $paged : '';

		// ── Context-dependent values ─────────────────────────────────────────

		$title    = '';
		$category = '';
		$tag      = '';
		$term     = '';
		$date     = '';

		$queried = get_queried_object();

		if ( is_singular() && $queried instanceof WP_Post ) {
			$title = get_the_title( $queried );
			$date  = get_the_date( 'Y-m-d', $queried );

			// First category.
			$cats = get_the_category( $queried->ID );

			if ( ! empty( $cats ) ) {
				$category = $cats[0]->name;
			}

			// First tag.
			$tags = get_the_tags( $queried->ID );

			if ( is_array( $tags ) && ! empty( $tags ) ) {
				$tag = $tags[0]->name;
			}

		} elseif ( is_category() && $queried instanceof WP_Term ) {
			$title    = $queried->name;
			$category = $queried->name;

		} elseif ( is_tag() && $queried instanceof WP_Term ) {
			$title = $queried->name;
			$tag   = $queried->name;

		} elseif ( ( is_tax() || is_archive() ) && $queried instanceof WP_Term ) {
			$title = $queried->name;
			$term  = $queried->name;

		} elseif ( is_post_type_archive() && $queried instanceof WP_Post_Type ) {
			$title = $queried->label;

		} elseif ( is_front_page() || is_home() ) {
			$title = $sitename;
		}

		// ── Assemble map ──────────────────────────────────────────────────────

		return [
			'%title%'    => $title,
			'%sitename%' => $sitename,
			'%sep%'      => $sep,
			'%tagline%'  => $tagline,
			'%category%' => $category,
			'%term%'     => $term,
			'%tag%'      => $tag,
			'%date%'     => $date,
			'%page%'     => $page_num,
		];
	}
}
