<?php
/**
 * Livada SEO — Self-hosted Update Checker
 *
 * Makes the plugin show up under "Mettre à jour maintenant" on the WP
 * Extensions page when a newer version is available, just like a plugin
 * from the WordPress.org repository — except the source of truth is a
 * JSON manifest hosted on livada.fr.
 *
 * Workflow:
 *   1. WordPress fires `pre_set_site_transient_update_plugins` (once per
 *      page-load, throttled by WP's own caching).
 *   2. We fetch `https://livada.fr/livada-seo-updates/info.json` (cached
 *      locally for 12h) and compare its `version` field against the running
 *      LIVADA_SEO_VERSION.
 *   3. If the remote version is greater, we inject an entry into the
 *      transient `response` array. WP then shows "update available" on
 *      the Extensions page and lets the user click "Update now" — which
 *      downloads the package URL we returned and unzips it in place.
 *
 * Hosting requirements (on livada.fr or any HTTPS server):
 *
 *     https://livada.fr/livada-seo-updates/info.json   (small JSON manifest)
 *     https://livada.fr/livada-seo-updates/livada-seo.zip  (the plugin ZIP, standard WP format)
 *
 * The JSON manifest format is documented in `info.example.json`.
 *
 * Filters:
 *   livada_seo_update_url         — override the manifest URL (e.g. dev/staging)
 *   livada_seo_update_cache_ttl   — override the 12h cache TTL (seconds)
 *   livada_seo_update_disabled    — return true to disable the updater entirely
 *
 * @package Livada_SEO
 */

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

class Livada_SEO_Updater {

	/**
	 * Default JSON manifest URL — override via the `livada_seo_update_url` filter.
	 *
	 * Routed through the Cloudflare Worker (license.livada.fr) for the same
	 * reason as the licence endpoints: a direct request to
	 * https://livada.fr/livada-seo-updates/info.json from a WP client site is
	 * blocked by Hostinger HCDN's JS challenge. The Worker fetches over an
	 * IP whitelisted by HCDN and re-serves the JSON unchanged.
	 */
	const DEFAULT_MANIFEST_URL = 'https://livada.io/api/update/info';

	/** Plugin slug as registered with WordPress. */
	const SLUG = 'livada-seo';

	/** Plugin file path relative to /wp-content/plugins/. */
	const PLUGIN_FILE = 'livada-seo/livada-seo.php';

	/** Transient key for the cached remote manifest. */
	const CACHE_KEY = 'livada_seo_remote_info';

	/** Cache TTL — 12 hours. */
	const CACHE_TTL = 12 * HOUR_IN_SECONDS;

	/** Option key — stores the last error so admin notices can surface it. */
	const LAST_ERROR_KEY = 'livada_seo_update_last_error';

	/** Short cache TTL on FAILURE — 15 min. Prevents hammering while allowing retry. */
	const CACHE_TTL_FAIL = 15 * MINUTE_IN_SECONDS;

	/** Transient key for the cached "fetch failed, retry later" marker. */
	const FAIL_CACHE_KEY = 'livada_seo_remote_info_fail';

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

	public static function init(): void {
		if ( apply_filters( 'livada_seo_update_disabled', false ) ) {
			return;
		}

		add_filter( 'pre_set_site_transient_update_plugins', [ self::class, 'inject_update' ] );
		add_filter( 'plugins_api', [ self::class, 'plugin_information' ], 10, 3 );

		// Bust our cache after a successful upgrade so the "update available"
		// notice doesn't linger one extra render cycle.
		add_action( 'upgrader_process_complete', [ self::class, 'on_upgrade_complete' ], 10, 2 );

		// Manual "Check for updates" link on the Extensions row.
		add_filter( 'plugin_row_meta', [ self::class, 'plugin_row_meta' ], 10, 2 );
		add_action( 'admin_init', [ self::class, 'maybe_force_check' ] );

		// Surface the last update-check error so the user isn't left guessing.
		add_action( 'admin_notices', [ self::class, 'maybe_render_error_notice' ] );
	}

