<?php

/**
 * PeepSo3_Config_Index
 *
 * Builds a flat, searchable index of every active PeepSo configuration option across every config
 * tab — core AND module-registered — so the native WordPress Command Palette (⌘K) can surface and
 * jump to any setting. See the REST endpoint 3/api/rest/v1/config_search.php and the client
 * assets/js/admin/config-search.js.
 *
 * The index is derived the exact same way the config screen renders (see PeepSoConfig::render()):
 *
 *     PeepSoConfig::get_tabs()                     // core + module tabs (peepso_admin_config_tabs)
 *       -> new $tab['function']()                  // a PeepSoConfigSection* class
 *       -> register_config_groups()
 *       -> apply_filters('peepso_admin_register_config_group-{tab}', $obj->config_groups)
 *       -> config_groups[] -> group['fields'][]    // each field: name / label / type / descript
 *
 * IMPORTANT — build context: modules register their config tab only inside `if (is_admin())`
 * (e.g. peepso-core-groups/groups.php, peepso-core-chat/peepsomessages.php). WordPress's is_admin()
 * is FALSE during a REST request, so an index built inside the REST call would be core-only. The
 * index is therefore built on an admin-screen request (where the full tab set is registered) and
 * cached; the REST endpoint just serves the cache. Only a full (admin-context) build is ever
 * persisted, so a REST cold-miss can never poison the cache with a partial index.
 *
 * Building is also non-trivial (~a few seconds — some sections scandir() the filesystem), so it runs
 * on `shutdown`, after the response is flushed, via schedule_warm(). It is cached in Mayfly keyed on
 * locale + a hash of active PeepSo plugin versions, so it rebuilds only when the locale changes or a
 * PeepSo plugin is updated/toggled. `?debug` bypasses the cache.
 */
class PeepSo3_Config_Index {

	const CACHE_PREFIX = 'peepso_config_index_';
	const LOCK_KEY     = 'peepso_config_index_building';
	const SCHEMA       = 6; // bump when the stored index shape changes, to invalidate old caches

	/**
	 * Word-level synonyms, applied automatically to each option's label so alternate vocabulary finds
	 * it (e.g. "account delete" finds "Profile delete", "turn off" finds a "Disable …" option). Keys
	 * and values are lowercase whole words; matching is bidirectional where it makes sense. Extend
	 * per-option via the 'peepso_config_search_keywords' filter rather than bloating this list.
	 */
	private static $synonyms = array(
		'profile'      => array('account'),
		'account'      => array('profile'),
		'delete'       => array('remove', 'erase'),
		'remove'       => array('delete'),
		'avatar'       => array('profile picture', 'photo'),
		'enable'       => array('turn on', 'activate'),
		'disable'      => array('turn off', 'deactivate'),
		'email'        => array('mail', 'e-mail'),
		'registration' => array('sign up', 'signup', 'register'),
		'login'        => array('sign in', 'signin', 'log in'),
		'notification' => array('alert'),
		'moderation'   => array('report', 'abuse'),
	);

	/**
	 * Field 'type' values that are display-only (not real saved options) — excluded from the index.
	 * Interactive types (text, textarea, select, radio, checkbox, yesno_switch, datepicker, …) are
	 * kept. Taxonomy per classes/form.php render_field().
	 */
	private static $skip_types = array(
		'separator', 'message', 'warning', 'hidden', 'title', 'header', 'submit', 'html', 'custom', 'avatar',
	);

	/* ---------------------------------------------------------------------------------------------
	 * Public API
	 * ------------------------------------------------------------------------------------------ */

	/**
	 * Return the index. Served from cache when warm. On a cold cache it is built live and — only when
	 * built in a full (admin-screen) context — persisted; a partial build (e.g. from a REST request,
	 * where module tabs are not registered) is returned but never cached.
	 *
	 * @param bool $debug When TRUE, bypass the cache and rebuild live.
	 * @return array { meta, tabs[], options[], parents{} }
	 */
	public static function get($debug = FALSE) {
		if (!$debug) {
			$cached = self::cached();
			if (NULL !== $cached) {
				$cached['meta']['cache_hit'] = 1;
				$cached['meta']['complete']  = TRUE;
				return $cached;
			}
		}

		$data = self::build();

		if (!$debug && !empty($data['meta']['complete'])) {
			self::store($data);
		}

		return $data;
	}

	/**
	 * Schedule a background (post-response) build if the cache is cold. Call this on admin-screen page
	 * loads — that is the only context where the full tab set is registered. No-op once warm.
	 */
	public static function schedule_warm() {
		if (NULL !== self::cached()) {
			return;
		}
		add_action('shutdown', array(__CLASS__, 'warm'), 100);
	}

