<?php
/**
 * Self-hosted update manager for Mersal plugins.
 *
 * Instead of shipping a separate "updater" plugin, this class wires Mersal's
 * own plugins into the *native* WordPress update system. Updates therefore
 * appear in the standard places:
 *
 *   - Dashboard → Updates
 *   - Plugins screen ("There is a new version…" + the native
 *     "Enable auto-updates" link)
 *
 * Update source (a plain folder on the Mersal site, one sub-folder per plugin):
 *
 *   https://mersal.it/Wordpress-Plugins/
 *       Mersal-Notifications/
 *           Mersal-Notifications-1.2.0.zip
 *           Mersal-Notifications-1.3.0.zip
 *           manifest.json        (optional — adds changelog / tested-up-to …)
 *       Another-Plugin/
 *           Another-Plugin-1.0.0.zip
 *
 * Drop a new ZIP into a folder and it becomes the latest version automatically
 * (the highest version number wins). Each sub-folder is treated as one plugin.
 *
 * To register a new Mersal plugin, add it to the array in
 * {@see Mersal_Notif_Updater::default_plugins()} or hook the
 * `mnotif_updater_plugins` filter from the plugin itself — a single line.
 *
 * @package Mersal_Notif
 */

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

class Mersal_Notif_Updater {

	/** @var Mersal_Notif_Updater|null Singleton. */
	private static $instance = null;

	/**
	 * Registered plugins, keyed by plugin basename (e.g. "mersal-notifications/mersal-notifications.php").
	 *
	 * Each value: array{ folder:string, name:string, slug:string, icon:string, homepage:string }.
	 *
	 * @var array<string,array>
	 */
	private $plugins = array();

	/** How long remote version lookups are cached (seconds). Kept short so new
	 * releases are detected quickly; the lookup itself is a tiny directory read. */
	const CACHE_TTL = HOUR_IN_SECONDS;

	/** Throttle for the automatic background refresh on admin pages. */
	const AUTO_REFRESH_EVERY = HOUR_IN_SECONDS;

	/** Option holding the global auto-update preference for Mersal plugins. */
	const OPT_AUTO = 'mnotif_updater_auto_update';

	/**
	 * Singleton accessor.
	 *
	 * @return Mersal_Notif_Updater
	 */
	public static function instance() {
		if ( null === self::$instance ) {
			self::$instance = new self();
		}
		return self::$instance;
	}

	/**
	 * Base URL of the plugins directory on the Mersal site (no trailing slash).
	 *
	 * @return string
	 */
	public static function base_url() {
		$base = defined( 'MNOTIF_UPDATE_BASE' ) ? MNOTIF_UPDATE_BASE : 'https://mersal.it/Wordpress-Plugins';
		/** Allow the host to point the updater somewhere else (staging, CDN…). */
		return untrailingslashit( (string) apply_filters( 'mnotif_updater_base_url', $base ) );
	}

	/**
	 * Wire all hooks. Call once from the plugin bootstrap.
	 */
	public function init() {
		$this->plugins = $this->default_plugins();

		// Let other Mersal plugins register themselves with one filter.
		$this->plugins = (array) apply_filters( 'mnotif_updater_plugins', $this->plugins, $this );

		// Carry each plugin's basename inside its own config for convenience.
		foreach ( $this->plugins as $basename => $cfg ) {
			$this->plugins[ $basename ]['basename'] = $basename;
		}

		// --- Native WordPress update integration ---
		add_filter( 'pre_set_site_transient_update_plugins', array( $this, 'inject_updates' ) );
		add_filter( 'plugins_api', array( $this, 'plugins_api' ), 10, 3 );
		add_filter( 'upgrader_source_selection', array( $this, 'fix_source_dir' ), 10, 4 );
		add_action( 'upgrader_process_complete', array( $this, 'flush_cache_after_update' ), 10, 2 );

		// Honor the global "auto-update" toggle for our plugins.
		add_filter( 'auto_update_plugin', array( $this, 'maybe_auto_update' ), 10, 2 );

		// Keep the native Updates/Plugins screens fresh automatically (throttled)
		// so a new release shows up without anyone clicking "Check for updates".
		add_action( 'admin_init', array( $this, 'maybe_auto_refresh' ) );

		// --- Admin "Updates" page AJAX (the in-dashboard management screen) ---
		add_action( 'wp_ajax_mnotif_updater_check', array( $this, 'ajax_check' ) );
		add_action( 'wp_ajax_mnotif_updater_install', array( $this, 'ajax_install' ) );
		add_action( 'wp_ajax_mnotif_updater_toggle_auto', array( $this, 'ajax_toggle_auto' ) );
	}

	/**
	 * The built-in plugin registry. Add new Mersal plugins here.
	 *
	 * @return array<string,array>
	 */
	private function default_plugins() {
		$icon = defined( 'MNOTIF_URL' ) ? MNOTIF_URL . 'assets/img/icon.svg' : '';

		// Basic identity (name, homepage…) is read from the plugin file header so
		// it never has to be duplicated here. Only the server folder + slug + icon
		// are updater-specific.
		$hdr = $this->read_file_header( MNOTIF_FILE );

		$plugins = array(
			plugin_basename( MNOTIF_FILE ) => array(
				'folder'   => 'Mersal-Notifications', // sub-folder name on the server.
				'slug'     => 'mersal-notifications',
				'icon'     => $icon,
				'name'     => ! empty( $hdr['name'] ) ? $hdr['name'] : 'Mersal Notifications',
				'homepage' => ! empty( $hdr['homepage'] ) ? $hdr['homepage'] : 'https://mersal.it',
			),
		);
		return $this->add_family_plugins( $plugins );
	}

