<?php
/**
 * Plugin Name:       Livada SEO
 * Plugin URI:        https://livada.fr
 * Description:       Lightweight, zero-frontend-overhead SEO plugin: meta tags, multi-location LocalBusiness, XML + News + HTML sitemaps, schemas (Article, FAQ, HowTo, Product, Event, Recipe, Review, Offer, Campground), redirects + 404 monitor, bulk editor, site audit, Yoast/SEOPress importer, hreflang (Polylang/WPML/TranslatePress), AEO (llms.txt), IndexNow, WooCommerce, image SEO with AI alt-text, plus a Claude Code MCP server.
 * Version:           1.14.1
 * Update URI:        https://livada.io/api/update/info
 * Requires at least: 6.0
 * Requires PHP:      7.4
 * Author:            Livada
 * Author URI:        https://livada.fr
 * License:           GPL-2.0-or-later
 * License URI:       https://www.gnu.org/licenses/gpl-2.0.html
 * Text Domain:       livada-seo
 * Domain Path:       /languages
 */

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

// ── Constants ─────────────────────────────────────────────────────────────────

define( 'LIVADA_SEO_VERSION', '1.14.1' );
define( 'LIVADA_SEO_PATH',    plugin_dir_path( __FILE__ ) );
define( 'LIVADA_SEO_URL',     plugin_dir_url( __FILE__ ) );

// ── Error logging ─────────────────────────────────────────────────────────────

// Off by default. Define LIVADA_SEO_DEBUG = true in wp-config.php to enable
// per-plugin error logging to error.log inside this plugin's directory.
if ( ! defined( 'LIVADA_SEO_DEBUG' ) ) {
	define( 'LIVADA_SEO_DEBUG', false );
}
if ( LIVADA_SEO_DEBUG ) {
	@ini_set( 'display_errors', 0 );
	@ini_set( 'log_errors', 1 );
	@ini_set( 'error_log', LIVADA_SEO_PATH . 'error.log' );
}

// ── Loader ────────────────────────────────────────────────────────────────────

require_once LIVADA_SEO_PATH . 'includes/class-loader.php';

// ── Activation ────────────────────────────────────────────────────────────────

register_activation_hook( __FILE__, 'livada_seo_activate' );

function livada_seo_activate(): void {
	global $wpdb;

	// 1. Create redirects table via dbDelta.
	$charset_collate = $wpdb->get_charset_collate();
	$table_name      = $wpdb->prefix . 'livada_seo_redirects';

	$sql = "CREATE TABLE {$table_name} (
		id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
		source_url VARCHAR(500) NOT NULL,
		target_url VARCHAR(500) NOT NULL,
		type SMALLINT NOT NULL DEFAULT 301,
		hits INT UNSIGNED NOT NULL DEFAULT 0,
		last_hit DATETIME NULL,
		created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
		PRIMARY KEY (id),
		UNIQUE KEY source_url (source_url(191))
	) {$charset_collate};";

	require_once ABSPATH . 'wp-admin/includes/upgrade.php';
	dbDelta( $sql );

	// 2. Set default options (only if not already set).
	$existing = get_option( 'livada_seo_settings' );

	$is_first_activation = ( false === $existing );

	if ( $is_first_activation ) {
		$defaults = livada_seo_default_settings();

		// 3. Generate IndexNow key UUID on first activation.
		$defaults['indexnow_key'] = livada_seo_generate_uuid();

		add_option( 'livada_seo_settings', $defaults, '', false );
	}

	// 4. Flush rewrite rules so any custom rewrite endpoints register cleanly.
	flush_rewrite_rules();

	// 5. Trigger the onboarding wizard on the next admin load (first activation only).
	if ( $is_first_activation ) {
		require_once LIVADA_SEO_PATH . 'includes/class-onboarding.php';
		if ( class_exists( 'Livada_SEO_Onboarding' ) ) {
			Livada_SEO_Onboarding::flag_first_activation();
		}
	}

	// 6. Force an OPcache reset so any new methods/hooks added since the
	//    previous version are visible immediately (Hostinger LiteSpeed has
	//    very persistent OPcache in production mode).
	livada_seo_reset_opcache();
}