	/**
	 * Render an admin notice on the Extensions / Updates / Livada SEO pages
	 * if the last update check failed. Auto-dismisses on next successful check.
	 */
	public static function maybe_render_error_notice(): void {
		$err = get_option( self::LAST_ERROR_KEY );
		if ( ! is_array( $err ) || empty( $err['code'] ) ) {
			return;
		}
		if ( ! current_user_can( 'update_plugins' ) ) {
			return;
		}
		// Only show on update-relevant screens to avoid noise.
		$screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
		$relevant_ids = [ 'plugins', 'update-core', 'dashboard', 'plugin-install', 'toplevel_page_livada-seo' ];
		if ( $screen && ! in_array( $screen->id, $relevant_ids, true ) && 0 !== strpos( (string) $screen->id, 'livada-seo' ) ) {
			return;
		}
		$when    = isset( $err['time'] ) ? human_time_diff( (int) $err['time'], time() ) : '?';
		$code    = (string) ( $err['code'] ?? 'unknown' );
		$message = (string) ( $err['message'] ?? '' );
		$ssl     = (string) ( $err['ssl'] ?? '' );

		printf(
			'<div class="notice notice-warning is-dismissible"><p><strong>Livada SEO — Update check failed (%s ago)</strong><br>Code: <code>%s</code> — %s%s</p></div>',
			esc_html( $when ),
			esc_html( $code ),
			esc_html( $message ),
			$ssl ? ' <em>(' . esc_html( $ssl ) . ' SSL)</em>' : ''
		);
	}

	// ── Manifest fetch + cache ────────────────────────────────────────────────

	/**
	 * Returns the parsed remote manifest, or null on failure.
	 * Cached for 12 hours by default.
	 *
	 * @param bool $force Force a fresh fetch even if a cache entry exists.
	 * @return array<string,mixed>|null
	 */
	public static function get_remote_info( bool $force = false ): ?array {
		if ( ! $force ) {
			$cached = get_transient( self::CACHE_KEY );
			if ( is_array( $cached ) ) {
				return $cached;
			}
			// If we recently failed, don't hammer the server.
			if ( get_transient( self::FAIL_CACHE_KEY ) ) {
				return null;
			}
		}

		$url = (string) apply_filters( 'livada_seo_update_url', self::DEFAULT_MANIFEST_URL );
		if ( '' === $url ) {
			return null;
		}

		// If the host opted into WP_HTTP_BLOCK_EXTERNAL, make sure our domain is whitelisted.
		// Adding the filter is idempotent and only affects this request lifecycle.
		add_filter( 'http_request_host_is_external', [ self::class, 'allow_remote_host' ], 10, 3 );

		$data = self::http_fetch_json( $url, /*relaxed_ssl=*/false );

		// Some hosts (older PHP, broken CA bundles) fail SSL verification.
		// Retry once with sslverify=false as a graceful fallback.
		if ( null === $data ) {
			$data = self::http_fetch_json( $url, /*relaxed_ssl=*/true );
		}

		if ( null === $data ) {
			// Cache the failure briefly so we retry in ~15 min but don't spam.
			set_transient( self::FAIL_CACHE_KEY, time(), self::CACHE_TTL_FAIL );
			return null;
		}

		// Success — clear any previous error + populate the success transient.
		delete_option( self::LAST_ERROR_KEY );
		delete_transient( self::FAIL_CACHE_KEY );

		$ttl = (int) apply_filters( 'livada_seo_update_cache_ttl', self::CACHE_TTL );
		set_transient( self::CACHE_KEY, $data, max( 60, $ttl ) );

		return $data;
	}

	/**
	 * One round-trip to the manifest URL. Records a structured error in
	 * the LAST_ERROR_KEY option on failure so admin notices can surface it.
	 *
	 * @return array<string,mixed>|null
	 */
	private static function http_fetch_json( string $url, bool $relaxed_ssl ): ?array {
		$args = [
			'timeout'     => 30,                 // Hostinger / shared hosts can be slow.
			'redirection' => 3,
			'httpversion' => '1.1',              // Avoid HTTP/2 quirks on older PHP/curl.
			'sslverify'   => ! $relaxed_ssl,     // First attempt verifies; retry relaxes.
			'blocking'    => true,
			'user-agent'  => 'Livada-SEO-Updater/' . ( defined( 'LIVADA_SEO_VERSION' ) ? LIVADA_SEO_VERSION : '0.0.0' ),
			'headers'     => [
				'Accept'        => 'application/json',
				'Cache-Control' => 'no-cache',
			],
		];

		$response = wp_remote_get( $url, $args );

		if ( is_wp_error( $response ) ) {
			update_option(
				self::LAST_ERROR_KEY,
				[
					'time'    => time(),
					'code'    => $response->get_error_code(),
					'message' => $response->get_error_message(),
					'ssl'     => $relaxed_ssl ? 'relaxed' : 'strict',
				],
				false
			);
			return null;
		}

		$status = (int) wp_remote_retrieve_response_code( $response );
		if ( 200 !== $status ) {
			update_option(
				self::LAST_ERROR_KEY,
				[
					'time'    => time(),
					'code'    => 'http_' . $status,
					'message' => sprintf( 'Unexpected HTTP %d from %s', $status, $url ),
					'ssl'     => $relaxed_ssl ? 'relaxed' : 'strict',
				],
				false
			);
			return null;
		}

		$body = (string) wp_remote_retrieve_body( $response );
		if ( '' === $body ) {
			update_option(
				self::LAST_ERROR_KEY,
				[ 'time' => time(), 'code' => 'empty_body', 'message' => 'Empty response body', 'ssl' => $relaxed_ssl ? 'relaxed' : 'strict' ],
				false
			);
			return null;
		}

		$data = json_decode( $body, true );
		if ( ! is_array( $data ) || empty( $data['version'] ) ) {
			update_option(
				self::LAST_ERROR_KEY,
				[
					'time'    => time(),
					'code'    => 'invalid_json',
					'message' => 'Response is not a valid JSON manifest (missing "version")',
					'ssl'     => $relaxed_ssl ? 'relaxed' : 'strict',
				],
				false
			);
			return null;
		}

		return $data;
	}