	/**
	 * Make sure the whole Mersal family is always in the registry — installed or
	 * not — so the Updates screen can offer one-click "Install" for any missing
	 * plugin straight from the Mersal release folder.
	 *
	 * @param array<string,array> $plugins Registry so far.
	 * @return array<string,array>
	 */
	private function add_family_plugins( $plugins ) {
		$family = array(
			'mersal-otp/mersal-otp.php'                     => array( 'folder' => 'Mersal-OTP', 'slug' => 'mersal-otp', 'name' => 'Mersal OTP' ),
			'mersal-notifications/mersal-notifications.php' => array( 'folder' => 'Mersal-Notifications', 'slug' => 'mersal-notifications', 'name' => 'Mersal Notifications' ),
			'mersal-campaign/mersal-campaign.php'           => array( 'folder' => 'Mersal-Campaign', 'slug' => 'mersal-campaign', 'name' => 'Mersal Campaign' ),
			'mersal-assistant/mersal-assistant.php'         => array( 'folder' => 'Mersal-Assistant', 'slug' => 'mersal-assistant', 'name' => 'Mersal Assistant' ),
		);
		foreach ( $family as $basename => $cfg ) {
			if ( isset( $plugins[ $basename ] ) ) {
				continue;
			}
			$cfg['icon']     = '';
			$cfg['homepage'] = 'https://mersal.it';
			// Prefer the installed copy's own header identity when present.
			$file = WP_PLUGIN_DIR . '/' . $basename;
			if ( is_readable( $file ) ) {
				$hdr2 = $this->read_file_header( $file );
				if ( ! empty( $hdr2['name'] ) ) {
					$cfg['name'] = $hdr2['name'];
				}
				if ( ! empty( $hdr2['homepage'] ) ) {
					$cfg['homepage'] = $hdr2['homepage'];
				}
			}
			$plugins[ $basename ] = $cfg;
		}
		return $plugins;
	}

	/**
	 * Read a plugin file's header block from disk (only the first 8 KB).
	 *
	 * @param string $file Absolute path to the plugin's main PHP file.
	 * @return array Parsed header fields (see {@see parse_plugin_header()}).
	 */
	private function read_file_header( $file ) {
		if ( ! $file || ! is_readable( $file ) ) {
			return array();
		}
		$content = (string) file_get_contents( $file, false, null, 0, 8192 ); // phpcs:ignore WordPress.WP.AlternativeFunctions
		return $this->parse_plugin_header( $content );
	}

	/**
	 * Registered plugins.
	 *
	 * @return array<string,array>
	 */
	public function get_plugins() {
		return $this->plugins;
	}

	/* ====================================================================== */
	/* Native WordPress update integration                                    */
	/* ====================================================================== */

	/**
	 * Inject available updates into the plugins update transient so WordPress
	 * shows them natively (Updates screen + Plugins screen).
	 *
	 * @param object $transient The `update_plugins` site transient.
	 * @return object
	 */
	public function inject_updates( $transient ) {
		if ( ! is_object( $transient ) ) {
			return $transient;
		}
		if ( ! isset( $transient->response ) ) {
			$transient->response = array();
		}
		if ( ! isset( $transient->no_update ) ) {
			$transient->no_update = array();
		}

		foreach ( $this->plugins as $basename => $cfg ) {
			$installed = $this->installed_version( $basename );
			if ( null === $installed ) {
				continue; // Plugin not present on this site — nothing to update.
			}

			$remote = $this->get_remote( $cfg['folder'] );
			if ( is_wp_error( $remote ) || empty( $remote['version'] ) ) {
				continue;
			}

			// Cheap: only use already-cached release meta here so the background
			// update check never has to download a ZIP.
			if ( ! empty( $remote['download_url'] ) ) {
				$remote = array_merge( $remote, $this->get_release_meta( $cfg['folder'], $remote['version'], $remote['download_url'], false ) );
			}

			$item = (object) array(
				'id'            => $basename,
				'slug'          => $cfg['slug'],
				'plugin'        => $basename,
				'new_version'   => $remote['version'],
				'url'           => $cfg['homepage'],
				'package'       => $remote['download_url'],
				'icons'         => $cfg['icon'] ? array( 'default' => $cfg['icon'] ) : array(),
				'tested'        => isset( $remote['tested'] ) ? $remote['tested'] : '',
				'requires'      => isset( $remote['requires'] ) ? $remote['requires'] : '',
				'requires_php'  => isset( $remote['requires_php'] ) ? $remote['requires_php'] : '',
			);

			if ( version_compare( $installed, $remote['version'], '<' ) ) {
				$transient->response[ $basename ] = $item;
				unset( $transient->no_update[ $basename ] );
			} else {
				// Tell WP it's current (enables the native auto-update UI row).
				$transient->no_update[ $basename ] = $item;
				unset( $transient->response[ $basename ] );
			}
		}

		return $transient;
	}

	/**
	 * Provide the "View details" / plugin-information popup for our plugins.
	 *
	 * @param false|object|array $result Default result.
	 * @param string             $action API action.
	 * @param object             $args   Request args.
	 * @return false|object
	 */
	public function plugins_api( $result, $action, $args ) {
		if ( 'plugin_information' !== $action || empty( $args->slug ) ) {
			return $result;
		}

		$cfg = $this->config_by_slug( $args->slug );
		if ( ! $cfg ) {
			return $result;
		}

		$remote = $this->get_remote( $cfg['folder'] );
		if ( is_wp_error( $remote ) ) {
			return $result;
		}

		// Basic identity (name, author, homepage, description) comes from the
		// plugin file header; tested / requires / changelog from readme.txt —
		// both read straight out of the ZIP and cached per version.
		if ( ! empty( $remote['download_url'] ) ) {
			$remote = array_merge( $remote, $this->get_release_meta( $cfg['folder'], $remote['version'], $remote['download_url'], true ) );
		}

		// Fallback: if the ZIP couldn't be read (e.g. server blocked the download),
		// fill description/changelog from the installed copy on disk so the popup
		// is never blank.
		$local = $this->get_local_meta( isset( $cfg['basename'] ) ? $cfg['basename'] : '' );
		foreach ( array( 'description', 'changelog', 'requires', 'tested', 'requires_php', 'author', 'author_url', 'homepage', 'name' ) as $k ) {
			if ( empty( $remote[ $k ] ) && ! empty( $local[ $k ] ) ) {
				$remote[ $k ] = $local[ $k ];
			}
		}

		// Build the author HTML from the header's Author + Author URI.
		$author = isset( $remote['author'] ) ? $remote['author'] : 'Mersal';
		if ( ! empty( $remote['author_url'] ) ) {
			$author = '<a href="' . esc_url( $remote['author_url'] ) . '">' . esc_html( $author ) . '</a>';
		} else {
			$author = esc_html( $author );
		}

		$name     = ! empty( $remote['name'] ) ? $remote['name'] : $cfg['name'];
		$homepage = ! empty( $remote['homepage'] ) ? $remote['homepage'] : $cfg['homepage'];
		$desc     = ! empty( $remote['description'] ) ? $remote['description'] : $name;

		$info = (object) array(
			'name'          => $name,
			'slug'          => $cfg['slug'],
			'version'       => $remote['version'],
			'author'        => $author,
			'homepage'      => $homepage,
			'requires'      => isset( $remote['requires'] ) ? $remote['requires'] : '',
			'tested'        => isset( $remote['tested'] ) ? $remote['tested'] : '',
			'requires_php'  => isset( $remote['requires_php'] ) ? $remote['requires_php'] : '',
			'last_updated'  => isset( $remote['last_updated'] ) ? $remote['last_updated'] : '',
			'download_link' => $remote['download_url'],
			'trunk'         => $remote['download_url'],
			'sections'      => array(
				'description' => wp_kses_post( wpautop( $desc ) ),
				'changelog'   => isset( $remote['changelog'] ) ? wp_kses_post( $this->format_changelog( $remote['changelog'] ) ) : '',
			),
		);

		if ( $cfg['icon'] ) {
			$info->icons = array( 'default' => $cfg['icon'] );
		}

		return $info;
	}