	/**
	 * Build + cache the index after the response has been sent. Admin-screen context only (so the
	 * full module tab set is present). Guarded by a short-lived lock to avoid concurrent rebuilds.
	 */
	public static function warm() {
		if (!is_admin() || NULL !== self::cached()) {
			return;
		}
		if (get_transient(self::LOCK_KEY)) {
			return;
		}
		set_transient(self::LOCK_KEY, 1, 2 * MINUTE_IN_SECONDS);

		// Flush + close the connection first so the (multi-second) build never delays the browser.
		if (function_exists('fastcgi_finish_request')) {
			@fastcgi_finish_request();
		}

		$data = self::build();
		if (!empty($data['meta']['complete'])) {
			self::store($data);
		}

		delete_transient(self::LOCK_KEY);
	}

	/* ---------------------------------------------------------------------------------------------
	 * Build
	 * ------------------------------------------------------------------------------------------ */

	/**
	 * Enumerate every indexable tab's config options. Each section is built defensively (output
	 * buffered, exceptions swallowed) so one misbehaving section can never abort the whole index.
	 * meta.complete reflects whether this ran with the full tab set (is_admin()).
	 *
	 * @return array
	 */
	public static function build() {
		$options  = array();
		$parents  = array();
		$tabs_out = array();

		$config = PeepSoConfig::get_instance();
		$tabs   = $config->get_tabs();

		foreach ((array) $tabs as $slug => $tab) {

			if (!self::is_indexable_tab($tab)) {
				continue;
			}

			$class     = $tab['function'];
			$tab_slug  = $tab['tab'];
			$tab_label = self::plain($tab['label']);
			$tab_cat   = isset($tab['cat']) ? $tab['cat'] : '';

			$groups = self::safe_build_groups($class, $tab_slug);
			if (NULL === $groups) {
				continue;
			}

			$has_field = FALSE;

			foreach ((array) $groups as $group) {
				if (empty($group['fields']) || !is_array($group['fields'])) {
					continue;
				}

				$group_title = isset($group['title']) ? self::plain($group['title']) : '';
				$first_field = '';

				foreach ($group['fields'] as $field) {
					if (empty($field['name']) || empty($field['type'])) {
						continue;
					}
					if (in_array($field['type'], self::$skip_types, TRUE)) {
						continue;
					}

					$name  = $field['name'];
					$label = isset($field['label']) ? self::plain($field['label']) : '';
					if ('' === $label) {
						// Reasonable fallback so a real option is never unlabeled / undiscoverable:
						// the field key rendered as words. A raw identifier — deliberately NOT
						// translated (it is language-independent).
						$label = ucfirst(self::humanize($name));
					}

					$descript = '';
					if (!empty($field['descript'])) {
						$descript = self::plain($field['descript']);
					} else if (!empty($field['description']) && is_string($field['description'])) {
						$descript = self::plain($field['description']);
					}

					// Formal parent -> child links (data-children) power the "hidden child" landing UX.
					if (!empty($field['children']) && is_array($field['children'])) {
						foreach ($field['children'] as $child) {
							if (is_string($child) && '' !== $child) {
								$parents[$child] = $name;
							}
						}
					}

					$entry = array(
						'name'      => $name,
						'label'     => $label,
						'descript'  => $descript,
						'type'      => $field['type'],
						'tab'       => $tab_slug,
						'tab_label' => $tab_label,
						'tab_cat'   => $tab_cat,
						'group'     => $group_title,
						'anchor'    => 'field_' . $name,
						'url'       => admin_url('admin.php?page=peepso_config&tab=' . $tab_slug) . '#field_' . $name,
					);

					// Extra search keywords / synonyms (auto word-synonyms + the filter). Only stored
					// when present, to keep the payload lean.
					$keywords = self::keywords($name, $label, $tab_slug);
					if (!empty($keywords)) {
						$entry['keywords'] = $keywords;
					}

					if ('' === $first_field) {
						$first_field = $name; // the group's first indexed field = its anchor
					}

					$options[] = $entry;
					$has_field = TRUE;
				}

				// A result for the whole group ("box"), e.g. "Accounts & Security › Registration", so
				// users can jump to a section, not just a field. Anchored at the group's first field
				// (#psgroup_…); the landing script highlights the enclosing metabox.
				if ('' !== $group_title && '' !== $first_field) {
					$gname  = !empty($group['name']) ? $group['name'] : $first_field;
					$gentry = array(
						'name'      => $gname,
						'label'     => $group_title,
						'is_group'  => TRUE,
						'tab'       => $tab_slug,
						'tab_label' => $tab_label,
						'tab_cat'   => $tab_cat,
						'anchor'    => 'psgroup_' . $first_field,
						'url'       => admin_url('admin.php?page=peepso_config&tab=' . $tab_slug) . '#psgroup_' . $first_field,
					);

					$gkw = self::keywords($gname, $group_title, $tab_slug);
					if (!empty($gkw)) {
						$gentry['keywords'] = $gkw;
					}

					$options[] = $gentry;
				}
			}

			if ($has_field) {
				$tabs_out[] = array('tab' => $tab_slug, 'label' => $tab_label, 'cat' => $tab_cat);
			}
		}

		// Also index the tool screens — the Manage (Profile Fields, Reactions, VIP Icons, …) and Queue
		// (Outgoing email, GDPR requests, Video Uploads, …) tabs — as navigable "Manage › X" results.
		$options = array_merge($options, self::screens());

		return array(
			'meta' => array(
				'locale'    => get_locale(),
				'count'     => count($options),
				'complete'  => (bool) is_admin(), // full tab set only registers on admin-screen requests
				'cache_hit' => 0,
			),
			'tabs'    => $tabs_out,
			'options' => $options,
			'parents' => $parents,
		);
	}

