<?php
/**
 * SailWP Co-pilot — SEO meta output for designed pages.
 *
 * Generates title, meta description, Open Graph, Twitter card,
 * canonical URL, and schema.org WebPage/BlogPosting tags for any
 * page flagged `_swcp_designed=1`.
 *
 * Co-existence: if a known SEO plugin is active (Yoast / Rank Math /
 * AIOSEO / SEOPress / The SEO Framework) we step aside entirely —
 * that plugin is the source of truth, double tags would only confuse
 * crawlers. Joost's 2026-05-15 decision: "respect — alleen actief
 * als geen andere SEO plugin."
 *
 * Scope: only fires on `_swcp_designed=1` pages. Regular WP pages,
 * posts, archives, etc. fall back to whatever WordPress core or the
 * active theme already emits.
 */
if ( ! defined( 'ABSPATH' ) ) exit;

class SailWP_Copilot_SEO {

	const DESCRIPTION_MAX = 155;
	const SCHEMA_KEY      = 'application/ld+json';

	public function __construct() {
		// Bail out entirely if another SEO plugin owns the head. We
		// check on `init` so plugins that load late still get
		// detected — most SEO plugins announce their globals during
		// `plugins_loaded`, which fires before `init`.
		add_action( 'init', [ $this, 'maybe_register' ], 20 );
	}

	public function maybe_register() {
		if ( $this->another_seo_plugin_active() ) {
			return;
		}
		// Priority 1 so our tags sit at the top of <head>, just
		// after the core charset/viewport meta WP emits.
		add_action( 'wp_head', [ $this, 'output_head_tags' ], 1 );
		// Replace WP's default <title> when we're handling a designed
		// page. We register the filter unconditionally and bail
		// inside if it isn't a designed page.
		add_filter( 'pre_get_document_title', [ $this, 'filter_document_title' ], 20 );
	}

	/**
	 * Return true when a competing SEO plugin is loaded. Add new
	 * detection clauses here as we encounter them in the wild.
	 */
	public function another_seo_plugin_active() {
		// Yoast SEO + Yoast SEO Premium
		if ( defined( 'WPSEO_VERSION' ) ) return true;
		if ( class_exists( 'WPSEO_Options' ) ) return true;
		// Rank Math
		if ( defined( 'RANK_MATH_VERSION' ) ) return true;
		if ( class_exists( 'RankMath' ) ) return true;
		// All in One SEO
		if ( defined( 'AIOSEO_VERSION' ) ) return true;
		if ( function_exists( 'aioseo' ) ) return true;
		// SEOPress
		if ( defined( 'SEOPRESS_VERSION' ) ) return true;
		// The SEO Framework
		if ( defined( 'THE_SEO_FRAMEWORK_VERSION' ) ) return true;
		if ( class_exists( 'The_SEO_Framework\\Load' ) ) return true;
		// Slim SEO
		if ( defined( 'SLIM_SEO_VER' ) ) return true;

		/**
		 * Allow other code to short-circuit our SEO output (e.g. a
		 * site that has its own bespoke meta layer). Return true to
		 * have SailWP stay out.
		 */
		return (bool) apply_filters( 'sailwp_seo_defer_to_other_plugin', false );
	}

	/**
	 * The current request maps to a designed page we should handle.
	 * Returns the post object on hit, false otherwise.
	 */
	private function current_designed_post() {
		if ( ! is_singular() ) return false;
		$post = get_post();
		if ( ! $post ) return false;
		if ( ! get_post_meta( $post->ID, '_swcp_designed', true ) ) return false;
		return $post;
	}

	public function filter_document_title( $title ) {
		$post = $this->current_designed_post();
		if ( ! $post ) return $title;
		return $this->build_title( $post );
	}

	public function output_head_tags() {
		$post = $this->current_designed_post();
		if ( ! $post ) return;

		$title       = esc_html( $this->build_title( $post ) );
		$description = esc_attr( $this->build_description( $post ) );
		$canonical   = esc_url( get_permalink( $post ) );
		$site_name   = esc_attr( get_bloginfo( 'name' ) );
		$image       = esc_url( $this->build_og_image( $post ) );
		$locale      = esc_attr( $this->og_locale() );
		$type        = 'page' === $post->post_type ? 'website' : 'article';
		$published   = esc_attr( mysql2date( 'c', $post->post_date_gmt, false ) );
		$modified    = esc_attr( mysql2date( 'c', $post->post_modified_gmt, false ) );

		echo "\n<!-- SailWP Co-pilot SEO -->\n";
		echo '<meta name="description" content="' . $description . '">' . "\n";
		echo '<link rel="canonical" href="' . $canonical . '">' . "\n";

		// Open Graph
		echo '<meta property="og:title" content="' . $title . '">' . "\n";
		echo '<meta property="og:description" content="' . $description . '">' . "\n";
		echo '<meta property="og:type" content="' . $type . '">' . "\n";
		echo '<meta property="og:url" content="' . $canonical . '">' . "\n";
		echo '<meta property="og:site_name" content="' . $site_name . '">' . "\n";
		echo '<meta property="og:locale" content="' . $locale . '">' . "\n";
		if ( $image ) {
			echo '<meta property="og:image" content="' . $image . '">' . "\n";
		}
		if ( 'article' === $type ) {
			echo '<meta property="article:published_time" content="' . $published . '">' . "\n";
			echo '<meta property="article:modified_time" content="' . $modified . '">' . "\n";
		}

		// Twitter card
		echo '<meta name="twitter:card" content="' . ( $image ? 'summary_large_image' : 'summary' ) . '">' . "\n";
		echo '<meta name="twitter:title" content="' . $title . '">' . "\n";
		echo '<meta name="twitter:description" content="' . $description . '">' . "\n";
		if ( $image ) {
			echo '<meta name="twitter:image" content="' . $image . '">' . "\n";
		}

		// Schema.org structured data
		$schema = $this->build_schema( $post, $title, $description, $canonical, $image, $published, $modified );
		if ( $schema ) {
			echo '<script type="' . self::SCHEMA_KEY . '">' . wp_json_encode( $schema, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ) . '</script>' . "\n";
		}

		echo "<!-- /SailWP Co-pilot SEO -->\n\n";
	}

