<?php
if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * Updates from the Aura service, in the dashboard, like any other plugin.
 *
 * WHAT IT REPLACES. Until now a new version reached a customer as an email with a zip attached and
 * a paragraph of instructions: deactivate, delete, upload, reactivate. Nobody does that for a
 * point release, so installations sat on whatever version they were installed with — and the
 * server has to keep answering all of them, because the plugin declares its own engine's
 * capabilities on every request. An estate that cannot be upgraded is an estate you cannot change.
 *
 * HOW IT WORKS. WordPress asks about updates twice a day, and since 5.8 the `Update URI:` header
 * decides who gets asked: with a foreign host in there, core skips wordpress.org for this plugin
 * and hands the question to `update_plugins_{hostname}` instead. That filter is this class. We
 * answer with a version number and the URL of a zip; core does the comparison, the download, the
 * unpacking and the swap. None of that is ours to write, and none of it is ours to get wrong.
 *
 * AND ONE THING THAT DOES NOT WAIT TO BE ASKED. Twice a day is the whole delay: answering that
 * question faster does nothing, because the question is what is late, and a release can sit
 * unoffered for most of a day while looking — from the outside — like a release that never
 * happened. So {@see watch()} runs hourly and writes the offer into the transient the dashboard
 * reads, which is the same place core would have put it.
 *
 * WHAT IS DELIBERATELY NOT HERE. No licence key travels with the question: the zip is already
 * downloadable by anyone from `/download`, so demanding a key here would only mean a site whose
 * licence lapsed stops receiving SECURITY fixes while keeping the code that has the hole in it.
 * Payment gates generation, not maintenance.
 */
class AIPB_Update {

	/** The folder the plugin lives in, and its identity for `plugins_api`. Also the zip's root. */
	const SLUG = 'aura-client';

	/** What the service answered, kept so that a dashboard page load costs no HTTP call. */
	const CACHE = 'aipb_update_manifest';

	/**
	 * AN HOUR, and it used to be six. The number matters because it STACKS on core's own: core
	 * asks about updates every twelve hours in the background and at most once an hour while
	 * somebody is on the Plugins screen, and it asks US only to be told whatever our transient
	 * happened to be holding. Six hours on top of twelve meant a release could take most of a day
	 * to be offered, and the delay was invisible — it looked like the release had not happened.
	 *
	 * One request an hour per site costs the service nothing next to a single generation.
	 */
	const CACHE_TTL = HOUR_IN_SECONDS;

	// A service that is down is asked again soon, but not on every single page.
	const CACHE_FAIL_TTL = 30 * MINUTE_IN_SECONDS;

	/** The hourly look at what the service is serving. See {@see watch()}. */
	const WATCH_HOOK = 'aipb_update_watch';

	public static function init() {
		// The filter's name carries the host, so it can only be built at runtime. Both hosts are
		// registered because they are normally the same one — the build stamps the header and the
		// service address from a single `AURA_PUBLIC_URL` — and differ only on a development
		// install that was made from a zip and then pointed elsewhere with `AURA_SERVER_URL`.
		foreach ( self::hosts() as $host ) {
			add_filter( 'update_plugins_' . $host, array( __CLASS__, 'offer' ), 10, 2 );
		}

		// The "View version x.y details" link in the plugins list. Without this it opens a modal
		// that says the plugin does not exist: core asks wordpress.org, and we are not there.
		add_filter( 'plugins_api', array( __CLASS__, 'details' ), 10, 3 );

		// THE MANUAL CHECK HAS TO REACH US TOO. "Check again" throws away core's own record and
		// asks every plugin afresh — but our answer came out of a transient describing what the
		// service said up to an hour ago, so the check returned the same number it had before
		// and the screen did not move. The one gesture somebody makes when they KNOW there is a
		// new version was the one gesture that changed nothing.
		add_action( 'load-update-core.php', array( __CLASS__, 'on_force_check' ) );

		// The hourly look. Scheduling is idempotent and costs one option read, so it is done here
		// rather than on activation: an install that was updated rather than activated — which is
		// every install after the first — never runs an activation hook again.
		add_action( self::WATCH_HOOK, array( __CLASS__, 'watch' ) );

		if ( ! wp_next_scheduled( self::WATCH_HOOK ) ) {
			wp_schedule_event( time() + MINUTE_IN_SECONDS, 'hourly', self::WATCH_HOOK );
		}
	}