	/**
	 * Instantiate a section class and return its config_groups (with the per-tab injection filter
	 * applied), or NULL if the class is not a usable section or building it failed.
	 */
	private static function safe_build_groups($class, $tab_slug) {
		$groups = NULL;

		ob_start();
		try {
			$section = new $class();

			if ($section instanceof PeepSoConfigSectionAbstract) {
				$section->register_config_groups();
				$section->config_groups = apply_filters(
					'peepso_admin_register_config_group-' . $tab_slug,
					$section->config_groups
				);
				$groups = $section->config_groups;
			}
		} catch (Throwable $e) {
			$groups = NULL;
		}
		ob_end_clean();

		return $groups;
	}

	/**
	 * A tab is indexable when it names a real, loadable PeepSoConfigSection* class and is not an
	 * external-link tab. Skips: the 'email' tab (no 'function', custom flow), callback-array tabs,
	 * and external URL tabs such as the 'free' installer link.
	 */
	private static function is_indexable_tab($tab) {
		if (empty($tab['function']) || !is_string($tab['function'])) {
			return FALSE;
		}
		if (empty($tab['tab']) || 'http' === substr($tab['tab'], 0, 4)) {
			return FALSE;
		}
		// class_exists() triggers the legacy PeepSo autoloader for active module section classes.
		if (!class_exists($tab['function'])) {
			return FALSE;
		}

		return TRUE;
	}

	/* ---------------------------------------------------------------------------------------------
	 * Cache
	 * ------------------------------------------------------------------------------------------ */

	/** @return array|null The cached index, or NULL when cold. */
	public static function cached() {
		$c = PeepSo3_Mayfly::get(self::key());

		return (is_array($c) && isset($c['options'])) ? $c : NULL;
	}

	private static function store($data) {
		PeepSo3_Mayfly::set(self::key(), $data, WEEK_IN_SECONDS);
	}

	private static function key() {
		return self::CACHE_PREFIX . self::SCHEMA . '_' . get_locale() . '_' . self::versions_hash();
	}

	/* ---------------------------------------------------------------------------------------------
	 * Helpers
	 * ------------------------------------------------------------------------------------------ */

	/**
	 * Strip HTML/markup from a (translated) label or description down to plain, searchable text.
	 * Config labels can carry inline markup (e.g. license fields with <small>/<span>).
	 */
	private static function plain($html) {
		return trim(wp_strip_all_tags((string) $html));
	}

	/**
	 * A field key rendered as words, e.g. "always_link_to_peepso_profile" -> "always link to peepso
	 * profile". A raw code identifier — deliberately NOT translated (it is language-independent), used
	 * as extra searchable text (client-side) and as a last-resort label fallback.
	 */
	private static function humanize($name) {
		return trim(preg_replace('/\s+/', ' ', str_replace(array('_', '-'), ' ', (string) $name)));
	}