	/**
	 * Rename the extracted folder to the plugin's real directory so updates land
	 * on top of the existing install (our ZIPs are named "Name-1.2.3.zip", whose
	 * top folder would otherwise carry the version number).
	 *
	 * @param string      $source        Extracted source directory.
	 * @param string      $remote_source Parent temp directory.
	 * @param WP_Upgrader $upgrader      Upgrader instance.
	 * @param array       $hook_extra    Extra info (contains the plugin basename).
	 * @return string|WP_Error
	 */
	public function fix_source_dir( $source, $remote_source, $upgrader, $hook_extra = array() ) {
		global $wp_filesystem;

		if ( empty( $hook_extra['plugin'] ) || ! isset( $this->plugins[ $hook_extra['plugin'] ] ) ) {
			return $source;
		}

		$desired = dirname( $hook_extra['plugin'] ); // e.g. "mersal-notifications".
		if ( '' === $desired || '.' === $desired ) {
			return $source;
		}

		$corrected = trailingslashit( $remote_source ) . $desired . '/';
		if ( trailingslashit( $source ) === $corrected ) {
			return $source;
		}

		if ( $wp_filesystem && $wp_filesystem->move( untrailingslashit( $source ), untrailingslashit( $corrected ), true ) ) {
			return $corrected;
		}

		return $source;
	}

	/**
	 * Clear cached version lookups after any plugin update completes.
	 *
	 * @param WP_Upgrader $upgrader Upgrader instance.
	 * @param array       $data     Update context.
	 */
	public function flush_cache_after_update( $upgrader, $data ) {
		if ( isset( $data['type'] ) && 'plugin' === $data['type'] ) {
			$this->flush_cache();
		}
	}

	/**
	 * Apply the global auto-update preference to our plugins.
	 *
	 * @param bool|null $update Whether to update.
	 * @param object    $item   The update offer.
	 * @return bool|null
	 */
	public function maybe_auto_update( $update, $item ) {
		$plugin = isset( $item->plugin ) ? $item->plugin : '';
		if ( $plugin && isset( $this->plugins[ $plugin ] ) && '1' === get_option( self::OPT_AUTO, '0' ) ) {
			return true;
		}
		return $update;
	}

	/* ====================================================================== */
	/* Remote discovery                                                       */
	/* ====================================================================== */

	/**
	 * Resolve the latest remote version + metadata for a plugin folder.
	 *
	 * Strategy:
	 *   1. Prefer a `manifest.json` in the folder (richest metadata).
	 *   2. Otherwise parse the directory listing for "Folder-<version>.zip".
	 *
	 * @param string $folder      Server sub-folder name.
	 * @param bool   $force        Skip the cache.
	 * @return array|WP_Error      array{ version, download_url, ... } on success.
	 */
	public function get_remote( $folder, $force = false ) {
		$key = 'mnotif_upd_' . md5( $folder );

		if ( ! $force ) {
			$cached = get_transient( $key );
			if ( false !== $cached ) {
				return $cached;
			}
		}

		$base_folder = self::base_url() . '/' . rawurlencode( $folder );
		$result      = $this->fetch_manifest( $base_folder );

		if ( is_wp_error( $result ) || empty( $result['version'] ) ) {
			$listing = $this->fetch_from_listing( $base_folder, $folder );
			if ( is_wp_error( $listing ) ) {
				// Keep a short negative cache so we don't hammer the server.
				set_transient( $key, $listing, 15 * MINUTE_IN_SECONDS );
				return $listing;
			}
			// Merge any manifest metadata we did get with the listing version.
			$result = is_array( $result ) ? array_merge( $result, $listing ) : $listing;
		}

		set_transient( $key, $result, self::CACHE_TTL );
		return $result;
	}

	/**
	 * Try to read manifest.json from the plugin folder.
	 *
	 * @param string $base_folder Folder URL (no trailing slash).
	 * @return array|WP_Error
	 */
	private function fetch_manifest( $base_folder ) {
		$response = wp_remote_get(
			$base_folder . '/manifest.json',
			array(
				'timeout' => 12,
				'headers' => array( 'Accept' => 'application/json' ),
			)
		);

		if ( is_wp_error( $response ) ) {
			return $response;
		}
		if ( 200 !== (int) wp_remote_retrieve_response_code( $response ) ) {
			return new WP_Error( 'no_manifest', 'manifest.json not found' );
		}

		$data = json_decode( wp_remote_retrieve_body( $response ), true );
		if ( ! is_array( $data ) ) {
			return new WP_Error( 'bad_manifest', 'Invalid manifest.json' );
		}

		$out = array();
		if ( ! empty( $data['version'] ) ) {
			$out['version'] = sanitize_text_field( $data['version'] );
			$out['download_url'] = ! empty( $data['download_url'] )
				? esc_url_raw( $data['download_url'] )
				: $base_folder . '/' . rawurlencode( $this->guess_zip_name( $base_folder, $out['version'] ) );
		}
		foreach ( array( 'tested', 'requires', 'requires_php', 'last_updated', 'changelog', 'description' ) as $k ) {
			if ( isset( $data[ $k ] ) ) {
				$out[ $k ] = is_string( $data[ $k ] ) ? wp_kses_post( $data[ $k ] ) : $data[ $k ];
			}
		}
		return $out;
	}