	/**
	 * What runs every hour: ask the service, and if there is something newer, SAY SO NOW.
	 *
	 * WHY THIS EXISTS AT ALL. WordPress is pull-based: a site is never told about a release, it
	 * finds out when it next asks, and left alone it asks every twelve hours. Answering that
	 * question faster does not help — the question itself is what is late. So this does not wait
	 * to be asked: it writes the offer straight into the `update_plugins` transient, which is the
	 * thing the dashboard actually reads. The badge appears on the next admin page load.
	 *
	 * WHAT IT IS NOT is a push. Nothing reaches a site that gets no traffic, because WP-Cron only
	 * runs when somebody visits — that is WordPress, not a shortcoming here. The honest promise is
	 * «within the hour of a site being used», against «within twelve hours» before.
	 */
	public static function watch() {
		self::flush(); // the point of asking is to find out what changed
		self::announce( self::manifest() );
	}

	/**
	 * Puts a release into the transient the dashboard reads, without waiting for core to ask.
	 *
	 * THREE CONDITIONS, and each one is there to stop a specific wrong outcome:
	 *
	 *  - `Update URI:` must be set, because that header is what makes core route the question to
	 *    us at all. Without it core's next check rebuilds the transient from wordpress.org and our
	 *    entry vanishes — an offer that appears and disappears is worse than one that is late. It
	 *    is empty in the source tree on purpose, so a development symlink stays quiet.
	 *  - There must be a package, and it must be newer. Core compares versions itself when it
	 *    asks; here nobody else is going to.
	 *  - The transient must already exist. Creating it would mean inventing a `last_checked`, and
	 *    core reads that to decide whether to check at all: a made-up one would suppress the very
	 *    check this is trying to anticipate.
	 *
	 * The entry is shaped exactly as `wp_update_plugins()` shapes it — see the `Update URI` branch
	 * there — so that whichever of the two wrote it, the dashboard finds the same thing.
	 *
	 * @param array|null $release
	 */
	protected static function announce( $release ) {
		if ( ! $release || '' === $release['download'] ) {
			return;
		}

		if ( ! version_compare( $release['version'], AIPB_VERSION, '>' ) ) {
			return;
		}

		$header = get_file_data( AIPB_PATH . 'ai-page-builder.php', array( 'uri' => 'Update URI' ) );
		$uri    = isset( $header['uri'] ) ? trim( (string) $header['uri'] ) : '';
		if ( '' === $uri ) {
			return;
		}

		$updates = get_site_transient( 'update_plugins' );
		if ( ! is_object( $updates ) || ! isset( $updates->last_checked ) ) {
			return;
		}

		$file = plugin_basename( AIPB_PATH . 'ai-page-builder.php' );

		$updates->response = is_array( $updates->response ?? null ) ? $updates->response : array();
		$updates->no_update = is_array( $updates->no_update ?? null ) ? $updates->no_update : array();

		// Only ever our own row. This transient is every plugin on the site.
		unset( $updates->no_update[ $file ] );

		$updates->response[ $file ] = (object) array(
			'id'           => $uri,
			'slug'         => self::SLUG,
			'plugin'       => $file,
			'version'      => $release['version'],
			'new_version'  => $release['version'],
			'url'          => $release['homepage'],
			'package'      => $release['download'],
			'requires'     => $release['requires'],
			'requires_php' => $release['requires_php'],
			'tested'       => $release['tested'],
		);

		// `last_checked` is deliberately left alone: it is core's record of when IT last asked,
		// and moving it would push core's own check further away.
		set_site_transient( 'update_plugins', $updates );
	}