// ── Deactivation ──────────────────────────────────────────────────────────────

register_deactivation_hook( __FILE__, 'livada_seo_deactivate' );

function livada_seo_deactivate(): void {
	// Flush rewrites so our custom rules are removed.
	flush_rewrite_rules();

	// Clear all transients created by this plugin.
	livada_seo_clear_transients();

	// Clear scheduled hooks. Only `livada_seo_cron_audit` is a recurring event;
	// `livada_seo_indexnow_ping` is a single-event queue that may have pending
	// pings. The legacy `livada_seo_cron_sitemap` / `livada_seo_cron_indexnow`
	// hooks were never actually scheduled by the plugin; they are cleared here
	// only for forward-compatibility with old installs.
	wp_clear_scheduled_hook( 'livada_seo_cron_audit' );
	wp_clear_scheduled_hook( 'livada_seo_indexnow_ping' );
	wp_clear_scheduled_hook( 'livada_seo_cron_sitemap' );
	wp_clear_scheduled_hook( 'livada_seo_cron_indexnow' );
}

// ── Helpers ───────────────────────────────────────────────────────────────────

/**
 * Returns the complete default settings array.
 *
 * Kept here (not only in the Loader) so activation can use it without loading
 * the full Loader at activation time.
 */
function livada_seo_default_settings(): array {
	return [
		'modules'               => [ 'meta', 'sitemap', 'schema', 'opengraph', 'redirects', 'breadcrumbs', 'analysis', 'robots', 'indexnow', 'toc', 'image_seo', 'analytics', 'geo', 'aeo', 'sitemap_html', 'site_audit', 'woocommerce', 'og_image' ],
		'separator'             => '|',
		'site_name'             => '',
		'title_home'            => '%sitename% %sep% %tagline%',
		'title_post'            => '%title% %sep% %sitename%',
		'title_page'            => '%title% %sep% %sitename%',
		'title_category'        => '%category% %sep% %sitename%',
		'title_tag'             => '%tag% %sep% %sitename%',
		'title_taxonomy'        => '%term% %sep% %sitename%',
		'desc_home'             => '',
		'schema_type'           => 'LocalBusiness',
		'schema_name'           => '',
		'schema_address'        => '',
		'schema_city'           => '',
		'schema_postal'         => '',
		'schema_country'        => 'FR',
		'schema_phone'          => '',
		'schema_email'          => '',
		'schema_url'            => '',
		'schema_logo'           => '',
		'schema_geo_lat'        => '',
		'schema_geo_lng'        => '',
		'schema_price_range'    => '',
		'schema_opening_hours'  => [],
		'schema_same_as'        => [],
		'schema_services'       => [],
		// Tourism / lodging fields — emitted only when set, work with any
		// LocalBusiness subtype (Campground, LodgingBusiness, Hotel, Resort, etc.).
		'schema_star_rating'    => '',
		'schema_number_of_rooms'=> '',
		'schema_pets_allowed'   => '',
		'schema_checkin_time'   => '',
		'schema_checkout_time'  => '',
		'schema_amenities'      => [],
		'schema_rating_value'   => '',
		'schema_review_count'   => '',
		'og_default_image'      => '',
		'twitter_card_type'     => 'summary_large_image',
		'social_facebook'       => '',
		'social_twitter'        => '',
		'social_linkedin'       => '',
		'social_instagram'      => '',
		'sitemap_post_types'    => [ 'post', 'page' ],
		'sitemap_taxonomies'    => [ 'category' ],
		'sitemap_max_urls'      => 1000,
		// Google News sub-sitemap (sitemap-news.xml). Listed in the index only
		// when posts published in the last 48h match these post types.
		'sitemap_news_post_types'         => [ 'post' ],
		'sitemap_news_publication_name'   => '', // falls back to bloginfo('name')
		'sitemap_news_language'           => '', // falls back to wp locale prefix
		'breadcrumb_separator'  => '&raquo;',
		'breadcrumb_home_text'  => 'Accueil',
		'robots_txt'            => "User-agent: *\nAllow: /\nSitemap: %siteurl%/sitemap.xml",
		'indexnow_key'          => '',
		'analytics_ga4_id'          => '',   // e.g. 'G-XXXXXXXXXX'
		'analytics_matomo_url'      => '',   // e.g. 'https://matomo.livada.fr/'
		'analytics_matomo_site_id'  => '',   // e.g. '1'
		'analytics_clarity_id'      => '',   // e.g. 'abcdefghij'
		'analytics_custom_head'     => '',   // raw <script> tag
		'analytics_exclude_admins'  => true, // skip tracking for manage_options users
		'geo_enabled'               => true,
		'geo_author_expertise'      => '',   // e.g. 'Expert SEO et marketing digital, 20 ans d\'expérience'
		'geo_author_url'            => '',   // e.g. 'https://livada.fr/about'
		'geo_speakable'             => true,
		'geo_citation_meta'         => true,
		// AEO — AI Engine Optimization.
		'aeo_enabled'               => true,
		'aeo_max_snippet'           => true,   // output max-snippet robots directive
		'aeo_llms_txt'              => true,   // serve /llms.txt and /llms-full.txt
		'aeo_knowledge_graph'       => true,   // output Organization JSON-LD entity
		'aeo_org_description'       => '',     // organization description for llms.txt / JSON-LD
		'aeo_org_founding_date'     => '',     // e.g. '2005'
		'aeo_org_founder'           => '',     // founder full name
		'aeo_org_knows_about'       => [],     // array of expertise topics
		'aeo_wikidata_id'           => '',     // Wikidata entity Q-id (e.g. 'Q140007358') — off-site identity signal, auto-injected into schema sameAs
		'aeo_content_signals'       => true,   // add AI-friendly content markers via the_content
		// Anthropic API key for vision-powered alt-text generation (Image SEO bulk).
		// Stored encrypted at rest with `enc:` prefix when set via the admin UI.
		'anthropic_api_key'         => '',
		// Auto-generated 1200×630 OG image when no custom one is set. Off by
		// default — opt in under General Settings. Requires GD; falls back
		// gracefully when no TTF font is available on the host.
		'og_image_enabled'          => false,
		'og_image_brand_color'      => '#1d2327',
		'og_image_accent_color'     => '#dba617',
		'og_image_font_path'        => '',
		// AI Bots Access Control (1.13.0+). Map: bot_key => bool.
		// Empty by default — the runtime merges with the registry defaults
		// so new bots added in future versions inherit their default automatically.
		'ai_bots'                   => [],
		// Hard block: when true, the plugin returns a real HTTP 403 to any
		// blocked bot's user-agent. When false, only the robots.txt directive
		// is published (well-behaved bots obey, scrapers ignore it).
		'ai_bots_hard_block'        => true,
		'import_completed'          => false,
		'db_version'                => '1.0.0',
	];
}