	/**
	 * Parse an autoindex directory listing for "Folder-<version>.zip" links and
	 * pick the highest version.
	 *
	 * @param string $base_folder Folder URL (no trailing slash).
	 * @param string $folder      Folder name (used as the file prefix).
	 * @return array|WP_Error
	 */
	private function fetch_from_listing( $base_folder, $folder ) {
		$response = wp_remote_get( $base_folder . '/', array( 'timeout' => 12 ) );

		if ( is_wp_error( $response ) ) {
			return $response;
		}
		if ( 200 !== (int) wp_remote_retrieve_response_code( $response ) ) {
			return new WP_Error(
				'no_listing',
				/* translators: %s: plugin folder name */
				sprintf( __( 'Could not read the version list for "%s". Enable directory listing or add a manifest.json.', 'mersal-notifications' ), $folder )
			);
		}

		$body = wp_remote_retrieve_body( $response );

		// Match e.g. Mersal-Notifications-1.2.0.zip — prefix is the folder name, version is x.y[.z…].
		$prefix = preg_quote( $folder, '/' );
		if ( ! preg_match_all( '/(' . $prefix . '-([0-9]+(?:\.[0-9]+)+))\.zip/i', $body, $matches, PREG_SET_ORDER ) ) {
			return new WP_Error(
				'no_versions',
				/* translators: %s: plugin folder name */
				sprintf( __( 'No version ZIP files found in "%s".', 'mersal-notifications' ), $folder )
			);
		}

		$best    = '0.0.0';
		$best_fn = '';
		foreach ( $matches as $m ) {
			if ( version_compare( $m[2], $best, '>' ) ) {
				$best    = $m[2];
				$best_fn = $m[0]; // e.g. "Mersal-Notifications-1.3.0.zip".
			}
		}

		return array(
			'version'      => $best,
			'download_url' => $base_folder . '/' . rawurlencode( $best_fn ),
		);
	}

	/**
	 * Build the conventional ZIP filename for a folder + version.
	 *
	 * @param string $base_folder Folder URL (no trailing slash).
	 * @param string $version     Version string.
	 * @return string
	 */
	private function guess_zip_name( $base_folder, $version ) {
		$folder = basename( rawurldecode( $base_folder ) );
		return $folder . '-' . $version . '.zip';
	}

	/* ====================================================================== */
	/* Release metadata (read straight from the uploaded ZIP)                 */
	/* ====================================================================== */

	/**
	 * Metadata for a release, read once from the ZIP and cached per version:
	 *   - basic identity (name, homepage, description, author, license) from the
	 *     plugin file *header* inside the ZIP;
	 *   - tested / requires / requires_php / changelog from its *readme.txt*.
	 *
	 * A published ZIP never changes, so the download happens at most once per
	 * release.
	 *
	 * @param string $folder       Server sub-folder name.
	 * @param string $version      Version being described.
	 * @param string $download_url ZIP URL.
	 * @param bool   $allow_fetch  When false, only return already-cached data
	 *                             (used on the background update check so it
	 *                             never downloads a ZIP).
	 * @return array
	 */
	public function get_release_meta( $folder, $version, $download_url, $allow_fetch = true ) {
		$key    = 'mnotif_rel_' . md5( $folder . '|' . $version );
		$cached = get_transient( $key );
		if ( is_array( $cached ) ) {
			return $cached;
		}
		if ( ! $allow_fetch ) {
			return array();
		}

		$parts = $this->extract_release_from_zip( $download_url );
		$meta  = array();

		// readme.txt → tested / requires / requires_php / changelog.
		if ( '' !== $parts['readme'] ) {
			$meta = $this->parse_readme( $parts['readme'], $version );
		}

		// Plugin file header → basic identity (these win over readme).
		if ( '' !== $parts['header'] ) {
			$hdr = $this->parse_plugin_header( $parts['header'] );
			foreach ( array( 'name', 'homepage', 'description', 'author', 'author_url', 'license' ) as $k ) {
				if ( ! empty( $hdr[ $k ] ) ) {
					$meta[ $k ] = $hdr[ $k ];
				}
			}
		}

		// Only cache a *successful* read (so a transient download failure during
		// setup doesn't poison the cache for days). Keyed by version, so a new
		// release re-fetches automatically.
		if ( ! empty( $meta ) ) {
			set_transient( $key, $meta, 30 * DAY_IN_SECONDS );
		}
		return $meta;
	}