	/**
	 * "Check again", pressed by a person.
	 *
	 * Core signals it with `force-check` in the query string on the updates screen. No nonce is
	 * read because none is needed: the whole effect is to drop a cache and ask the service one
	 * more time — the worst a forged link achieves is a single HTTP request the site was going to
	 * make anyway, within the hour.
	 */
	public static function on_force_check() {
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended
		if ( isset( $_GET['force-check'] ) ) {
			self::flush();
		}
	}

	/**
	 * Is there anything newer, and where is it.
	 *
	 * Core calls this once per update check for this plugin only. Returning `false` means "nothing
	 * to say" and is the answer for every failure: an unreachable service must look like no
	 * update, never like a broken plugin. The version comparison is core's — see
	 * `wp_update_plugins()` — so a service that answers with an OLDER number simply parks it in
	 * `no_update`, which is exactly right.
	 *
	 * @param array|false $update      What another filter already decided. First one wins.
	 * @param array       $plugin_data The headers of the installed plugin.
	 *
	 * @return array|false
	 */
	public static function offer( $update, $plugin_data ) {
		if ( false !== $update ) {
			return $update;
		}

		$release = self::manifest();
		if ( ! $release ) {
			return false;
		}

		// No package, no offer. Core would show the row and then fail on "Update now", which is
		// worse than staying quiet: the customer has already been told there is something to get.
		if ( '' === $release['download'] ) {
			return false;
		}

		return array(
			'slug'         => self::SLUG,
			'version'      => $release['version'],
			'url'          => $release['homepage'],
			'package'      => $release['download'],
			'requires'     => $release['requires'],
			'requires_php' => $release['requires_php'],
			'tested'       => $release['tested'],
		);
	}

	/**
	 * The details modal: what core would have fetched from wordpress.org.
	 *
	 * It answers ONLY for our slug and leaves everything else untouched — this filter is shared
	 * with every other plugin's install screen, and a callback that answers for all of them breaks
	 * the plugin directory for the whole site.
	 *
	 * @param false|object|array $result
	 * @param string             $action
	 * @param object             $args
	 *
	 * @return false|object|array
	 */
	public static function details( $result, $action, $args ) {
		if ( 'plugin_information' !== $action || ! isset( $args->slug ) || self::SLUG !== $args->slug ) {
			return $result;
		}

		$release = self::manifest();
		if ( ! $release ) {
			return $result;
		}

		$info = array(
			'name'          => $release['name'],
			'slug'          => self::SLUG,
			'version'       => $release['version'],
			'author'        => $release['author'],
			'homepage'      => $release['homepage'],
			'requires'      => $release['requires'],
			'requires_php'  => $release['requires_php'],
			'tested'        => $release['tested'],
			'last_updated'  => $release['released'],
			'download_link' => $release['download'],
			'sections'      => array( 'description' => $release['description'] ),
		);

		// The modal's changelog tab is only worth opening if there is something in it: an empty
		// tab reads as a release nobody bothered to describe.
		if ( '' !== $release['changelog'] ) {
			$info['sections']['changelog'] = $release['changelog'];
		}

		return (object) $info;
	}

	/**
	 * What the service says its current release is.
	 *
	 * The FAILURE is cached too, for less time, for the same reason `AIPB_Remote::health()` does
	 * it: this runs on update checks, and those happen on the plugins screen — a service that does
	 * not answer must not turn every visit to that page into a ten second wait.
	 *
	 * @return array|null Normalised, every key present, or null when there is nothing usable.
	 */
	public static function manifest() {
		// A cached failure is an empty array: it is the difference between "asked recently, no
		// answer" and "never asked", and without it a service that is down gets asked again on
		// every single admin page load.
		$cached = get_transient( self::CACHE );
		if ( is_array( $cached ) ) {
			return $cached ? $cached : null;
		}

		$base = AIPB_Remote::base_url();
		if ( '' === $base ) {
			return null;
		}

		$resp = wp_remote_get(
			add_query_arg( 'v', AIPB_VERSION, $base . '/v1/plugin/update' ),
			array( 'timeout' => 10 )
		);

		if ( is_wp_error( $resp ) || 200 !== wp_remote_retrieve_response_code( $resp ) ) {
			set_transient( self::CACHE, array(), self::CACHE_FAIL_TTL );

			return null;
		}

		$release = self::normalise( json_decode( wp_remote_retrieve_body( $resp ), true ) );
		if ( null === $release ) {
			set_transient( self::CACHE, array(), self::CACHE_FAIL_TTL );

			return null;
		}

		set_transient( self::CACHE, $release, self::CACHE_TTL );

		return $release;
	}