	/**
	 * Index the PeepSo tool screens as navigable results: every tab of the Manage and Queue admin
	 * pages (core defaults + whatever modules register via peepso_admin_manage_tabs /
	 * peepso_admin_queue_tabs). These are whole screens, so each is a single "Manage › X" / "Queue › X"
	 * result that navigates to the page + tab. Built defensively — get_tabs() runs DB counts and reads
	 * options, so one failure must not abort the index.
	 *
	 * @return array
	 */
	private static function screens() {
		$out = array();

		$sources = array(
			array('class' => 'PeepSoManage', 'page' => 'peepso-manage', 'label' => __('Manage', 'peepso-core')),
			array('class' => 'PeepSoQueue',  'page' => 'peepso-queue',  'label' => __('Queue', 'peepso-core')),
		);

		foreach ($sources as $src) {
			if (!class_exists($src['class'])) {
				continue;
			}

			$tabs = NULL;
			ob_start();
			try {
				$obj  = call_user_func(array($src['class'], 'get_instance'));
				$tabs = $obj->get_tabs();
			} catch (Throwable $e) {
				$tabs = NULL;
			}
			ob_end_clean();

			if (!is_array($tabs)) {
				continue;
			}

			$screen_label = self::plain($src['label']);

			foreach ($tabs as $slug => $tab) {
				if (empty($tab['tab']) || 'http' === substr($tab['tab'], 0, 4)) {
					continue; // external-link tabs
				}

				$tab_slug = $tab['tab'];
				$label    = !empty($tab['label']) ? self::plain($tab['label']) : ucfirst(self::humanize($tab_slug));
				if ('' === $label) {
					continue;
				}

				$entry = array(
					'name'      => $tab_slug,
					'label'     => $label,
					'is_group'  => TRUE,           // shown as "Manage › Reactions" like a section result
					'tab'       => $src['page'],   // command-id uniqueness + navigation page
					'tab_label' => $screen_label,  // "Manage" / "Queue"
					'tab_cat'   => isset($tab['cat']) ? $tab['cat'] : '',
					'anchor'    => '',
					'url'       => admin_url('admin.php?page=' . $src['page'] . '&tab=' . $tab_slug),
				);

				// Associate the screen with its page word ("queue" / "manage") as a first-class keyword
				// so natural phrasings like "mail queue", "video queue" or "manage groups" reliably
				// surface it (rather than relying only on the weaker tab-label match).
				$kw   = self::keywords($tab_slug, $label, $src['page']);
				$kw[] = strtolower($screen_label);
				$kw   = array_values(array_unique($kw));
				if (!empty($kw)) {
					$entry['keywords'] = $kw;
				}

				$out[] = $entry;
			}
		}

		return $out;
	}

	/**
	 * Extra search keywords for an option: automatic word-level synonyms derived from the label, plus
	 * any supplied via the 'peepso_config_search_keywords' filter (for precise per-option aliases).
	 * Returns a de-duplicated, trimmed list of plain strings.
	 *
	 * @param string $name  Option/field name.
	 * @param string $label Plain-text label.
	 * @param string $tab   Tab slug.
	 * @return array
	 */
	private static function keywords($name, $label, $tab) {
		$kw = array();

		// Auto word-level synonyms from the label's words.
		$words = preg_split('/[^a-z0-9]+/i', strtolower($label), -1, PREG_SPLIT_NO_EMPTY);
		foreach ((array) $words as $word) {
			if (isset(self::$synonyms[$word])) {
				foreach (self::$synonyms[$word] as $syn) {
					$kw[] = $syn;
				}
			}
		}

		/**
		 * Filter the extra search keywords/synonyms for a single config option. Return additional
		 * plain-text strings to make the option findable by alternate vocabulary.
		 *
		 * @param array  $kw    Keywords collected so far.
		 * @param string $name  Option/field name (e.g. 'site_delete_account').
		 * @param string $tab   Tab slug (e.g. 'accounts').
		 * @param string $label Plain-text label.
		 */
		$kw = apply_filters('peepso_config_search_keywords', $kw, $name, $tab, $label);

		// Normalize: plain trimmed strings, unique (case-insensitive), no empties.
		$out  = array();
		$seen = array();
		foreach ((array) $kw as $k) {
			$k  = trim(wp_strip_all_tags((string) $k));
			$lk = strtolower($k);
			if ('' === $k || isset($seen[$lk])) {
				continue;
			}
			$seen[$lk] = TRUE;
			$out[] = $k;
		}

		return $out;
	}

	/**
	 * Short hash of the active PeepSo plugin set + core version. Changes whenever a PeepSo plugin is
	 * updated, activated or deactivated, auto-invalidating the cached index.
	 */
	private static function versions_hash() {
		$parts = array(PeepSo::PLUGIN_VERSION);

		foreach ((array) get_option('active_plugins', array()) as $plugin) {
			if (FALSE !== stripos($plugin, 'peepso')) {
				$parts[] = $plugin;
			}
		}

		sort($parts);

		return substr(md5(implode('|', $parts)), 0, 12);
	}
}

// EOF