	/**
	 * Download the release ZIP once and pull out both its readme.txt and the
	 * main plugin file's header block (ZipArchive, falling back to unzip_file).
	 *
	 * @param string $download_url ZIP URL.
	 * @return array{readme:string,header:string}
	 */
	private function extract_release_from_zip( $download_url ) {
		require_once ABSPATH . 'wp-admin/includes/file.php';

		$out = array( 'readme' => '', 'header' => '' );

		$tmp = download_url( $download_url, 60 );
		if ( is_wp_error( $tmp ) ) {
			return $out;
		}

		if ( class_exists( 'ZipArchive' ) ) {
			$zip = new ZipArchive();
			if ( true === $zip->open( $tmp ) ) {
				for ( $i = 0; $i < $zip->numFiles; $i++ ) {
					$name = $zip->getNameIndex( $i );
					if ( ! $name ) {
						continue;
					}
					if ( '' === $out['readme'] && preg_match( '#(^|/)readme\.txt$#i', $name ) ) {
						$out['readme'] = (string) $zip->getFromIndex( $i );
					} elseif ( '' === $out['header'] && preg_match( '#^[^/]+/[^/]+\.php$#i', $name ) ) {
						// A top-level PHP file — check if it carries the plugin header.
						$chunk = (string) $zip->getFromIndex( $i, 8192 );
						if ( false !== stripos( $chunk, 'Plugin Name:' ) ) {
							$out['header'] = $chunk;
						}
					}
					if ( '' !== $out['readme'] && '' !== $out['header'] ) {
						break;
					}
				}
				$zip->close();
			}
		}

		if ( '' === $out['readme'] || '' === $out['header'] ) {
			// Fallback for hosts without the Zip extension.
			WP_Filesystem();
			global $wp_filesystem;
			$dir   = trailingslashit( get_temp_dir() ) . 'mnotif-rel-' . time() . '/';
			$unzip = unzip_file( $tmp, $dir );
			if ( ! is_wp_error( $unzip ) ) {
				if ( '' === $out['readme'] ) {
					$found = glob( $dir . '*/readme.txt' );
					if ( empty( $found ) ) {
						$found = glob( $dir . 'readme.txt' );
					}
					if ( ! empty( $found ) ) {
						$out['readme'] = (string) file_get_contents( $found[0] ); // phpcs:ignore WordPress.WP.AlternativeFunctions
					}
				}
				if ( '' === $out['header'] ) {
					foreach ( (array) glob( $dir . '*/*.php' ) as $php ) {
						$chunk = (string) file_get_contents( $php, false, null, 0, 8192 ); // phpcs:ignore WordPress.WP.AlternativeFunctions
						if ( false !== stripos( $chunk, 'Plugin Name:' ) ) {
							$out['header'] = $chunk;
							break;
						}
					}
				}
				if ( $wp_filesystem ) {
					$wp_filesystem->delete( $dir, true );
				}
			}
		}

		wp_delete_file( $tmp );
		return $out;
	}

	/**
	 * Parse a plugin file header block ("Plugin Name:", "Version:", …) the same
	 * way WordPress' get_file_data() does.
	 *
	 * @param string $content First bytes of a plugin PHP file.
	 * @return array
	 */
	private function parse_plugin_header( $content ) {
		$content = str_replace( "\r", "\n", substr( (string) $content, 0, 8192 ) );
		$labels  = array(
			'name'        => 'Plugin Name',
			'homepage'    => 'Plugin URI',
			'description' => 'Description',
			'version'     => 'Version',
			'author'      => 'Author',
			'author_url'  => 'Author URI',
			'license'     => 'License',
		);
		$out = array();
		foreach ( $labels as $key => $label ) {
			if ( preg_match( '/^[ \t\/*#@]*' . preg_quote( $label, '/' ) . ':(.*)$/mi', $content, $m ) ) {
				$val = trim( preg_replace( '/\s*(?:\*\/|\?>).*/s', '', $m[1] ) );
				if ( '' !== $val ) {
					$out[ $key ] = $val;
				}
			}
		}
		return $out;
	}

	/**
	 * Parse the readme.txt fields that are NOT in the plugin header.
	 *
	 * @param string $text    readme.txt contents.
	 * @param string $version Version whose changelog block to prefer.
	 * @return array
	 */
	private function parse_readme( $text, $version ) {
		$text = str_replace( array( "\r\n", "\r" ), "\n", (string) $text );
		$meta = array();

		// --- Header fields ("Key: value" lines near the top). ---
		$headers = array(
			'requires'     => 'Requires at least',
			'tested'       => 'Tested up to',
			'requires_php' => 'Requires PHP',
		);
		foreach ( $headers as $field => $label ) {
			if ( preg_match( '/^\s*' . preg_quote( $label, '/' ) . '\s*:\s*(.+)$/mi', $text, $m ) ) {
				$meta[ $field ] = sanitize_text_field( trim( $m[1] ) );
			}
		}

		// --- Changelog: prefer this exact version's block; otherwise the newest. ---
		$changelog = $this->readme_section( $text, 'Changelog' );
		if ( '' !== $changelog ) {
			if ( preg_match( '/^=\s*v?' . preg_quote( $version, '/' ) . '\s*=\s*\n(.*?)(?=\n=\s|\z)/mis', $changelog, $m ) ) {
				$changelog = trim( $m[1] );
			} elseif ( preg_match( '/^=\s*[^=\n]+=\s*\n(.*?)(?=\n=\s|\z)/mis', $changelog, $m ) ) {
				// No block matched the version number — show the most recent block.
				$changelog = trim( $m[1] );
			}
			$meta['changelog'] = $changelog;
		}

		return $meta;
	}

	/**
	 * Extract a "== Name ==" section body from readme text.
	 *
	 * @param string $text readme.txt contents (LF-normalised).
	 * @param string $name Section name.
	 * @return string
	 */
	private function readme_section( $text, $name ) {
		if ( preg_match( '/^==\s*' . preg_quote( $name, '/' ) . '\s*==\s*\n(.*?)(?=\n==\s|\z)/mis', $text, $m ) ) {
			return trim( $m[1] );
		}
		return '';
	}

	/* ====================================================================== */
	/* Helpers                                                                */
	/* ====================================================================== */

	/**
	 * Installed version of a registered plugin, or null when not installed.
	 *
	 * @param string $basename Plugin basename.
	 * @return string|null
	 */
	public function installed_version( $basename ) {
		if ( ! function_exists( 'get_plugins' ) ) {
			require_once ABSPATH . 'wp-admin/includes/plugin.php';
		}
		$all = get_plugins();
		return isset( $all[ $basename ] ) ? $all[ $basename ]['Version'] : null;
	}

	/**
	 * Find a registry entry by its WP slug.
	 *
	 * @param string $slug Plugin slug.
	 * @return array|null
	 */
	private function config_by_slug( $slug ) {
		foreach ( $this->plugins as $cfg ) {
			if ( $cfg['slug'] === $slug ) {
				return $cfg;
			}
		}
		return null;
	}