/**
 * Generates a UUID v4 string for use as the IndexNow key.
 */
function livada_seo_generate_uuid(): string {
	$data    = random_bytes( 16 );
	$data[6] = chr( ( ord( $data[6] ) & 0x0f ) | 0x40 ); // version 4
	$data[8] = chr( ( ord( $data[8] ) & 0x3f ) | 0x80 ); // variant RFC 4122

	return vsprintf( '%s%s-%s-%s-%s-%s%s%s', str_split( bin2hex( $data ), 4 ) );
}

/**
 * Deletes all transients whose option_name starts with `_transient_livada_seo_`.
 * Direct SQL is intentional: there is no WP API to bulk-delete by prefix.
 */
function livada_seo_clear_transients(): void {
	global $wpdb;

	// phpcs:ignore WordPress.DB.DirectDatabaseQuery
	$wpdb->query(
		"DELETE FROM {$wpdb->options}
		 WHERE option_name LIKE '_transient_livada_seo_%'
		    OR option_name LIKE '_transient_timeout_livada_seo_%'"
	);
}

// ── Locale fallback for French variants ──────────────────────────────────────
//
// WordPress looks for the EXACT filename `livada-seo-{locale}.mo`. When the
// site locale is fr_BE / fr_CA / fr_CH / fr_LU there is no dedicated .mo, so
// the plugin would silently fall back to English even though the client speaks
// French. We catch every fr_* locale and route it through fr_FR.
//
// Same intent for pt_BR → pt_PT (Brazil shares vocabulary with Portugal close
// enough for an SEO admin UI). Easy to extend with additional fallbacks later.