	/**
	 * Everything the answer says, checked before anybody acts on it.
	 *
	 * The version and the package are the two that matter: one decides whether a site is told to
	 * update, the other is a URL WordPress will download and unpack over a live installation. A
	 * malformed answer has to end here, not three layers further in.
	 *
	 * @param mixed $data
	 *
	 * @return array|null
	 */
	protected static function normalise( $data ) {
		if ( ! is_array( $data ) || empty( $data['version'] ) || ! is_string( $data['version'] ) ) {
			return null;
		}

		$version = trim( $data['version'] );
		// A version core cannot compare is a version core will guess about, and `version_compare`
		// guesses generously: anything unexpected sorts as older or newer depending on the string.
		if ( ! preg_match( '/^[0-9][0-9a-zA-Z.\-+]*$/', $version ) ) {
			return null;
		}

		$download = isset( $data['download'] ) ? (string) $data['download'] : '';
		$download = wp_http_validate_url( $download ) ? $download : '';

		// Only over TLS. This URL becomes code executed on the customer's site: on plain HTTP
		// anybody between us and them chooses what that code is. A service reachable over HTTP
		// only — development — simply offers no updates, which is the right answer there.
		if ( 'https' !== strtolower( (string) wp_parse_url( $download, PHP_URL_SCHEME ) ) ) {
			$download = '';
		}

		$text = function ( $value ) {
			return is_scalar( $value ) ? (string) $value : '';
		};

		return array(
			'version'      => $version,
			'download'     => $download,
			'name'         => $text( isset( $data['name'] ) ? $data['name'] : '' ),
			'author'       => $text( isset( $data['author'] ) ? $data['author'] : '' ),
			'homepage'     => $text( isset( $data['homepage'] ) ? $data['homepage'] : '' ),
			'requires'     => $text( isset( $data['requires'] ) ? $data['requires'] : '' ),
			'requires_php' => $text( isset( $data['requires_php'] ) ? $data['requires_php'] : '' ),
			'tested'       => $text( isset( $data['tested'] ) ? $data['tested'] : '' ),
			'released'     => $text( isset( $data['released'] ) ? $data['released'] : '' ),
			'description'  => wp_kses_post( $text( isset( $data['description'] ) ? $data['description'] : '' ) ),
			'changelog'    => wp_kses_post( $text( isset( $data['changelog'] ) ? $data['changelog'] : '' ) ),
		);
	}

	/**
	 * The hosts whose update question belongs to us.
	 *
	 * @return array<string>
	 */
	protected static function hosts() {
		$hosts = array();

		foreach ( array( AIPB_SERVER_URL, AIPB_Remote::base_url() ) as $url ) {
			$host = $url ? wp_parse_url( (string) $url, PHP_URL_HOST ) : '';
			if ( $host && ! in_array( $host, $hosts, true ) ) {
				$hosts[] = $host;
			}
		}

		return $hosts;
	}

	/**
	 * Forget what the service said.
	 *
	 * Called after an upgrade (see `aipb_maybe_upgrade`) and on "Check again" (see
	 * `on_force_check`, which is what makes that button mean anything here): what is
	 * cached describes a release that may well be the one now installed, and a stale copy of it is
	 * how a plugin offers to update itself to the version it is already running.
	 */
	public static function flush() {
		delete_transient( self::CACHE );
	}

	/** Deactivation: the hourly look has nothing left to look at. */
	public static function unschedule() {
		$next = wp_next_scheduled( self::WATCH_HOOK );
		if ( $next ) {
			wp_unschedule_event( $next, self::WATCH_HOOK );
		}
	}
}