	/**
	 * Metadata read from the *installed* copy on disk (header + local readme.txt).
	 * Used as a fallback so the details popup is never blank when the remote ZIP
	 * can't be downloaded.
	 *
	 * @param string $basename Plugin basename.
	 * @return array
	 */
	private function get_local_meta( $basename ) {
		if ( ! $basename ) {
			return array();
		}
		$self = plugin_basename( MNOTIF_FILE );
		$file = ( $basename === $self ) ? MNOTIF_FILE : WP_PLUGIN_DIR . '/' . $basename;
		$dir  = trailingslashit( dirname( $file ) );

		$meta   = $this->read_file_header( $file ); // name/version/description/author…
		$readme = $dir . 'readme.txt';
		if ( is_readable( $readme ) ) {
			$version = ! empty( $meta['version'] ) ? $meta['version'] : (string) $this->installed_version( $basename );
			// Header fields win over readme on shared keys.
			$meta = array_merge( $this->parse_readme( (string) file_get_contents( $readme ), $version ), $meta ); // phpcs:ignore WordPress.WP.AlternativeFunctions
		}
		return $meta;
	}

	/**
	 * Convert a plain-text changelog into simple HTML bullet points.
	 *
	 * @param string $text Raw changelog.
	 * @return string
	 */
	private function format_changelog( $text ) {
		$lines = preg_split( '/\r\n|\r|\n/', (string) $text );
		$items = '';
		foreach ( $lines as $line ) {
			$line = trim( preg_replace( '/^[\*\-\x{2022}]\s*/u', '', trim( $line ) ) );
			if ( '' !== $line ) {
				$items .= '<li>' . esc_html( $line ) . '</li>';
			}
		}
		return $items ? '<ul>' . $items . '</ul>' : '';
	}

	/**
	 * Clear all cached remote lookups (version + release metadata) and the WP
	 * update transient, so the next check is fully fresh.
	 */
	public function flush_cache() {
		foreach ( $this->plugins as $cfg ) {
			// Drop the release-metadata cache for the currently-known version too.
			$remote = get_transient( 'mnotif_upd_' . md5( $cfg['folder'] ) );
			if ( is_array( $remote ) && ! empty( $remote['version'] ) ) {
				delete_transient( 'mnotif_rel_' . md5( $cfg['folder'] . '|' . $remote['version'] ) );
			}
			delete_transient( 'mnotif_upd_' . md5( $cfg['folder'] ) );
		}
		delete_site_transient( 'update_plugins' );
	}

	/**
	 * Throttled automatic refresh: on admin pages, at most once per hour, force
	 * WordPress to re-check plugin updates so a freshly uploaded release appears
	 * on the native Updates/Plugins screens without any manual "Check" click.
	 */
	public function maybe_auto_refresh() {
		if ( ! current_user_can( 'update_plugins' ) ) {
			return;
		}
		if ( false !== get_transient( 'mnotif_upd_autocheck' ) ) {
			return; // Checked recently.
		}
		set_transient( 'mnotif_upd_autocheck', 1, self::AUTO_REFRESH_EVERY );

		// Refresh our cheap version lookups, then let WP rebuild its transient.
		foreach ( $this->plugins as $cfg ) {
			delete_transient( 'mnotif_upd_' . md5( $cfg['folder'] ) );
		}
		delete_site_transient( 'update_plugins' );
		if ( function_exists( 'wp_update_plugins' ) ) {
			wp_update_plugins();
		}
	}

	/* ====================================================================== */
	/* Admin AJAX (the in-dashboard "Updates" page)                           */
	/* ====================================================================== */

	/**
	 * Build the snapshot used by the admin Updates page.
	 *
	 * @param bool $force Force a fresh remote lookup.
	 * @return array<int,array>
	 */
	public function status_snapshot( $force = false ) {
		$rows = array();
		foreach ( $this->plugins as $basename => $cfg ) {
			$installed = $this->installed_version( $basename );
			$remote    = $this->get_remote( $cfg['folder'], $force );

			$remote_ver = is_wp_error( $remote ) ? null : $remote['version'];
			$error      = is_wp_error( $remote ) ? $remote->get_error_message() : '';

			if ( null === $installed ) {
				$status = $remote_ver ? 'not_installed' : 'error';
			} elseif ( ! $remote_ver ) {
				$status = 'error';
			} elseif ( version_compare( $installed, $remote_ver, '<' ) ) {
				$status = 'outdated';
			} else {
				$status = 'latest';
			}

			$rows[] = array(
				'basename'  => $basename,
				'name'      => $cfg['name'],
				'icon'      => $cfg['icon'],
				'installed' => $installed,
				'remote'    => $remote_ver,
				'download'  => is_wp_error( $remote ) ? '' : $remote['download_url'],
				'status'    => $status,
				'error'     => $error,
			);
		}
		return $rows;
	}

	/**
	 * AJAX: refresh all plugin statuses.
	 */
	public function ajax_check() {
		$this->verify_ajax();
		$this->flush_cache();
		wp_send_json_success( array( 'plugins' => $this->status_snapshot( true ) ) );
	}

	/**
	 * AJAX: toggle the global auto-update preference.
	 */
	public function ajax_toggle_auto() {
		$this->verify_ajax();
		$enabled = ! empty( $_POST['enabled'] ) && '1' === sanitize_text_field( wp_unslash( $_POST['enabled'] ) ) ? '1' : '0';
		update_option( self::OPT_AUTO, $enabled );
		wp_send_json_success( array( 'enabled' => $enabled ) );
	}