	/**
	 * Whitelist livada.io for the duration of an update check, in case the
	 * host has `WP_HTTP_BLOCK_EXTERNAL` enabled in wp-config.php.
	 *
	 * Hooked transiently inside get_remote_info().
	 */
	public static function allow_remote_host( $is_external, string $host, string $url ): bool {
		// Don't override an already-true value (it means WP would have let it through anyway).
		if ( $is_external ) {
			return $is_external;
		}
		// Default manifest URL is parsed once.
		$default_host = (string) wp_parse_url( self::DEFAULT_MANIFEST_URL, PHP_URL_HOST );
		// Also allow the filtered URL host (custom dev/staging endpoints).
		$filtered_host = (string) wp_parse_url(
			(string) apply_filters( 'livada_seo_update_url', self::DEFAULT_MANIFEST_URL ),
			PHP_URL_HOST
		);
		return ( $host === $default_host || $host === $filtered_host );
	}

	// ── Filter: inject into the update transient ─────────────────────────────

	/**
	 * Hooked on `pre_set_site_transient_update_plugins`. WordPress passes us
	 * the upcoming transient value; we add our entry when a new version is
	 * available so the Extensions page renders the "Update now" link.
	 */
	public static function inject_update( $transient ) {
		// `$transient` may be false on first call before WP populates it.
		if ( ! is_object( $transient ) ) {
			return $transient;
		}
		if ( empty( $transient->checked ) ) {
			return $transient;
		}

		$remote = self::get_remote_info();
		if ( ! $remote ) {
			return $transient;
		}

		$current = defined( 'LIVADA_SEO_VERSION' ) ? LIVADA_SEO_VERSION : '0.0.0';
		$latest  = (string) $remote['version'];

		if ( ! version_compare( $latest, $current, '>' ) ) {
			// Up to date — keep the no_update entry so WP knows we checked.
			$transient->no_update[ self::PLUGIN_FILE ] = self::build_response_object( $remote, $latest );
			return $transient;
		}

		$transient->response[ self::PLUGIN_FILE ] = self::build_response_object( $remote, $latest );
		return $transient;
	}

	private static function build_response_object( array $remote, string $version ): object {
		// Gate the download behind a valid licence.
		// Livada_SEO_License::get_download_url() appends ?license_key=&site_url=
		// to the base URL so the server can validate before serving the ZIP.
		// If no valid licence is present it returns '' — WP treats an empty
		// package as "update available but not downloadable".
		$base_url = (string) ( $remote['download_url'] ?? '' );
		if ( class_exists( 'Livada_SEO_License' ) ) {
			$package = Livada_SEO_License::get_download_url( $base_url );
		} else {
			$package = $base_url;
		}

		return (object) [
			'id'           => self::PLUGIN_FILE,
			'slug'         => self::SLUG,
			'plugin'       => self::PLUGIN_FILE,
			'new_version'  => $version,
			'url'          => (string) ( $remote['homepage']     ?? 'https://livada.fr' ),
			'package'      => $package,
			'tested'       => (string) ( $remote['tested']       ?? '' ),
			'requires'     => (string) ( $remote['requires']     ?? '6.0' ),
			'requires_php' => (string) ( $remote['requires_php'] ?? '7.4' ),
			'icons'        => is_array( $remote['icons']   ?? null ) ? $remote['icons']   : [],
			'banners'      => is_array( $remote['banners'] ?? null ) ? $remote['banners'] : [],
		];
	}

	// ── Filter: "View details" modal on the Extensions page ───────────────────