add_filter( 'plugin_locale', 'livada_seo_locale_fallback', 10, 2 );
add_filter( 'load_textdomain_mofile', 'livada_seo_mofile_fallback', 10, 2 );

function livada_seo_locale_fallback( $locale, $domain ) {
	if ( 'livada-seo' !== $domain ) {
		return $locale;
	}
	if ( is_string( $locale ) && 'fr_FR' !== $locale && 0 === strpos( $locale, 'fr' ) ) {
		return 'fr_FR';
	}
	if ( is_string( $locale ) && 0 === strpos( $locale, 'pt' ) && 'pt_PT' !== $locale ) {
		// Map pt_BR (and any other pt_*) to pt_PT once we ship one. No-op today
		// because pt_PT.mo doesn't exist yet, but the hook is ready.
		return 'pt_PT';
	}
	return $locale;
}

function livada_seo_mofile_fallback( $mofile, $domain ) {
	if ( 'livada-seo' !== $domain ) {
		return $mofile;
	}
	// If the requested .mo doesn't exist but it's a French variant, fall back
	// to the canonical fr_FR file. Belt-and-suspenders with plugin_locale —
	// some hosts/plugins bypass plugin_locale on certain code paths (notably
	// the "just-in-time" loader on WP 5.0+).
	if ( ! file_exists( $mofile ) && false !== strpos( $mofile, '/livada-seo-fr' ) ) {
		$fr_fr = preg_replace( '#/livada-seo-fr[^/]*\.mo$#', '/livada-seo-fr_FR.mo', $mofile );
		if ( $fr_fr !== $mofile && file_exists( $fr_fr ) ) {
			return $fr_fr;
		}
	}
	return $mofile;
}

// ── OPcache auto-reset on plugin update ──────────────────────────────────────
//
// Hostinger LiteSpeed (and many managed PHP hosts) ship with OPcache running
// in production mode (`validate_timestamps=0` or `revalidate_freq` set very
// high). When WordPress replaces the plugin files during an update the new
// PHP bytecode can take 10–30 minutes to pick up — which means new hooks,
// new methods or new admin views appear "missing" until OPcache revalidates.
//
// We force a reset on two events:
//   1. After WP finishes a plugin upgrade (`upgrader_process_complete`).
//   2. On plugin activation (covers fresh installs after a manual ZIP upload).
//
// `opcache_reset()` is a no-op when OPcache is disabled or when the function
// is not whitelisted (some hosts blacklist it), so the call is safe everywhere.

function livada_seo_reset_opcache(): void {
	if ( function_exists( 'opcache_reset' ) ) {
		@opcache_reset();
	}
}

add_action(
	'upgrader_process_complete',
	function ( $upgrader, $hook_extra ) {
		if ( ! isset( $hook_extra['type'] ) || 'plugin' !== $hook_extra['type'] ) {
			return;
		}
		// Trigger only when our plugin is part of the batch.
		$plugins = $hook_extra['plugins'] ?? [];
		if ( ! is_array( $plugins ) || ! in_array( plugin_basename( __FILE__ ), $plugins, true ) ) {
			return;
		}
		livada_seo_reset_opcache();
	},
	10,
	2
);

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

add_action( 'plugins_loaded', [ 'Livada_SEO_Loader', 'init' ] );