	/**
	 * AJAX: install (or update) a registered plugin to its latest version.
	 */
	public function ajax_install() {
		$this->verify_ajax();

		$basename = isset( $_POST['basename'] ) ? sanitize_text_field( wp_unslash( $_POST['basename'] ) ) : '';
		if ( ! isset( $this->plugins[ $basename ] ) ) {
			wp_send_json_error( array( 'message' => __( 'Unknown plugin.', 'mersal-notifications' ) ) );
		}
		$cfg    = $this->plugins[ $basename ];
		$remote = $this->get_remote( $cfg['folder'], true );
		if ( is_wp_error( $remote ) || empty( $remote['download_url'] ) ) {
			wp_send_json_error( array( 'message' => is_wp_error( $remote ) ? $remote->get_error_message() : __( 'No download URL.', 'mersal-notifications' ) ) );
		}

		// Only allow HTTPS downloads from the configured Mersal host.
		$allowed = wp_parse_url( self::base_url(), PHP_URL_HOST );
		$host    = wp_parse_url( $remote['download_url'], PHP_URL_HOST );
		$scheme  = wp_parse_url( $remote['download_url'], PHP_URL_SCHEME );
		if ( ! $allowed || $host !== $allowed || 'https' !== strtolower( (string) $scheme ) ) {
			wp_send_json_error( array( 'message' => __( 'Download URL is not from the trusted Mersal host.', 'mersal-notifications' ) ) );
		}

		require_once ABSPATH . 'wp-admin/includes/file.php';
		require_once ABSPATH . 'wp-admin/includes/misc.php';
		require_once ABSPATH . 'wp-admin/includes/plugin.php';
		require_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php';
		require_once ABSPATH . 'wp-admin/includes/class-wp-ajax-upgrader-skin.php';

		$skin     = new WP_Ajax_Upgrader_Skin();
		$upgrader = new Plugin_Upgrader( $skin );

		$installed = $this->installed_version( $basename );
		if ( null === $installed ) {
			$result = $upgrader->install( $remote['download_url'] );
		} else {
			// Prime the update transient with the freshly-resolved package so the
			// upgrader can never reuse a stale (possibly 404) URL from an earlier
			// throttled check.
			$transient = get_site_transient( 'update_plugins' );
			if ( ! is_object( $transient ) ) {
				$transient = new stdClass();
			}
			if ( ! isset( $transient->response ) || ! is_array( $transient->response ) ) {
				$transient->response = array();
			}
			$transient->response[ $basename ] = (object) array(
				'id'          => $basename,
				'slug'        => $cfg['slug'],
				'plugin'      => $basename,
				'new_version' => $remote['version'],
				'package'     => $remote['download_url'],
				'url'         => isset( $cfg['homepage'] ) ? $cfg['homepage'] : '',
			);
			set_site_transient( 'update_plugins', $transient );

			$result = $upgrader->upgrade( $basename );
		}

		if ( is_wp_error( $result ) ) {
			wp_send_json_error( array( 'message' => $result->get_error_message() ) );
		}
		if ( false === $result ) {
			$errors = $skin->get_errors();
			$msg    = is_wp_error( $errors ) && $errors->has_errors() ? $errors->get_error_message() : __( 'Installation failed.', 'mersal-notifications' );
			wp_send_json_error( array( 'message' => $msg ) );
		}

		// Make sure the plugin is active after a fresh install.
		if ( null === $installed && ! is_plugin_active( $basename ) ) {
			activate_plugin( $basename );
		}

		$this->flush_cache();
		wp_send_json_success( array( 'message' => __( 'Done. Reloading…', 'mersal-notifications' ), 'version' => $remote['version'] ) );
	}

	/**
	 * Shared AJAX guard (nonce + capability).
	 */
	private function verify_ajax() {
		check_ajax_referer( 'mnotif_updater', 'nonce' );
		if ( ! current_user_can( 'update_plugins' ) ) {
			wp_send_json_error( array( 'message' => __( 'Unauthorized', 'mersal-notifications' ) ), 403 );
		}
	}

	/**
	 * Whether the global auto-update toggle is on.
	 *
	 * @return bool
	 */
	public function auto_update_enabled() {
		return '1' === get_option( self::OPT_AUTO, '0' );
	}

	/* ====================================================================== */
	/* Admin "Updates" page (rendered inside the Mersal admin shell)          */
	/* ====================================================================== */