	/**
	 * Hooked on `plugins_api`. Provides changelog + description when the user
	 * clicks "Voir les détails de la version X.Y.Z" in the update-available row.
	 */
	public static function plugin_information( $result, $action, $args ) {
		if ( 'plugin_information' !== $action ) {
			return $result;
		}
		if ( empty( $args->slug ) || self::SLUG !== $args->slug ) {
			return $result;
		}

		$remote = self::get_remote_info();
		if ( ! $remote ) {
			return $result;
		}

		$sections = [];
		foreach ( [ 'description', 'installation', 'changelog', 'upgrade_notice', 'faq' ] as $key ) {
			if ( ! empty( $remote['sections'][ $key ] ) ) {
				$sections[ $key ] = (string) $remote['sections'][ $key ];
			} elseif ( ! empty( $remote[ $key ] ) ) {
				$sections[ $key ] = (string) $remote[ $key ];
			}
		}

		return (object) [
			'name'           => (string) ( $remote['name']         ?? 'Livada SEO' ),
			'slug'           => self::SLUG,
			'version'        => (string) $remote['version'],
			'author'         => (string) ( $remote['author']       ?? 'Livada' ),
			'author_profile' => (string) ( $remote['author_profile'] ?? 'https://livada.fr' ),
			'homepage'       => (string) ( $remote['homepage']     ?? 'https://livada.fr' ),
			'requires'       => (string) ( $remote['requires']     ?? '6.0' ),
			'tested'         => (string) ( $remote['tested']       ?? '' ),
			'requires_php'   => (string) ( $remote['requires_php'] ?? '7.4' ),
			'download_link'  => (string) ( $remote['download_url'] ?? '' ),
			'last_updated'   => (string) ( $remote['last_updated'] ?? '' ),
			'sections'       => $sections,
			'banners'        => is_array( $remote['banners'] ?? null ) ? $remote['banners'] : [],
			'icons'          => is_array( $remote['icons']   ?? null ) ? $remote['icons']   : [],
		];
	}

	// ── Cache invalidation after a successful upgrade ─────────────────────────

	public static function on_upgrade_complete( $upgrader, $hook_extra ): void {
		if ( empty( $hook_extra['action'] ) || 'update' !== $hook_extra['action'] ) {
			return;
		}
		if ( empty( $hook_extra['type'] ) || 'plugin' !== $hook_extra['type'] ) {
			return;
		}

		// Multiple plugins may be in the same upgrade batch; check the list.
		if ( ! empty( $hook_extra['plugins'] ) && is_array( $hook_extra['plugins'] ) ) {
			if ( ! in_array( self::PLUGIN_FILE, $hook_extra['plugins'], true ) ) {
				return;
			}
		}

		delete_transient( self::CACHE_KEY );

		if ( class_exists( 'Livada_SEO_Activity_Log' ) ) {
			Livada_SEO_Activity_Log::log(
				'plugin_upgraded',
				sprintf( 'Auto-update completed to %s', defined( 'LIVADA_SEO_VERSION' ) ? LIVADA_SEO_VERSION : '?' )
			);
		}
	}

	// ── Manual "Check for updates" link on the row ────────────────────────────

	public static function plugin_row_meta( array $links, string $file ): array {
		if ( self::PLUGIN_FILE !== $file ) {
			return $links;
		}

		$url = wp_nonce_url(
			admin_url( 'plugins.php?livada_seo_check_update=1' ),
			'livada_seo_check_update'
		);
		$links[] = '<a href="' . esc_url( $url ) . '">' . esc_html__( 'Check for updates', 'livada-seo' ) . '</a>';
		return $links;
	}

	public static function maybe_force_check(): void {
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended
		$param = isset( $_GET['livada_seo_check_update'] ) ? sanitize_text_field( wp_unslash( $_GET['livada_seo_check_update'] ) ) : '';
		// Only act on the trigger ('1'); ignore the post-redirect marker ('done') and anything else.
		if ( '1' !== $param ) {
			return;
		}
		if ( ! current_user_can( 'update_plugins' ) ) {
			return;
		}
		check_admin_referer( 'livada_seo_check_update' );

		// Force-refresh remote manifest + reset WP's update transient so the
		// Extensions page picks up the latest state on the next render.
		delete_transient( self::CACHE_KEY );
		delete_transient( self::FAIL_CACHE_KEY );
		delete_option( self::LAST_ERROR_KEY );
		delete_site_transient( 'update_plugins' );
		self::get_remote_info( true );
		wp_update_plugins();

		wp_safe_redirect( admin_url( 'plugins.php?livada_seo_check_update=done' ) );
		exit;
	}
}