	private function build_title( $post ) {
		$site_name = get_bloginfo( 'name' );
		$title     = get_the_title( $post );
		$separator = apply_filters( 'document_title_separator', '·' );
		if ( ! $title ) return $site_name;
		return $title . ' ' . $separator . ' ' . $site_name;
	}

	/**
	 * Build a meta description from the post. Priority:
	 *   1. `_swcp_seo_description` post meta (manual override)
	 *   2. post_excerpt if set
	 *   3. First paragraph of stripped post_content
	 * Capped at DESCRIPTION_MAX with a trailing ellipsis.
	 */
	private function build_description( $post ) {
		$manual = trim( (string) get_post_meta( $post->ID, '_swcp_seo_description', true ) );
		if ( $manual !== '' ) {
			return $this->trim_to_length( $manual, self::DESCRIPTION_MAX );
		}

		$excerpt = trim( (string) $post->post_excerpt );
		if ( $excerpt !== '' ) {
			return $this->trim_to_length( wp_strip_all_tags( $excerpt ), self::DESCRIPTION_MAX );
		}

		// Pull the first meaningful paragraph from the design content.
		// strip_shortcodes first so [shortcode] noise doesn't leak in.
		$body = strip_shortcodes( (string) $post->post_content );
		// Drop <style> + <script> blocks completely — designed pages
		// embed CSS inline at the top and we don't want to render it.
		$body = preg_replace( '#<(style|script)[^>]*>.*?</\1>#is', ' ', $body );
		$body = wp_strip_all_tags( $body );
		$body = preg_replace( '/\s+/', ' ', $body );
		$body = trim( $body );

		return $this->trim_to_length( $body, self::DESCRIPTION_MAX );
	}

	/**
	 * Find an OG image. Priority:
	 *   1. `_swcp_seo_image` post meta (manual override URL)
	 *   2. Post featured image
	 *   3. First <img src> in post_content
	 */
	private function build_og_image( $post ) {
		$manual = trim( (string) get_post_meta( $post->ID, '_swcp_seo_image', true ) );
		if ( $manual !== '' ) return $manual;

		$featured = (int) get_post_thumbnail_id( $post );
		if ( $featured ) {
			$src = wp_get_attachment_image_url( $featured, 'large' );
			if ( $src ) return $src;
		}

		// Scrape the first <img src="..."> from content.
		if ( preg_match( '/<img[^>]+src=["\']([^"\']+)["\']/i', (string) $post->post_content, $m ) ) {
			return $m[1];
		}

		return '';
	}

	private function og_locale() {
		$locale = get_locale();
		// WP uses xx_YY format which OG also accepts. Fall back to
		// en_US when something exotic comes through.
		if ( ! preg_match( '/^[a-z]{2,3}_[A-Z]{2}$/', $locale ) ) {
			return 'en_US';
		}
		return $locale;
	}

	private function trim_to_length( $text, $max ) {
		$text = trim( $text );
		if ( $text === '' ) return '';
		if ( strlen( $text ) <= $max ) return $text;
		// Avoid cutting mid-word: trim to the last space within the
		// budget, append an ellipsis. mb_strimwidth handles
		// multibyte safely.
		$cut = function_exists( 'mb_strimwidth' )
			? mb_strimwidth( $text, 0, $max, '' )
			: substr( $text, 0, $max );
		$lastSpace = strrpos( $cut, ' ' );
		if ( $lastSpace !== false && $lastSpace > $max - 20 ) {
			$cut = substr( $cut, 0, $lastSpace );
		}
		return rtrim( $cut, " .,;:" ) . '…';
	}

	/**
	 * Build a schema.org graph: WebSite + WebPage (or BlogPosting for
	 * posts). One @graph wrapper so a single JSON-LD block covers
	 * everything search engines need.
	 */
	private function build_schema( $post, $title, $description, $canonical, $image, $published, $modified ) {
		$site_name = get_bloginfo( 'name' );
		$site_url  = home_url( '/' );
		$is_post   = ( 'post' === $post->post_type );
		$author    = get_userdata( (int) $post->post_author );

		$graph = [];

		$graph[] = [
			'@type'    => 'WebSite',
			'@id'      => $site_url . '#website',
			'url'      => $site_url,
			'name'     => $site_name,
			'inLanguage' => str_replace( '_', '-', get_locale() ),
		];

		$page_node = [
			'@type'         => $is_post ? 'BlogPosting' : 'WebPage',
			'@id'           => $canonical . '#main',
			'url'           => $canonical,
			'name'          => $title,
			'headline'      => $title,
			'description'   => $description,
			'isPartOf'      => [ '@id' => $site_url . '#website' ],
			'inLanguage'    => str_replace( '_', '-', get_locale() ),
			'datePublished' => $published,
			'dateModified'  => $modified,
		];

		if ( $image ) {
			$page_node['image'] = [
				'@type' => 'ImageObject',
				'url'   => $image,
			];
		}

		if ( $is_post && $author ) {
			$page_node['author'] = [
				'@type' => 'Person',
				'name'  => $author->display_name,
			];
		}

		$graph[] = $page_node;

		return [
			'@context' => 'https://schema.org',
			'@graph'   => $graph,
		];
	}
}