	/**
	 * Render the in-dashboard Updates screen using the plugin's design system.
	 *
	 * @param string $lang Active language ('ar'|'en').
	 */
	public function render_admin_page( $lang = 'ar' ) {
		$en = ( 'en' === $lang );
		// Force a fresh (but cheap) version lookup every time the page opens, so
		// the latest release shows up without clicking "Check for updates".
		$rows     = $this->status_snapshot( true );
		$auto_on  = $this->auto_update_enabled();
		$base     = self::base_url();

		echo '<div class="page-head"><div class="titles"><h1>' . ( $en ? 'Updates' : 'التحديثات' ) . '</h1>'
			. '<p>' . ( $en ? 'Install and update your Mersal plugins straight from mersal.it.' : 'ثبّت وحدّث إضافات مرسال مباشرةً من mersal.it.' ) . '</p></div>'
			. '<div class="actions"><button type="button" class="btn primary" id="mnotif-upd-check"><span class="mi sm">refresh</span>' . ( $en ? 'Check for updates' : 'تحقّق من التحديثات' ) . '</button></div></div>';

		// Auto-update preference row.
		echo '<div class="card" style="margin-bottom:16px;"><div class="card-body" style="display:flex;align-items:center;justify-content:space-between;gap:16px;flex-wrap:wrap;">';
		echo '<div class="row" style="gap:12px;"><div style="width:40px;height:40px;border-radius:10px;background:var(--accent-soft);color:var(--accent);display:grid;place-items:center;"><span class="mi">autorenew</span></div>';
		echo '<div><b style="display:block;color:var(--fg-1);">' . ( $en ? 'Automatic updates' : 'التحديثات التلقائية' ) . '</b><span style="font-size:12px;color:var(--fg-2);">' . ( $en ? 'Let WordPress install new Mersal versions automatically.' : 'اترك ووردبريس يثبّت إصدارات مرسال الجديدة تلقائيًا.' ) . '</span></div></div>';
		echo '<label class="mnotif-switch"><input type="checkbox" id="mnotif-upd-auto" ' . checked( $auto_on, true, false ) . '><span></span></label>';
		echo '</div></div>';

		// Plugin rows.
		echo '<div class="card"><div class="card-head"><div class="ico"><span class="mi">extension</span></div><div class="t"><b>' . ( $en ? 'Mersal plugins' : 'إضافات مرسال' ) . '</b><span>' . esc_html( wp_parse_url( $base, PHP_URL_HOST ) ) . '</span></div></div>';
		echo '<div id="mnotif-upd-list">';
		echo $this->render_rows_html( $rows, $en ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
		echo '</div></div>';

		$cfg = array(
			'ajax'  => admin_url( 'admin-ajax.php' ),
			'nonce' => wp_create_nonce( 'mnotif_updater' ),
			'i18n'  => array(
				'checking'  => $en ? 'Checking…' : 'جارٍ التحقّق…',
				'installing' => $en ? 'Installing…' : 'جارٍ التثبيت…',
				'updating'  => $en ? 'Updating…' : 'جارٍ التحديث…',
				'reloading' => $en ? 'Done. Reloading…' : 'تمّ. إعادة التحميل…',
				'failed'    => $en ? 'Failed' : 'فشل',
				'confirm'   => $en ? 'Install this version now?' : 'تثبيت هذا الإصدار الآن؟',
			),
		);
		echo '<script>window.MNOTIF_UPD=' . wp_json_encode( $cfg ) . ';</script>';
		echo '<script>' . $this->page_script() . '</script>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
	}

	/**
	 * Build the HTML for the plugin status rows.
	 *
	 * @param array $rows Snapshot rows.
	 * @param bool  $en   English UI.
	 * @return string
	 */
	private function render_rows_html( $rows, $en ) {
		$labels = array(
			'latest'        => $en ? 'Up to date' : 'مُحدَّث',
			'outdated'      => $en ? 'Update available' : 'تحديث متاح',
			'not_installed' => $en ? 'Not installed' : 'غير مثبَّت',
			'error'         => $en ? 'Unavailable' : 'غير متاح',
		);
		$chip = array(
			'latest'        => 'success',
			'outdated'      => 'warning',
			'not_installed' => 'info',
			'error'         => 'danger',
		);

		$html = '';
		foreach ( $rows as $r ) {
			$status = $r['status'];
			$html  .= '<div class="row" style="padding:16px 18px;border-top:1px solid var(--border);gap:14px;align-items:center;">';

			// Icon.
			$html .= '<div style="width:44px;height:44px;border-radius:10px;background:var(--surface-3);display:grid;place-items:center;overflow:hidden;flex:0 0 44px;">';
			if ( $r['icon'] ) {
				$html .= '<img src="' . esc_url( $r['icon'] ) . '" alt="" width="28" height="28" style="object-fit:contain;">';
			} else {
				$html .= '<span class="mi">extension</span>';
			}
			$html .= '</div>';

			// Name + versions.
			$ver = $en ? 'Installed' : 'المثبَّت';
			$lat = $en ? 'Latest' : 'الأحدث';
			$installed_txt = $r['installed'] ? esc_html( $r['installed'] ) : '—';
			$remote_txt    = $r['remote'] ? esc_html( $r['remote'] ) : '—';
			$html .= '<div style="flex:1;min-width:180px;"><b style="color:var(--fg-1);display:block;">' . esc_html( $r['name'] ) . '</b>';
			$html .= '<span style="font-size:12px;color:var(--fg-2);">' . $ver . ': <code>' . $installed_txt . '</code> · ' . $lat . ': <code>' . $remote_txt . '</code></span>';
			if ( $r['error'] ) {
				$html .= '<span style="font-size:12px;color:var(--danger);display:block;margin-top:2px;">' . esc_html( $r['error'] ) . '</span>';
			}
			$html .= '</div>';

			// Status chip.
			$html .= '<span class="chip ' . esc_attr( $chip[ $status ] ) . '">' . esc_html( $labels[ $status ] ) . '</span>';

			// Action button.
			if ( 'outdated' === $status ) {
				$html .= '<button type="button" class="btn primary sm mnotif-upd-do" data-basename="' . esc_attr( $r['basename'] ) . '" data-mode="update"><span class="mi sm">download</span>' . ( $en ? 'Update' : 'تحديث' ) . '</button>';
			} elseif ( 'not_installed' === $status ) {
				$html .= '<button type="button" class="btn primary sm mnotif-upd-do" data-basename="' . esc_attr( $r['basename'] ) . '" data-mode="install"><span class="mi sm">add</span>' . ( $en ? 'Install' : 'تثبيت' ) . '</button>';
			} elseif ( 'latest' === $status ) {
				$html .= '<button type="button" class="btn ghost sm" disabled><span class="mi sm">check</span>' . ( $en ? 'Current' : 'الحالي' ) . '</button>';
			}

			$html .= '</div>';
		}
		return $html;
	}

	/**
	 * Inline JavaScript for the Updates page (check / install / toggle).
	 *
	 * @return string
	 */
	private function page_script() {
		return <<<'JS'
(function(){
  var D = window.MNOTIF_UPD || {}; var i18n = D.i18n || {};
  function post(data, cb){
    data.nonce = D.nonce;
    var body = Object.keys(data).map(function(k){ return encodeURIComponent(k)+'='+encodeURIComponent(data[k]); }).join('&');
    fetch(D.ajax, { method:'POST', credentials:'same-origin', headers:{'Content-Type':'application/x-www-form-urlencoded'}, body: body })
      .then(function(r){ return r.json(); }).then(cb).catch(function(){ cb({success:false, data:{message:i18n.failed}}); });
  }
  var check = document.getElementById('mnotif-upd-check');
  if (check) check.addEventListener('click', function(){
    check.disabled = true; var old = check.innerHTML; check.textContent = i18n.checking || 'Checking…';
    post({ action:'mnotif_updater_check' }, function(){ window.location.reload(); });
  });
  var auto = document.getElementById('mnotif-upd-auto');
  if (auto) auto.addEventListener('change', function(){
    post({ action:'mnotif_updater_toggle_auto', enabled: auto.checked ? '1' : '0' }, function(res){
      if (!res || !res.success) auto.checked = !auto.checked;
    });
  });
  document.addEventListener('click', function(e){
    var btn = e.target.closest('.mnotif-upd-do'); if (!btn) return;
    if (!window.confirm(i18n.confirm || 'Install now?')) return;
    var mode = btn.getAttribute('data-mode');
    btn.disabled = true; btn.textContent = (mode === 'install' ? (i18n.installing||'Installing…') : (i18n.updating||'Updating…'));
    post({ action:'mnotif_updater_install', basename: btn.getAttribute('data-basename') }, function(res){
      if (res && res.success){ btn.textContent = i18n.reloading || 'Done…'; setTimeout(function(){ window.location.reload(); }, 1200); }
      else { btn.disabled = false; btn.textContent = (i18n.failed||'Failed'); window.alert((res && res.data && res.data.message) ? res.data.message : (i18n.failed||'Failed')); }
    });
  });
})();
JS;
	}
}
